fix: split-horizon DNS zone uses WireGuard IP, not Docker bridge IP
Unit Tests / test (push) Successful in 7m31s

VPN peers can reach Caddy via the host's WireGuard interface (10.0.0.1),
not via the Docker bridge IP (172.20.0.2) which is unreachable outside
the container network. _bootstrap_dns now calls _get_wg_server_ip()
instead of ip_utils.get_service_ips() so the internal zone returns a
routable address for service subdomains.

Also log config save failures instead of silently swallowing them —
the silent PermissionError/OSError was masking write failures and
making it impossible to diagnose why installed services disappeared
after container restarts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 02:11:01 -04:00
parent e4c80149f4
commit bd71466a87
4 changed files with 94 additions and 4 deletions
+55
View File
@@ -639,5 +639,60 @@ class TestUpdateSplitHorizonZoneStaleCleanup(unittest.TestCase):
self.assertTrue(os.path.exists(current_zone))
class TestGetWgServerIp(unittest.TestCase):
"""_get_wg_server_ip must read from wg0.conf and fall back to 10.0.0.1.
Regression guard: _bootstrap_dns used to pass 172.20.0.2 (Docker bridge IP)
to update_split_horizon_zone. WireGuard peers cannot reach that IP; the zone
must use the WireGuard server IP (e.g. 10.0.0.1) so VPN clients can reach Caddy.
"""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.data_dir = os.path.join(self.test_dir, 'data')
self.config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(os.path.join(self.data_dir, 'dns'), exist_ok=True)
os.makedirs(os.path.join(self.config_dir, 'dns'), exist_ok=True)
self.nm = NetworkManager(self.data_dir, self.config_dir)
def tearDown(self):
shutil.rmtree(self.test_dir)
def _write_wg_conf(self, address: str) -> None:
wg_dir = os.path.join(self.config_dir, 'wireguard', 'wg_confs')
os.makedirs(wg_dir, exist_ok=True)
with open(os.path.join(wg_dir, 'wg0.conf'), 'w') as f:
f.write(f'[Interface]\nAddress = {address}\nListenPort = 51820\n')
def test_reads_address_from_wg0_conf(self):
self._write_wg_conf('10.0.0.1/24')
self.assertEqual(self.nm._get_wg_server_ip(), '10.0.0.1')
def test_reads_non_default_address(self):
self._write_wg_conf('10.8.0.1/16')
self.assertEqual(self.nm._get_wg_server_ip(), '10.8.0.1')
def test_falls_back_to_10_0_0_1_when_conf_missing(self):
self.assertEqual(self.nm._get_wg_server_ip(), '10.0.0.1')
def test_split_horizon_zone_uses_wg_ip_not_docker_bridge(self):
"""update_split_horizon_zone called with WG IP writes that IP in zone file.
This is the correct call pattern from _bootstrap_dns: pass the WireGuard
server IP, not 172.20.0.x (Docker bridge IP unreachable from VPN peers).
"""
self._write_wg_conf('10.0.0.1/24')
wg_ip = self.nm._get_wg_server_ip()
self.assertEqual(wg_ip, '10.0.0.1',
'WireGuard IP must be read from wg0.conf, not be a Docker bridge address')
with patch('subprocess.run'):
self.nm.update_split_horizon_zone('pic1.pic.ngo', wg_ip)
zone_path = os.path.join(self.data_dir, 'dns', 'pic1.pic.ngo.zone')
content = open(zone_path).read()
self.assertIn('10.0.0.1', content)
self.assertNotIn('172.20.0', content,
'Zone must not contain Docker bridge IP — VPN peers cannot reach it')
if __name__ == '__main__':
unittest.main()