Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 714fb9b1a9 |
+14
-9
@@ -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
|
Traffic from vpn_subnet is allowed only to service VIPs listed in
|
||||||
inbound_services; all other cell traffic is DROPped. Cells get no
|
inbound_services; all other cell traffic is DROPped. Cells get no
|
||||||
internet or peer access — only explicit service access via Caddy on
|
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.
|
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
|
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)
|
2. Exit relay ACCEPT (-o eth0) (if exit_relay, above catch-all)
|
||||||
3. Service ACCEPT to Caddy port 80 (if any inbound_services)
|
3. Service ACCEPT to Caddy port 80 (if any inbound_services)
|
||||||
4. DNS ACCEPT to cell-dns port 53 (UDP + TCP)
|
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:
|
try:
|
||||||
tag = _cell_tag(cell_name)
|
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',
|
'-p', proto, '--dport', '53',
|
||||||
'-m', 'comment', '--comment', tag, '-j', 'ACCEPT'])
|
'-m', 'comment', '--comment', tag, '-j', 'ACCEPT'])
|
||||||
|
|
||||||
# API permission-sync ACCEPT — inserted LAST so it goes to position 1 (above
|
# Peer-sync ACCEPT — inserted LAST so it goes to position 1 (above the
|
||||||
# the catch-all DROP). Remote cells push permissions to our cell-api via the
|
# catch-all DROP). Remote cells push offer/permission state to our API over
|
||||||
# WG tunnel; iptables sees source=cell_subnet dst=api_ip after DNAT.
|
# the WG tunnel. The push targets the remote's Caddy on 443 (DNAT wg0:443 →
|
||||||
api_ip = _get_cell_api_ip()
|
# Caddy → cell-api), NOT cell-api:3000 directly: the API binds 127.0.0.1
|
||||||
if api_ip:
|
# only and is reachable solely through Caddy. After DNAT iptables sees
|
||||||
_iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', api_ip,
|
# source=cell_subnet dst=caddy_ip:443; the existing `-o eth0 MASQUERADE`
|
||||||
'-p', 'tcp', '--dport', '3000',
|
# 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'])
|
'-m', 'comment', '--comment', tag, '-j', 'ACCEPT'])
|
||||||
|
|
||||||
# Ensure reply traffic (e.g. ICMP, TCP ACKs) for connections initiated
|
# Ensure reply traffic (e.g. ICMP, TCP ACKs) for connections initiated
|
||||||
|
|||||||
@@ -702,32 +702,45 @@ class TestCellRules(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
self.assertTrue(subnet_drops, "Expected a catch-all DROP rule for the subnet")
|
self.assertTrue(subnet_drops, "Expected a catch-all DROP rule for the subnet")
|
||||||
|
|
||||||
def test_apply_cell_rules_sends_accept_for_allowed_service(self):
|
def _caddy_accepts_on_port(self, calls, port):
|
||||||
"""apply_cell_rules inserts Caddy ACCEPT when inbound_services is non-empty."""
|
"""Caddy-dest ACCEPT calls matching --dport <port>."""
|
||||||
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
return [
|
||||||
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 = [
|
|
||||||
c for c in calls
|
c for c in calls
|
||||||
if '-s' in c and '10.0.1.0/24' in c
|
if '-d' in c and self._FAKE_CADDY_IP in c
|
||||||
and '-d' in c and api_ip in c
|
and '--dport' in c and str(port) in c
|
||||||
and '--dport' in c and '3000' in c
|
|
||||||
and '-j' in c and c[c.index('-j') + 1] == 'ACCEPT'
|
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):
|
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."""
|
"""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) ───────────────────────────
|
# ── apply_cell_rules — empty inbound (all-deny) ───────────────────────────
|
||||||
|
|
||||||
def test_apply_cell_rules_empty_inbound_no_service_accept(self):
|
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', [])
|
calls = self._capture_apply('office', '10.0.1.0/24', [])
|
||||||
# No ACCEPT to Caddy
|
# No service ACCEPT to Caddy on :80
|
||||||
caddy_targets = self._targets_for_dest(calls, self._FAKE_CADDY_IP)
|
self.assertFalse(self._caddy_accepts_on_port(calls, 80),
|
||||||
self.assertNotIn('ACCEPT', caddy_targets,
|
"No Caddy:80 ACCEPT expected with empty inbound_services")
|
||||||
"No Caddy ACCEPT expected with empty inbound_services")
|
|
||||||
# No per-VIP rules at all
|
# No per-VIP rules at all
|
||||||
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
|
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
|
||||||
svc_targets = self._targets_for_dest(calls, svc_ip)
|
svc_targets = self._targets_for_dest(calls, svc_ip)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
|
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
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
Reference in New Issue
Block a user