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:
@@ -205,6 +205,8 @@ class TestGenerateCorefileWithCellLinks(unittest.TestCase):
|
||||
class TestApplyPeerRules(unittest.TestCase):
|
||||
"""Verify correct iptables calls for full-internet vs split-tunnel peers."""
|
||||
|
||||
_FAKE_CADDY_IP = '172.20.0.2'
|
||||
|
||||
def _run_apply(self, peer_ip, settings):
|
||||
calls_made = []
|
||||
|
||||
@@ -215,7 +217,9 @@ class TestApplyPeerRules(unittest.TestCase):
|
||||
m.stdout = ''
|
||||
return m
|
||||
|
||||
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec):
|
||||
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
|
||||
patch.object(firewall_manager, '_get_caddy_container_ip',
|
||||
return_value=self._FAKE_CADDY_IP):
|
||||
firewall_manager.apply_peer_rules(peer_ip, settings)
|
||||
|
||||
return calls_made
|
||||
@@ -239,23 +243,23 @@ class TestApplyPeerRules(unittest.TestCase):
|
||||
self.assertIn('DROP', targets)
|
||||
self.assertIn('ACCEPT', targets)
|
||||
|
||||
def test_service_access_restriction_generates_drop(self):
|
||||
def test_service_access_restriction_uses_caddy_rule(self):
|
||||
"""service_access controls access via a single Caddy ACCEPT/DROP rule, not per-VIP rules."""
|
||||
calls = self._run_apply('10.0.0.4', {'internet_access': False,
|
||||
'service_access': ['calendar'],
|
||||
'peer_access': True})
|
||||
iptables_calls = [c for c in calls if 'iptables' in c]
|
||||
# files/mail/webdav should be DROPped, calendar ACCEPTed
|
||||
targets_with_ips = [
|
||||
(c[c.index('-d') + 1], c[c.index('-j') + 1])
|
||||
for c in iptables_calls
|
||||
if '-d' in c and '-j' in c
|
||||
]
|
||||
svc_rules = {ip: t for ip, t in targets_with_ips
|
||||
if ip in firewall_manager.SERVICE_IPS.values()}
|
||||
calendar_ip = firewall_manager.SERVICE_IPS['calendar']
|
||||
files_ip = firewall_manager.SERVICE_IPS['files']
|
||||
self.assertEqual(svc_rules.get(calendar_ip), 'ACCEPT')
|
||||
self.assertEqual(svc_rules.get(files_ip), 'DROP')
|
||||
# Caddy rule should be ACCEPT (any non-empty service_access)
|
||||
caddy_rules = [c for c in iptables_calls
|
||||
if '-d' in c and self._FAKE_CADDY_IP in c
|
||||
and '--dport' in c and '80' in c]
|
||||
self.assertTrue(caddy_rules, "Expected a Caddy port-80 rule for service access")
|
||||
target = caddy_rules[-1][caddy_rules[-1].index('-j') + 1]
|
||||
self.assertEqual(target, 'ACCEPT', "Non-empty service_access should ACCEPT Caddy")
|
||||
# No per-VIP rules — per-service control is at DNS ACL level
|
||||
for svc_ip in firewall_manager.SERVICE_IPS.values():
|
||||
vip_rules = [c for c in iptables_calls if '-d' in c and svc_ip in c]
|
||||
self.assertFalse(vip_rules, f"No per-VIP FORWARD rules expected for {svc_ip}")
|
||||
|
||||
def test_all_rules_tagged_with_peer_comment(self):
|
||||
calls = self._run_apply('10.0.0.2', {'internet_access': True,
|
||||
@@ -380,18 +384,21 @@ class TestUpdateServiceIps(unittest.TestCase):
|
||||
self.assertEqual(set(firewall_manager.SERVICE_IPS.keys()),
|
||||
{'calendar', 'files', 'mail', 'webdav'})
|
||||
|
||||
def test_apply_peer_rules_uses_updated_ips(self):
|
||||
def test_apply_peer_rules_uses_caddy_not_vips(self):
|
||||
"""Service access uses Caddy IP for FORWARD rules, not SERVICE_IPS VIPs."""
|
||||
firewall_manager.update_service_ips('10.0.0.0/24')
|
||||
called_with = []
|
||||
_CADDY_IP = '172.20.0.2'
|
||||
|
||||
def fake_wg_exec(args):
|
||||
called_with.append(args)
|
||||
m = MagicMock()
|
||||
m.returncode = 1 # simulate rule-doesn't-exist → _ensure_rule inserts
|
||||
m.returncode = 1
|
||||
return m
|
||||
|
||||
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
|
||||
patch.object(firewall_manager, 'clear_peer_rules'):
|
||||
patch.object(firewall_manager, 'clear_peer_rules'), \
|
||||
patch.object(firewall_manager, '_get_caddy_container_ip', return_value=_CADDY_IP):
|
||||
firewall_manager.apply_peer_rules('10.0.0.5', {
|
||||
'internet_access': True,
|
||||
'service_access': ['calendar'],
|
||||
@@ -400,9 +407,10 @@ class TestUpdateServiceIps(unittest.TestCase):
|
||||
|
||||
iptables_calls = [c for c in called_with if c and c[0] == 'iptables']
|
||||
dest_ips = [c[c.index('-d') + 1] for c in iptables_calls if '-d' in c]
|
||||
# calendar vIP should now be 10.0.0.21
|
||||
self.assertIn('10.0.0.21', dest_ips)
|
||||
# old IP must not appear
|
||||
# Caddy IP should appear for service access
|
||||
self.assertIn(_CADDY_IP, dest_ips)
|
||||
# VIPs (old or updated) must not appear — service access is via Caddy
|
||||
self.assertNotIn('10.0.0.21', dest_ips)
|
||||
self.assertNotIn('172.20.0.21', dest_ips)
|
||||
|
||||
|
||||
@@ -416,9 +424,11 @@ class TestCellRules(unittest.TestCase):
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
_FAKE_API_IP = '172.20.0.10'
|
||||
_FAKE_CADDY_IP = '172.20.0.2'
|
||||
_FAKE_DNS_IP = '172.20.0.3'
|
||||
|
||||
def _capture_apply(self, cell_name, vpn_subnet, inbound_services):
|
||||
"""Run apply_cell_rules with _wg_exec and _get_cell_api_ip mocked."""
|
||||
"""Run apply_cell_rules with _wg_exec and container IP helpers mocked."""
|
||||
calls_made = []
|
||||
|
||||
def fake_wg_exec(args):
|
||||
@@ -429,7 +439,9 @@ class TestCellRules(unittest.TestCase):
|
||||
return m
|
||||
|
||||
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
|
||||
patch.object(firewall_manager, '_get_cell_api_ip', return_value=self._FAKE_API_IP):
|
||||
patch.object(firewall_manager, '_get_cell_api_ip', return_value=self._FAKE_API_IP), \
|
||||
patch.object(firewall_manager, '_get_caddy_container_ip', return_value=self._FAKE_CADDY_IP), \
|
||||
patch.object(firewall_manager, '_get_dns_container_ip', return_value=self._FAKE_DNS_IP):
|
||||
firewall_manager.apply_cell_rules(cell_name, vpn_subnet, inbound_services)
|
||||
|
||||
return [c for c in calls_made if 'iptables' in c]
|
||||
@@ -480,18 +492,18 @@ class TestCellRules(unittest.TestCase):
|
||||
self.assertTrue(subnet_drops, "Expected a catch-all DROP rule for the subnet")
|
||||
|
||||
def test_apply_cell_rules_sends_accept_for_allowed_service(self):
|
||||
"""apply_cell_rules inserts ACCEPT for the calendar VIP when calendar is in inbound."""
|
||||
"""apply_cell_rules inserts Caddy ACCEPT when inbound_services is non-empty."""
|
||||
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
||||
calendar_ip = firewall_manager.SERVICE_IPS['calendar']
|
||||
calendar_targets = self._targets_for_dest(calls, calendar_ip)
|
||||
self.assertIn('ACCEPT', calendar_targets)
|
||||
caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP)
|
||||
self.assertIn('ACCEPT', caddy_targets,
|
||||
"Expected ACCEPT to Caddy when inbound_services is non-empty")
|
||||
|
||||
def test_apply_cell_rules_sends_drop_for_disallowed_service(self):
|
||||
"""apply_cell_rules inserts DROP for a service not in inbound_services."""
|
||||
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
||||
files_ip = firewall_manager.SERVICE_IPS['files']
|
||||
files_targets = self._targets_for_dest(calls, files_ip)
|
||||
self.assertIn('DROP', files_targets)
|
||||
def test_apply_cell_rules_no_caddy_accept_when_no_inbound(self):
|
||||
"""apply_cell_rules does NOT insert Caddy ACCEPT when inbound_services is empty."""
|
||||
calls = self._capture_apply('office', '10.0.1.0/24', [])
|
||||
caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP)
|
||||
self.assertNotIn('ACCEPT', caddy_targets,
|
||||
"No Caddy ACCEPT expected when inbound_services is empty")
|
||||
|
||||
def test_apply_cell_rules_accepts_api_sync_traffic(self):
|
||||
"""apply_cell_rules inserts ACCEPT for cell-api:3000 so permission-sync pushes pass."""
|
||||
@@ -517,7 +529,9 @@ class TestCellRules(unittest.TestCase):
|
||||
m = MagicMock(); m.returncode = 0; m.stdout = ''; return m
|
||||
|
||||
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
|
||||
patch.object(firewall_manager, '_get_cell_api_ip', return_value='172.20.0.10'):
|
||||
patch.object(firewall_manager, '_get_cell_api_ip', return_value='172.20.0.10'), \
|
||||
patch.object(firewall_manager, '_get_caddy_container_ip', return_value='172.20.0.2'), \
|
||||
patch.object(firewall_manager, '_get_dns_container_ip', return_value='172.20.0.3'):
|
||||
firewall_manager.apply_cell_rules('office', '10.0.1.0/24', [])
|
||||
|
||||
# The API-sync ACCEPT must be the LAST -I FORWARD insertion so it sits at position 1
|
||||
@@ -527,26 +541,28 @@ class TestCellRules(unittest.TestCase):
|
||||
|
||||
# ── apply_cell_rules — empty inbound (all-deny) ───────────────────────────
|
||||
|
||||
def test_apply_cell_rules_empty_inbound_all_drop(self):
|
||||
"""With inbound_services=[], all per-service rules are DROP."""
|
||||
def test_apply_cell_rules_empty_inbound_no_service_accept(self):
|
||||
"""With inbound_services=[], no service ACCEPT is added; catch-all DROP blocks traffic."""
|
||||
calls = self._capture_apply('office', '10.0.1.0/24', [])
|
||||
# No ACCEPT to Caddy
|
||||
caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP)
|
||||
self.assertNotIn('ACCEPT', caddy_targets,
|
||||
"No Caddy ACCEPT expected with empty inbound_services")
|
||||
# No per-VIP rules at all
|
||||
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
|
||||
svc_targets = self._targets_for_dest(calls, svc_ip)
|
||||
self.assertTrue(svc_targets,
|
||||
f"Expected at least one rule for {service} ({svc_ip})")
|
||||
self.assertNotIn('ACCEPT', svc_targets,
|
||||
f"{service} should be DROP when not in inbound_services")
|
||||
self.assertFalse(svc_targets,
|
||||
f"No per-VIP rules expected for {service} ({svc_ip})")
|
||||
|
||||
# ── apply_cell_rules — all inbound (all-accept) ───────────────────────────
|
||||
|
||||
def test_apply_cell_rules_all_inbound_all_accept(self):
|
||||
"""With all four services in inbound, all per-service rules are ACCEPT."""
|
||||
def test_apply_cell_rules_all_inbound_caddy_accept(self):
|
||||
"""With all four services in inbound, an ACCEPT rule is added for Caddy port 80."""
|
||||
all_services = list(firewall_manager.SERVICE_IPS.keys())
|
||||
calls = self._capture_apply('office', '10.0.1.0/24', all_services)
|
||||
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
|
||||
svc_targets = self._targets_for_dest(calls, svc_ip)
|
||||
self.assertIn('ACCEPT', svc_targets,
|
||||
f"{service} should be ACCEPT when in inbound_services")
|
||||
caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP)
|
||||
self.assertIn('ACCEPT', caddy_targets,
|
||||
"Expected ACCEPT to Caddy when all services are in inbound_services")
|
||||
|
||||
# ── apply_cell_rules — all rules tagged ───────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user