diff --git a/api/firewall_manager.py b/api/firewall_manager.py index 9f81c52..d1d342e 100644 --- a/api/firewall_manager.py +++ b/api/firewall_manager.py @@ -374,7 +374,8 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str Traffic from vpn_subnet is allowed only to service VIPs listed in inbound_services; all other cell traffic is DROPped. Cells get no internet or peer access — only explicit service access via Caddy on - port 80, plus the cell-api port (3000) for permission-sync pushes. + port 80, plus Caddy on 443 for cross-cell peer-sync pushes (offer/ + permission state) which reach cell-api through Caddy. DNS (port 53) is always allowed so cell peers can resolve service names. Service names resolve to the WG server IP; ensure_service_dnat() routes @@ -388,7 +389,7 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str 2. Exit relay ACCEPT (-o eth0) (if exit_relay, above catch-all) 3. Service ACCEPT to Caddy port 80 (if any inbound_services) 4. DNS ACCEPT to cell-dns port 53 (UDP + TCP) - 5. API-sync ACCEPT (inserted last → top) + 5. Peer-sync ACCEPT to Caddy port 443 (inserted last → top) """ try: tag = _cell_tag(cell_name) @@ -425,13 +426,17 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str '-p', proto, '--dport', '53', '-m', 'comment', '--comment', tag, '-j', 'ACCEPT']) - # API permission-sync ACCEPT — inserted LAST so it goes to position 1 (above - # the catch-all DROP). Remote cells push permissions to our cell-api via the - # WG tunnel; iptables sees source=cell_subnet dst=api_ip after DNAT. - api_ip = _get_cell_api_ip() - if api_ip: - _iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', api_ip, - '-p', 'tcp', '--dport', '3000', + # Peer-sync ACCEPT — inserted LAST so it goes to position 1 (above the + # catch-all DROP). Remote cells push offer/permission state to our API over + # the WG tunnel. The push targets the remote's Caddy on 443 (DNAT wg0:443 → + # Caddy → cell-api), NOT cell-api:3000 directly: the API binds 127.0.0.1 + # only and is reachable solely through Caddy. After DNAT iptables sees + # source=cell_subnet dst=caddy_ip:443; the existing `-o eth0 MASQUERADE` + # routes Caddy's reply back through the tunnel. + caddy_ip = _get_caddy_container_ip() + if caddy_ip: + _iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', caddy_ip, + '-p', 'tcp', '--dport', '443', '-m', 'comment', '--comment', tag, '-j', 'ACCEPT']) # Ensure reply traffic (e.g. ICMP, TCP ACKs) for connections initiated diff --git a/tests/test_firewall_manager.py b/tests/test_firewall_manager.py index 60dd147..f0544ae 100644 --- a/tests/test_firewall_manager.py +++ b/tests/test_firewall_manager.py @@ -702,32 +702,45 @@ 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 Caddy ACCEPT when inbound_services is non-empty.""" - calls = self._capture_apply('office', '10.0.1.0/24', ['calendar']) - 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_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.""" - calls = self._capture_apply('office', '10.0.1.0/24', []) - api_ip = self._FAKE_API_IP - api_accepts = [ + def _caddy_accepts_on_port(self, calls, port): + """Caddy-dest ACCEPT calls matching --dport .""" + return [ c for c in calls - if '-s' in c and '10.0.1.0/24' in c - and '-d' in c and api_ip in c - and '--dport' in c and '3000' in c + if '-d' in c and self._FAKE_CADDY_IP in c + and '--dport' in c and str(port) in c and '-j' in c and c[c.index('-j') + 1] == 'ACCEPT' ] - self.assertTrue(api_accepts, 'Expected an ACCEPT rule for cell-api:3000') + + def test_apply_cell_rules_sends_accept_for_allowed_service(self): + """apply_cell_rules inserts a Caddy:80 ACCEPT when inbound_services is non-empty.""" + calls = self._capture_apply('office', '10.0.1.0/24', ['calendar']) + self.assertTrue(self._caddy_accepts_on_port(calls, 80), + "Expected ACCEPT to Caddy:80 for an inbound service") + + def test_apply_cell_rules_no_service_accept_when_no_inbound(self): + """No Caddy:80 (service) ACCEPT when inbound_services is empty. + + The :443 peer-sync ACCEPT is separate and always present (below). + """ + calls = self._capture_apply('office', '10.0.1.0/24', []) + self.assertFalse(self._caddy_accepts_on_port(calls, 80), + "No Caddy:80 service ACCEPT expected with empty inbound") + + def test_apply_cell_rules_accepts_peer_sync_to_caddy_443(self): + """Cross-cell peer-sync ACCEPT to Caddy:443 is always added (the push reaches + cell-api through Caddy, since the API binds 127.0.0.1 only).""" + calls = self._capture_apply('office', '10.0.1.0/24', []) + peer_sync = [ + c for c in self._caddy_accepts_on_port(calls, 443) + if '-s' in c and '10.0.1.0/24' in c + ] + self.assertTrue(peer_sync, 'Expected ACCEPT to Caddy:443 for peer-sync') + # And it must NOT target the (127.0.0.1-only) cell-api on :3000 anymore. + api_3000 = [ + c for c in calls + if '-d' in c and self._FAKE_API_IP in c and '--dport' in c and '3000' in c + ] + self.assertFalse(api_3000, 'Peer-sync must not target cell-api:3000') 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.""" @@ -754,12 +767,12 @@ class TestCellRules(unittest.TestCase): # ── apply_cell_rules — empty inbound (all-deny) ─────────────────────────── def test_apply_cell_rules_empty_inbound_no_service_accept(self): - """With inbound_services=[], no service ACCEPT is added; catch-all DROP blocks traffic.""" + """With inbound_services=[], no Caddy:80 service ACCEPT is added; the catch-all + DROP blocks service traffic (only the :443 peer-sync ACCEPT is present).""" 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 service ACCEPT to Caddy on :80 + self.assertFalse(self._caddy_accepts_on_port(calls, 80), + "No Caddy:80 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) diff --git a/wireguard/Dockerfile b/wireguard/Dockerfile index 9a88c54..5b3ca98 100644 --- a/wireguard/Dockerfile +++ b/wireguard/Dockerfile @@ -1,6 +1,11 @@ FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc -RUN apk add --no-cache wireguard-tools iptables ip6tables iproute2 +# curl + ca-certificates: cell-to-cell peer-sync pushes (offer/permission state) +# originate from this container's network namespace — the only one with routes to +# remote-cell VPN subnets over the tunnel — and go over HTTPS to the remote's +# Caddy. busybox wget here has no TLS, so curl is required (~5MB over the slim +# base; the alternative is no automatic cross-cell sync). +RUN apk add --no-cache wireguard-tools iptables ip6tables iproute2 curl ca-certificates COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh