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
+41 -34
View File
@@ -465,10 +465,15 @@ class TestPeerEndpointAccessControl:
class TestDNSZoneRecords:
"""
Verify that network_manager._build_dns_records() generates the correct IPs.
api and webui must point to Caddy (not their container IPs) so Caddy can
reverse-proxy them — their containers don't listen on port 80.
All service names now resolve to the WG server IP (10.0.0.1) rather than
Docker VIPs. ensure_service_dnat() routes wg0:80 → Caddy; Caddy routes to
the correct backend by Host header. This allows cross-cell peers to reach
services without Docker bridge subnet conflicts.
"""
_WG_SERVER_IP = '10.0.0.1'
def setUp(self):
pass
@@ -477,59 +482,59 @@ class TestDNSZoneRecords:
mgr = nm.NetworkManager.__new__(nm.NetworkManager)
return mgr._build_dns_records(cell_name, ip_range)
def test_api_resolves_to_caddy_not_api_container(self):
def test_api_resolves_to_wg_server_ip(self):
records = self._records()
api_rec = next((r for r in records if r['name'] == 'api'), None)
assert api_rec is not None, "No DNS record for 'api'"
assert api_rec['value'] == '172.20.0.2', (
f"api.dev should resolve to Caddy (172.20.0.2), not the API container "
f"(172.20.0.10); got {api_rec['value']}"
assert api_rec['value'] == self._WG_SERVER_IP, (
f"api.dev should resolve to WG server IP ({self._WG_SERVER_IP}); "
f"got {api_rec['value']}"
)
def test_webui_resolves_to_caddy_not_webui_container(self):
def test_webui_resolves_to_wg_server_ip(self):
records = self._records()
rec = next((r for r in records if r['name'] == 'webui'), None)
assert rec is not None, "No DNS record for 'webui'"
assert rec['value'] == '172.20.0.2', (
f"webui.dev should resolve to Caddy (172.20.0.2), not the WebUI container "
f"(172.20.0.11); got {rec['value']}"
assert rec['value'] == self._WG_SERVER_IP, (
f"webui.dev should resolve to WG server IP ({self._WG_SERVER_IP}); "
f"got {rec['value']}"
)
def test_calendar_uses_vip(self):
def test_calendar_resolves_to_wg_server_ip(self):
records = self._records()
rec = next((r for r in records if r['name'] == 'calendar'), None)
assert rec and rec['value'] == '172.20.0.21', \
f"calendar.dev VIP should be 172.20.0.21; got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"calendar.dev should resolve to WG server IP; got {rec}"
def test_files_uses_vip(self):
def test_files_resolves_to_wg_server_ip(self):
records = self._records()
rec = next((r for r in records if r['name'] == 'files'), None)
assert rec and rec['value'] == '172.20.0.22', \
f"files.dev VIP should be 172.20.0.22; got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"files.dev should resolve to WG server IP; got {rec}"
def test_mail_uses_vip(self):
def test_mail_resolves_to_wg_server_ip(self):
records = self._records()
rec = next((r for r in records if r['name'] == 'mail'), None)
assert rec and rec['value'] == '172.20.0.23', \
f"mail.dev VIP should be 172.20.0.23; got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"mail.dev should resolve to WG server IP; got {rec}"
def test_webmail_uses_mail_vip(self):
def test_webmail_resolves_to_wg_server_ip(self):
records = self._records()
rec = next((r for r in records if r['name'] == 'webmail'), None)
assert rec and rec['value'] == '172.20.0.23', \
f"webmail.dev should share the mail VIP 172.20.0.23; got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"webmail.dev should resolve to WG server IP; got {rec}"
def test_webdav_uses_vip(self):
def test_webdav_resolves_to_wg_server_ip(self):
records = self._records()
rec = next((r for r in records if r['name'] == 'webdav'), None)
assert rec and rec['value'] == '172.20.0.24', \
f"webdav.dev VIP should be 172.20.0.24; got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"webdav.dev should resolve to WG server IP; got {rec}"
def test_cell_name_resolves_to_caddy(self):
def test_cell_name_resolves_to_wg_server_ip(self):
records = self._records(cell_name='mypic')
rec = next((r for r in records if r['name'] == 'mypic'), None)
assert rec and rec['value'] == '172.20.0.2', \
f"mypic.dev should resolve to Caddy (172.20.0.2); got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"mypic.dev should resolve to WG server IP; got {rec}"
def test_all_records_are_type_a(self):
records = self._records()
@@ -540,21 +545,23 @@ class TestDNSZoneRecords:
class TestDNSZoneRecordsWithPytest:
"""Same as above but using pytest-style (no setUp/tearDown)."""
_WG_SERVER_IP = '10.0.0.1'
@pytest.fixture
def records(self):
import network_manager as nm
mgr = nm.NetworkManager.__new__(nm.NetworkManager)
return mgr._build_dns_records('pic0', '172.20.0.0/16')
def test_api_resolves_to_caddy(self, records):
def test_api_resolves_to_wg_server_ip(self, records):
rec = next((r for r in records if r['name'] == 'api'), None)
assert rec and rec['value'] == '172.20.0.2', \
f"api.dev should point to Caddy (172.20.0.2); got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"api.dev should point to WG server IP ({self._WG_SERVER_IP}); got {rec}"
def test_webui_resolves_to_caddy(self, records):
def test_webui_resolves_to_wg_server_ip(self, records):
rec = next((r for r in records if r['name'] == 'webui'), None)
assert rec and rec['value'] == '172.20.0.2', \
f"webui.dev should point to Caddy (172.20.0.2); got {rec}"
assert rec and rec['value'] == self._WG_SERVER_IP, \
f"webui.dev should point to WG server IP ({self._WG_SERVER_IP}); got {rec}"
# ─────────────────── Caddyfile generation ─────────────────────────────────────