feat: Phase 3 - per-peer internet routing via exit cell
Adds the ability to route a specific peer's internet traffic through a
connected cell acting as an exit relay.
Cell A side:
- PUT /api/peers/<peer>/route-via {"via_cell": "cellB"} sets route_via
- Updates WG AllowedIPs to include 0.0.0.0/0 for the exit cell peer
- Adds ip rule + ip route in policy table inside cell-wireguard so the
specific peer's traffic egresses via cellB's WG IP
- Sets exit_relay_active on the cell link and pushes use_as_exit_relay=True
to cellB via peer-sync
Cell B side:
- Receives use_as_exit_relay in the peer-sync payload
- Calls apply_cell_rules(..., exit_relay=True) to add FORWARD -o eth0 ACCEPT
- Stores remote_exit_relay_active flag for startup recovery
Startup recovery:
- apply_all_cell_rules passes exit_relay=remote_exit_relay_active (cellB)
- _apply_startup_enforcement reapplies ip rule for each peer with route_via (cellA)
since policy routing rules don't survive container restart
peer_registry gets route_via field with lazy migration.
22 new tests across test_cell_link_manager, test_peer_registry, test_peer_route_via.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+24
-7
@@ -262,7 +262,8 @@ def _get_cell_api_ip() -> Optional[str]:
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str]) -> bool:
|
||||
def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str],
|
||||
exit_relay: bool = False) -> bool:
|
||||
"""Apply FORWARD rules for a cell-to-cell peer.
|
||||
|
||||
Traffic from vpn_subnet is allowed only to service VIPs listed in
|
||||
@@ -270,10 +271,15 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str
|
||||
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)
|
||||
3. API-sync ACCEPT (inserted last → top, above catch-all)
|
||||
When exit_relay=True, the remote cell's peers can route internet
|
||||
traffic through this cell (Phase 3). A broad ACCEPT for traffic
|
||||
going out eth0 is added below per-service rules but above catch-all.
|
||||
|
||||
Rule insertion order (first inserted = bottom, last inserted = top):
|
||||
1. Catch-all DROP for the subnet (inserted first → bottom)
|
||||
2. Exit relay ACCEPT (-o eth0) (if exit_relay, above catch-all)
|
||||
3. Per-service ACCEPT/DROP (inserted in reversed() order)
|
||||
4. API-sync ACCEPT (inserted last → top)
|
||||
"""
|
||||
try:
|
||||
tag = _cell_tag(cell_name)
|
||||
@@ -283,6 +289,13 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str
|
||||
_iptables(['-I', 'FORWARD', '-s', vpn_subnet,
|
||||
'-m', 'comment', '--comment', tag, '-j', 'DROP'])
|
||||
|
||||
# Exit relay ACCEPT — allow internet-bound traffic from this cell's peers.
|
||||
# Inserted ABOVE catch-all but BELOW per-service rules so service-level
|
||||
# DROP rules still take effect for specific service VIPs.
|
||||
if exit_relay:
|
||||
_iptables(['-I', 'FORWARD', '-s', vpn_subnet, '-o', 'eth0',
|
||||
'-m', 'comment', '--comment', tag, '-j', 'ACCEPT'])
|
||||
|
||||
# Per-service rules — inserted in reverse dict order, highest-priority last
|
||||
for service, svc_ip in reversed(list(SERVICE_IPS.items())):
|
||||
target = 'ACCEPT' if service in inbound_services else 'DROP'
|
||||
@@ -298,7 +311,10 @@ def apply_cell_rules(cell_name: str, vpn_subnet: str, inbound_services: List[str
|
||||
'-p', 'tcp', '--dport', '3000',
|
||||
'-m', 'comment', '--comment', tag, '-j', 'ACCEPT'])
|
||||
|
||||
logger.info(f"Applied cell rules for {cell_name} ({vpn_subnet}): inbound={inbound_services}")
|
||||
logger.info(
|
||||
f"Applied cell rules for {cell_name} ({vpn_subnet}): "
|
||||
f"inbound={inbound_services} exit_relay={exit_relay}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"apply_cell_rules({cell_name}): {e}")
|
||||
@@ -314,7 +330,8 @@ def apply_all_cell_rules(cell_links: List[Dict[str, Any]]) -> None:
|
||||
continue
|
||||
perms = link.get('permissions', {})
|
||||
inbound = [s for s, v in perms.get('inbound', {}).items() if v]
|
||||
apply_cell_rules(name, subnet, inbound)
|
||||
exit_relay = bool(link.get('remote_exit_relay_active', False))
|
||||
apply_cell_rules(name, subnet, inbound, exit_relay=exit_relay)
|
||||
|
||||
|
||||
def ensure_cell_api_dnat() -> bool:
|
||||
|
||||
Reference in New Issue
Block a user