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:
@@ -84,6 +84,13 @@ class CellLinkManager:
|
||||
if 'remote_exit_offered' not in link:
|
||||
link['remote_exit_offered'] = False
|
||||
changed = True
|
||||
# Phase 3 migration: per-peer internet routing
|
||||
if 'exit_relay_active' not in link:
|
||||
link['exit_relay_active'] = False
|
||||
changed = True
|
||||
if 'remote_exit_relay_active' not in link:
|
||||
link['remote_exit_relay_active'] = False
|
||||
changed = True
|
||||
if changed:
|
||||
self._save(links)
|
||||
return links
|
||||
@@ -154,6 +161,7 @@ class CellLinkManager:
|
||||
'inbound': dict(perms.get('outbound', {})),
|
||||
},
|
||||
'exit_offered': bool(link.get('exit_offered', False)),
|
||||
'use_as_exit_relay': bool(link.get('exit_relay_active', False)),
|
||||
'sent_at': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
payload = json.dumps(body)
|
||||
@@ -239,7 +247,8 @@ class CellLinkManager:
|
||||
|
||||
def apply_remote_permissions(self, from_public_key: str,
|
||||
permissions: Dict[str, Any],
|
||||
exit_offered: bool = False) -> Dict[str, Any]:
|
||||
exit_offered: bool = False,
|
||||
use_as_exit_relay: bool = False) -> Dict[str, Any]:
|
||||
"""Store permissions pushed by a remote cell (identified by WG public key).
|
||||
|
||||
Validates service names, persists, and re-applies local iptables rules.
|
||||
@@ -257,13 +266,15 @@ class CellLinkManager:
|
||||
|
||||
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||
link['remote_exit_offered'] = bool(exit_offered)
|
||||
link['remote_exit_relay_active'] = bool(use_as_exit_relay)
|
||||
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
||||
self._save(links)
|
||||
|
||||
inbound_list = [s for s, v in clean_inbound.items() if v]
|
||||
try:
|
||||
import firewall_manager as _fm
|
||||
_fm.apply_cell_rules(link['cell_name'], link['vpn_subnet'], inbound_list)
|
||||
_fm.apply_cell_rules(link['cell_name'], link['vpn_subnet'], inbound_list,
|
||||
exit_relay=use_as_exit_relay)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"apply_cell_rules after remote push for '{link['cell_name']}' "
|
||||
@@ -470,6 +481,22 @@ class CellLinkManager:
|
||||
self._try_push(cell_name, link)
|
||||
return link
|
||||
|
||||
def set_exit_relay_active(self, cell_name: str, active: bool) -> Dict[str, Any]:
|
||||
"""Record that THIS cell is routing a peer's internet traffic via cell_name.
|
||||
|
||||
Persists the flag locally and pushes updated state to the remote cell so
|
||||
it can enable/disable the FORWARD-to-eth0 rule on its side.
|
||||
Returns the updated link record.
|
||||
"""
|
||||
links = self._load()
|
||||
link = next((l for l in links if l['cell_name'] == cell_name), None)
|
||||
if not link:
|
||||
raise ValueError(f"Cell '{cell_name}' not found")
|
||||
link['exit_relay_active'] = bool(active)
|
||||
self._save(links)
|
||||
self._try_push(cell_name, link)
|
||||
return link
|
||||
|
||||
def get_connection_status(self, cell_name: str) -> Dict[str, Any]:
|
||||
"""Return link record enriched with live WireGuard handshake status."""
|
||||
links = self._load()
|
||||
|
||||
Reference in New Issue
Block a user