fix: complete cross-cell peer-sync push (domain SNI + source-preserving NAT)
Unit Tests / test (push) Successful in 9m45s
Unit Tests / test (push) Successful in 9m45s
Finishes the transport repair (L1+L2 landed in 714fb9b). The push now works
end-to-end between linked cells — verified live: offer/permission state
propagates automatically and the cell_relay derives/reverts without manual steps.
L3 — push by domain, not bare IP (cell_link_manager): the push targeted
https://<vpn-ip>, but in DDNS/ACME mode Caddy only holds a cert for the cell's
domain, so the TLS handshake failed by IP. Target https://<remote-domain> with
`curl --resolve <domain>:443:<dns_ip>` — connect to the VPN IP over the tunnel
but present the domain as SNI/Host. remote_api_url is now domain-based; legacy
http://ip:3000 and https://ip URLs migrate on load.
L4 — preserve the real source for auth (firewall_manager): the blanket
`-o eth0 MASQUERADE` rewrote the push source, so the remote's X-Forwarded-For
source-subnet auth couldn't match. apply_cell_rules adds a tightly-scoped nat
POSTROUTING RETURN (linked-subnet → caddy:443 only) above the masquerade; the
host route returns Caddy's reply through the tunnel. Reviewed by pic-security:
WireGuard per-cell AllowedIPs + Caddy last-XFF (no trusted_proxies) keep this
un-spoofable; the API stays 127.0.0.1-only.
Also:
- validate remote-invite domain/dns_ip/endpoint/subnet at ingest (they reach a
curl --resolve argv — block leading-dash argument-injection).
- remove the host subnet route on cell unlink (remove_cell_subnet_route); the
route was never cleaned, leaving a stale subnet that made is_local_request
treat it as local. Mock firewall side-effects in the affected unit tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -652,6 +652,7 @@ class TestCellRules(unittest.TestCase):
|
||||
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), \
|
||||
patch.object(firewall_manager, 'ensure_cell_subnet_routes', return_value=None), \
|
||||
patch.object(firewall_manager, 'ensure_forward_stateful', return_value=True):
|
||||
firewall_manager.apply_cell_rules(cell_name, vpn_subnet, inbound_services)
|
||||
|
||||
@@ -742,6 +743,20 @@ class TestCellRules(unittest.TestCase):
|
||||
]
|
||||
self.assertFalse(api_3000, 'Peer-sync must not target cell-api:3000')
|
||||
|
||||
def test_apply_cell_rules_excludes_peer_sync_from_masquerade(self):
|
||||
"""Peer-sync to Caddy:443 must RETURN in nat POSTROUTING (skip the blanket
|
||||
MASQUERADE) so the remote sees the linked cell's real VPN source for auth."""
|
||||
calls = self._capture_apply('office', '10.0.1.0/24', [])
|
||||
returns = [
|
||||
c for c in calls
|
||||
if '-t' in c and 'nat' in c and 'POSTROUTING' in c
|
||||
and '-s' in c and '10.0.1.0/24' in c
|
||||
and '-d' in c and self._FAKE_CADDY_IP in c
|
||||
and '--dport' in c and '443' in c
|
||||
and '-j' in c and c[c.index('-j') + 1] == 'RETURN'
|
||||
]
|
||||
self.assertTrue(returns, 'Expected nat POSTROUTING RETURN to preserve peer-sync source')
|
||||
|
||||
def test_apply_cell_rules_api_sync_accept_before_catchall_drop(self):
|
||||
"""The API-sync ACCEPT must be inserted after service rules so it ends up above DROP."""
|
||||
insertion_order = []
|
||||
@@ -756,6 +771,7 @@ class TestCellRules(unittest.TestCase):
|
||||
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'), \
|
||||
patch.object(firewall_manager, 'ensure_cell_subnet_routes', return_value=None), \
|
||||
patch.object(firewall_manager, 'ensure_forward_stateful', return_value=True):
|
||||
firewall_manager.apply_cell_rules('office', '10.0.1.0/24', [])
|
||||
|
||||
@@ -852,6 +868,31 @@ class TestCellRules(unittest.TestCase):
|
||||
# peer rule for a different entity must survive
|
||||
self.assertIn('pic-peer-10-0-0-2/32', content)
|
||||
|
||||
# ── remove_cell_subnet_route ──────────────────────────────────────────────
|
||||
|
||||
def test_remove_cell_subnet_route_issues_ip_route_del(self):
|
||||
"""remove_cell_subnet_route deletes the host route for the cell's subnet."""
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
captured['cmd'] = cmd
|
||||
return MagicMock(returncode=0, stdout='', stderr='')
|
||||
|
||||
with patch.object(firewall_manager, '_run', side_effect=fake_run):
|
||||
firewall_manager.remove_cell_subnet_route('10.1.0.0/24')
|
||||
cmd = captured.get('cmd', [])
|
||||
self.assertIn('ip', cmd)
|
||||
self.assertIn('route', cmd)
|
||||
self.assertIn('del', cmd)
|
||||
self.assertIn('10.1.0.0/24', cmd)
|
||||
self.assertIn('172.20.0.9', cmd)
|
||||
|
||||
def test_remove_cell_subnet_route_noop_on_empty(self):
|
||||
"""An empty subnet is a no-op (no docker call)."""
|
||||
with patch.object(firewall_manager, '_run') as run:
|
||||
firewall_manager.remove_cell_subnet_route('')
|
||||
run.assert_not_called()
|
||||
|
||||
# ── apply_all_cell_rules ──────────────────────────────────────────────────
|
||||
|
||||
def test_apply_all_cell_rules_calls_apply_for_each(self):
|
||||
@@ -1130,6 +1171,7 @@ class TestEnsureForwardStateful(unittest.TestCase):
|
||||
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'), \
|
||||
patch.object(firewall_manager, '_get_cell_api_ip', return_value='172.20.0.10'), \
|
||||
patch.object(firewall_manager, 'ensure_cell_subnet_routes', return_value=None), \
|
||||
patch.object(firewall_manager, 'ensure_forward_stateful') as mock_stateful:
|
||||
firewall_manager.apply_cell_rules('testcell', '10.0.0.0/24', [])
|
||||
mock_stateful.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user