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:
+4
-1
@@ -219,8 +219,11 @@ def peer_sync_permissions():
|
||||
return jsonify({'ok': False, 'error': f'unknown service: {svc!r}'}), 400
|
||||
|
||||
exit_offered = bool(data.get('exit_offered', False))
|
||||
use_as_exit_relay = bool(data.get('use_as_exit_relay', False))
|
||||
from app import cell_link_manager
|
||||
cell_link_manager.apply_remote_permissions(sender_pubkey, perms, exit_offered=exit_offered)
|
||||
cell_link_manager.apply_remote_permissions(sender_pubkey, perms,
|
||||
exit_offered=exit_offered,
|
||||
use_as_exit_relay=use_as_exit_relay)
|
||||
return jsonify({'ok': True, 'applied_at': datetime.utcnow().isoformat()})
|
||||
except ValueError as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 404
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user