feat: fix cross-cell service access — DNS DNAT, service DNAT, Caddy routing

DNS A records now return the WireGuard server IP (10.0.0.1) instead of
Docker bridge VIPs so cross-cell peers resolve service names correctly
regardless of their bridge subnet. DNAT rules (wg0:53→cell-dns:53 and
wg0:80→cell-caddy:80) are applied at startup. Caddy routes by Host header,
eliminating the Docker bridge subnet conflict. Firewall cell rules allow
DNS and service (Caddy) traffic from linked cell subnets. Split-tunnel
AllowedIPs now dynamically includes connected-cell VPN subnets and drops
the 172.20.0.0/16 range. Peers with route_via set now receive full-tunnel
config (0.0.0.0/0) so all their traffic exits via the remote cell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 03:12:09 -04:00
parent f2f15eb17e
commit 9a800e3b6b
11 changed files with 325 additions and 146 deletions
+15 -11
View File
@@ -284,20 +284,24 @@ class TestBootstrapDnsRecords(unittest.TestCase):
self.assertTrue(os.path.exists(zone_file))
@patch('subprocess.run')
def test_contains_default_caddy_ip(self, _mock):
def test_contains_wg_server_ip(self, _mock):
"""Zone file records now use WG server IP (10.0.0.1) not Docker VIPs."""
self.nm.bootstrap_dns_records('mycell', 'cell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
content = open(zone_file).read()
self.assertIn('172.20.0.2', content) # caddy
self.assertIn('10.0.0.1', content) # WG server IP for all services
self.assertNotIn('172.20.0.2', content) # Caddy VIP no longer in zone
self.assertNotIn('172.20.0.21', content) # Service VIPs no longer in zone
@patch('subprocess.run')
def test_custom_ip_range_used(self, _mock):
def test_custom_ip_range_does_not_affect_service_ips(self, _mock):
"""ip_range is no longer used for service record IPs; WG server IP is used."""
self.nm.bootstrap_dns_records('mycell', 'cell', ip_range='10.5.0.0/24')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
content = open(zone_file).read()
self.assertIn('10.5.0.2', content) # caddy
self.assertIn('10.5.0.21', content) # vip_calendar
self.assertNotIn('172.20', content)
self.assertIn('10.0.0.1', content) # WG server IP
self.assertNotIn('10.5.0.2', content) # old caddy pattern gone
self.assertNotIn('10.5.0.21', content) # old VIP pattern gone
@patch('subprocess.run')
def test_idempotent_skips_existing_zone(self, _mock):
@@ -324,15 +328,15 @@ class TestApplyIpRange(unittest.TestCase):
shutil.rmtree(self.test_dir)
@patch('subprocess.run')
def test_zone_file_updated_with_new_ips(self, _mock):
# Bootstrap with default range, then change to 10.0.0.0/24
def test_zone_file_updated_with_wg_server_ip(self, _mock):
"""apply_ip_range regenerates zone with WG server IP for all service records."""
self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16')
result = self.nm.apply_ip_range('10.0.0.0/24', 'mycell', 'cell')
zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone')
content = open(zone_file).read()
self.assertIn('10.0.0.2', content) # caddy
self.assertIn('10.0.0.21', content) # vip_calendar
self.assertNotIn('172.20', content)
self.assertIn('10.0.0.1', content) # WG server IP for all services
self.assertNotIn('172.20.0.2', content) # old Caddy pattern gone
self.assertNotIn('172.20.0.21', content) # old VIP pattern gone
@patch('subprocess.run')
def test_returns_restarted_on_success(self, _mock):