Fix FORWARD rule ordering: embed API-sync ACCEPT inside apply_cell_rules

The per-cell catch-all DROP was reaching position 5 before our ACCEPT
(position 6) because apply_all_cell_rules can re-run after
ensure_cell_api_dnat, pushing the DNAT ACCEPT below the DROP.

Fix: add the API-sync ACCEPT inside apply_cell_rules itself, tagged with
the cell's own tag and inserted LAST (= position 1, above the DROP).
Since it's part of the cell's rule block it is always in the right
position relative to the catch-all DROP, regardless of call order.

Also adds _get_cell_api_ip() helper (docker inspect cell-api) so the
destination IP is always current, and two new tests that verify both the
rule exists and that the insertion order guarantees it wins over DROP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 14:05:49 -04:00
parent 4ba79fd614
commit ea6731d62c
2 changed files with 58 additions and 4 deletions
+21 -2
View File
@@ -254,16 +254,26 @@ def clear_cell_rules(cell_name: str) -> None:
logger.error(f"clear_cell_rules({cell_name}): {e}")
def _get_cell_api_ip() -> Optional[str]:
"""Return cell-api's Docker bridge IP. Returns empty string on failure."""
r = _run(['docker', 'inspect', '--format',
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',
'cell-api'], check=False)
return r.stdout.strip()
def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str]) -> bool:
"""Apply FORWARD rules for a cell-to-cell peer.
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 VIPs.
internet or peer access — only explicit service VIPs, plus the
cell-api port (3000) for permission-sync pushes arriving via DNAT.
Rule insertion order (last inserted → top of chain):
1. Catch-all DROP for the subnet (inserted first → bottom)
2. Per-service ACCEPT/DROP (inserted in reversed() order → top)
2. Per-service ACCEPT/DROP (inserted in reversed() order)
3. API-sync ACCEPT (inserted last → top, above catch-all)
"""
try:
tag = _cell_tag(cell_name)
@@ -279,6 +289,15 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str
_iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-d', svc_ip,
'-m', 'comment', '--comment', tag, '-j', target])
# 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',
'-m', 'comment', '--comment', tag, '-j', 'ACCEPT'])
logger.info(f"Applied cell rules for {cell_name} ({vpn_subnet}): inbound={inbound_services}")
return True
except Exception as e: