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:
2026-05-01 16:23:31 -04:00
parent dcee03dd3f
commit 8ea834e108
11 changed files with 547 additions and 11 deletions
+71
View File
@@ -183,6 +183,77 @@ def update_peer(peer_name):
return jsonify({"error": str(e)}), 500
@bp.route('/api/peers/<peer_name>/route-via', methods=['PUT'])
def set_peer_route_via(peer_name):
"""Route a peer's internet traffic through a connected exit cell.
Body: {"via_cell": "cellB"} to enable, {"via_cell": null} to disable.
On enable: updates WG AllowedIPs and adds policy routing rule inside
cell-wireguard so the peer's packets egress through the exit cell.
On disable: reverts AllowedIPs and removes the ip rule.
Also signals the exit cell to add/remove the FORWARD-to-eth0 firewall rule.
"""
try:
from app import peer_registry, wireguard_manager, cell_link_manager
data = request.get_json(silent=True)
if data is None or 'via_cell' not in data:
return jsonify({'error': 'via_cell field required (string or null)'}), 400
via_cell = data['via_cell']
if via_cell is not None and not isinstance(via_cell, str):
return jsonify({'error': 'via_cell must be a string or null'}), 400
peer = peer_registry.get_peer(peer_name)
if not peer:
return jsonify({'error': 'Peer not found'}), 404
peer_ip = peer.get('ip', '').split('/')[0]
if not peer_ip:
return jsonify({'error': 'Peer has no IP assigned'}), 400
old_via = peer.get('route_via')
# Remove old routing if switching away from a previous exit cell
if old_via and old_via != via_cell:
old_link = next(
(l for l in cell_link_manager.list_connections()
if l['cell_name'] == old_via), None
)
if old_link:
wireguard_manager.update_cell_peer_allowed_ips(
old_link['public_key'], old_link['vpn_subnet'],
add_default_route=False)
wireguard_manager.remove_peer_route_via(peer_ip)
try:
cell_link_manager.set_exit_relay_active(old_via, False)
except Exception as e:
logger.warning(f"set_exit_relay_active(False) for {old_via!r} failed: {e}")
# Apply new routing
if via_cell:
link = next(
(l for l in cell_link_manager.list_connections()
if l['cell_name'] == via_cell), None
)
if not link:
return jsonify({'error': f"Cell {via_cell!r} not connected"}), 404
wireguard_manager.update_cell_peer_allowed_ips(
link['public_key'], link['vpn_subnet'], add_default_route=True)
wireguard_manager.apply_peer_route_via(peer_ip, via_wg_ip=link['dns_ip'])
try:
cell_link_manager.set_exit_relay_active(via_cell, True)
except Exception as e:
logger.warning(f"set_exit_relay_active(True) for {via_cell!r} failed: {e}")
updated_peer = peer_registry.set_route_via(peer_name, via_cell)
return jsonify({'message': f"Route-via for '{peer_name}' updated", 'peer': updated_peer})
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error setting route-via for {peer_name}: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/peers/<peer_name>/clear-reinstall', methods=['POST'])
def clear_peer_reinstall(peer_name):
try: