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
+121 -1
View File
@@ -641,7 +641,8 @@ class TestPermissionSync(unittest.TestCase):
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {}},
)
mock_rules.assert_called_once_with('office', '10.1.0.0/24', ['calendar'])
mock_rules.assert_called_once_with('office', '10.1.0.0/24', ['calendar'],
exit_relay=False)
# ── replay_pending_pushes ─────────────────────────────────────────────────
@@ -848,5 +849,124 @@ class TestExitOffer(unittest.TestCase):
self.assertFalse(links[0]['remote_exit_offered'])
class TestExitRelay(unittest.TestCase):
"""Tests for Phase 3: per-peer internet routing via exit cell."""
INVITE = {
'cell_name': 'office',
'public_key': 'officepubkey=',
'endpoint': '5.5.5.5:51820',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office',
}
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.data_dir = os.path.join(self.test_dir, 'data')
self.config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(self.config_dir, exist_ok=True)
self.wg = _make_wg_mock()
self.net = _make_nm_mock()
self.mgr = CellLinkManager(self.data_dir, self.config_dir, self.wg, self.net)
def tearDown(self):
shutil.rmtree(self.test_dir)
def _add_office(self):
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': True, 'error': None}):
self.mgr.add_connection(self.INVITE)
def test_phase3_migration_adds_exit_relay_fields(self):
"""Existing links without Phase 3 fields get them on load."""
raw = [{'cell_name': 'office', 'public_key': 'officepubkey=',
'vpn_subnet': '10.1.0.0/24', 'dns_ip': '10.1.0.1',
'domain': 'office', 'endpoint': '5.5.5.5:51820',
'permissions': {'inbound': {}, 'outbound': {}},
'remote_api_url': 'http://10.1.0.1:3000',
'last_push_status': 'ok', 'last_push_at': None,
'last_push_error': None, 'pending_push': False,
'last_remote_update_at': None,
'exit_offered': False, 'remote_exit_offered': False}]
with open(os.path.join(self.data_dir, 'cell_links.json'), 'w') as f:
json.dump(raw, f)
links = self.mgr.list_connections()
self.assertIn('exit_relay_active', links[0])
self.assertIn('remote_exit_relay_active', links[0])
self.assertFalse(links[0]['exit_relay_active'])
self.assertFalse(links[0]['remote_exit_relay_active'])
def test_set_exit_relay_active_persists(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._try_push'):
link = self.mgr.set_exit_relay_active('office', True)
self.assertTrue(link['exit_relay_active'])
self.assertTrue(self.mgr.list_connections()[0]['exit_relay_active'])
def test_set_exit_relay_active_false_persists(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._try_push'):
self.mgr.set_exit_relay_active('office', True)
link = self.mgr.set_exit_relay_active('office', False)
self.assertFalse(link['exit_relay_active'])
def test_set_exit_relay_active_triggers_push(self):
self._add_office()
push_mock = MagicMock()
with patch('cell_link_manager.CellLinkManager._try_push', push_mock):
self.mgr.set_exit_relay_active('office', True)
push_mock.assert_called_once()
def test_set_exit_relay_active_unknown_cell_raises(self):
with self.assertRaises(ValueError):
self.mgr.set_exit_relay_active('nobody', True)
def test_push_includes_use_as_exit_relay_true(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._try_push'):
self.mgr.set_exit_relay_active('office', True)
captured = []
def fake_run(cmd, **kw):
captured.append(cmd)
r = MagicMock()
r.returncode = 0
r.stdout = '200'
return r
import json as _json
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.subprocess.run', side_effect=fake_run), \
patch('cell_link_manager.CellLinkManager._local_wg_ip', return_value=None):
self.mgr._try_push('office', self.mgr.list_connections()[0])
flat = [arg for cmd in captured for arg in cmd]
payload_str = next(a for a in flat if a.startswith('{'))
body = _json.loads(payload_str)
self.assertIn('use_as_exit_relay', body)
self.assertTrue(body['use_as_exit_relay'])
def test_apply_remote_permissions_stores_remote_exit_relay_active(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules'):
self.mgr.apply_remote_permissions('officepubkey=', {},
use_as_exit_relay=True)
link = self.mgr.list_connections()[0]
self.assertTrue(link['remote_exit_relay_active'])
def test_apply_remote_permissions_calls_apply_cell_rules_with_exit_relay_true(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules') as mock_rules:
self.mgr.apply_remote_permissions('officepubkey=', {},
use_as_exit_relay=True)
mock_rules.assert_called_once_with('office', '10.1.0.0/24', [],
exit_relay=True)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -572,6 +572,7 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
mock_clm.apply_remote_permissions.assert_called_once_with(
'officepubkey=', self._VALID_BODY['permissions'],
exit_offered=False,
use_as_exit_relay=False,
)
@patch('app.cell_link_manager')
+30
View File
@@ -77,5 +77,35 @@ class TestPeerRegistry(unittest.TestCase):
registry = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir)
self.assertEqual(registry.list_peers(), [])
def test_route_via_migration_adds_field(self):
"""Existing peers without route_via get it as None on load."""
peers_file = os.path.join(self.test_dir, 'peers.json')
raw = [{'peer': 'alice', 'ip': '10.0.0.5', 'public_key': 'key=',
'active': True, 'created_at': '2026-01-01T00:00:00'}]
with open(peers_file, 'w') as f:
json.dump(raw, f)
reg = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir)
peer = reg.get_peer('alice')
self.assertIn('route_via', peer)
self.assertIsNone(peer['route_via'])
def test_set_route_via_persists(self):
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
updated = self.registry.set_route_via('alice', 'exit-cell')
self.assertEqual(updated['route_via'], 'exit-cell')
# Verify it survives a reload
reloaded = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir)
self.assertEqual(reloaded.get_peer('alice')['route_via'], 'exit-cell')
def test_set_route_via_clear(self):
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
self.registry.set_route_via('alice', 'exit-cell')
updated = self.registry.set_route_via('alice', None)
self.assertIsNone(updated['route_via'])
def test_set_route_via_unknown_peer_raises(self):
with self.assertRaises(ValueError):
self.registry.set_route_via('nobody', 'exit-cell')
if __name__ == '__main__':
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Tests for PUT /api/peers/<peer_name>/route-via.
Covers:
- missing via_cell field 400
- peer not found 404
- connected cell not found 404
- enable route-via: calls wg methods and set_exit_relay_active(True)
- disable route-via: clears wg methods and set_exit_relay_active(False)
- switching from one exit cell to another removes old routing first
"""
import sys
import json
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from app import app
_PEER = {
'peer': 'alice',
'ip': '10.0.0.5',
'public_key': 'alicepubkey=',
'route_via': None,
'internet_access': True,
'service_access': [],
}
_CELL_LINK = {
'cell_name': 'exit-cell',
'public_key': 'exitcellpubkey=',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'remote_exit_relay_active': False,
}
class TestSetPeerRouteVia(unittest.TestCase):
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
def _put(self, peer_name, body):
return self.client.put(
f'/api/peers/{peer_name}/route-via',
data=json.dumps(body),
content_type='application/json',
)
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_missing_via_cell_returns_400(self, mock_reg, mock_wg, mock_clm):
r = self._put('alice', {})
self.assertEqual(r.status_code, 400)
data = json.loads(r.data)
self.assertIn('via_cell', data['error'])
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_peer_not_found_returns_404(self, mock_reg, mock_wg, mock_clm):
mock_reg.get_peer.return_value = None
r = self._put('nobody', {'via_cell': 'exit-cell'})
self.assertEqual(r.status_code, 404)
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_unknown_cell_returns_404(self, mock_reg, mock_wg, mock_clm):
mock_reg.get_peer.return_value = _PEER
mock_clm.list_connections.return_value = []
r = self._put('alice', {'via_cell': 'no-such-cell'})
self.assertEqual(r.status_code, 404)
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_enable_route_via_calls_wg_methods(self, mock_reg, mock_wg, mock_clm):
mock_reg.get_peer.return_value = _PEER
mock_reg.set_route_via.return_value = dict(_PEER, route_via='exit-cell')
mock_clm.list_connections.return_value = [_CELL_LINK]
r = self._put('alice', {'via_cell': 'exit-cell'})
self.assertEqual(r.status_code, 200)
mock_wg.update_cell_peer_allowed_ips.assert_called_once_with(
'exitcellpubkey=', '10.1.0.0/24', add_default_route=True)
mock_wg.apply_peer_route_via.assert_called_once_with(
'10.0.0.5', via_wg_ip='10.1.0.1')
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_enable_route_via_signals_exit_relay_active(self, mock_reg, mock_wg, mock_clm):
mock_reg.get_peer.return_value = _PEER
mock_reg.set_route_via.return_value = dict(_PEER, route_via='exit-cell')
mock_clm.list_connections.return_value = [_CELL_LINK]
self._put('alice', {'via_cell': 'exit-cell'})
mock_clm.set_exit_relay_active.assert_called_once_with('exit-cell', True)
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_disable_route_via_clears_old_routing(self, mock_reg, mock_wg, mock_clm):
peer_with_via = dict(_PEER, route_via='exit-cell')
mock_reg.get_peer.return_value = peer_with_via
mock_reg.set_route_via.return_value = dict(_PEER, route_via=None)
mock_clm.list_connections.return_value = [_CELL_LINK]
r = self._put('alice', {'via_cell': None})
self.assertEqual(r.status_code, 200)
mock_wg.update_cell_peer_allowed_ips.assert_called_once_with(
'exitcellpubkey=', '10.1.0.0/24', add_default_route=False)
mock_wg.remove_peer_route_via.assert_called_once_with('10.0.0.5')
mock_clm.set_exit_relay_active.assert_called_once_with('exit-cell', False)
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_enable_route_via_persists_field(self, mock_reg, mock_wg, mock_clm):
mock_reg.get_peer.return_value = _PEER
updated = dict(_PEER, route_via='exit-cell')
mock_reg.set_route_via.return_value = updated
mock_clm.list_connections.return_value = [_CELL_LINK]
r = self._put('alice', {'via_cell': 'exit-cell'})
self.assertEqual(r.status_code, 200)
mock_reg.set_route_via.assert_called_once_with('alice', 'exit-cell')
data = json.loads(r.data)
self.assertEqual(data['peer']['route_via'], 'exit-cell')
@patch('app.cell_link_manager')
@patch('app.wireguard_manager')
@patch('app.peer_registry')
def test_switch_exit_cell_removes_old_before_adding_new(self, mock_reg, mock_wg, mock_clm):
old_link = dict(_CELL_LINK, cell_name='old-exit', public_key='oldpubkey=',
vpn_subnet='10.2.0.0/24', dns_ip='10.2.0.1')
new_link = dict(_CELL_LINK, cell_name='new-exit', public_key='newpubkey=')
peer_with_old = dict(_PEER, route_via='old-exit')
mock_reg.get_peer.return_value = peer_with_old
mock_reg.set_route_via.return_value = dict(_PEER, route_via='new-exit')
mock_clm.list_connections.return_value = [old_link, new_link]
self._put('alice', {'via_cell': 'new-exit'})
calls = mock_wg.update_cell_peer_allowed_ips.call_args_list
# First call removes old (add_default_route=False), second adds new (True)
self.assertEqual(len(calls), 2)
self.assertFalse(calls[0].kwargs['add_default_route'])
self.assertTrue(calls[1].kwargs['add_default_route'])
# set_exit_relay_active called twice: False for old, True for new
relay_calls = mock_clm.set_exit_relay_active.call_args_list
self.assertEqual(len(relay_calls), 2)
self.assertEqual(relay_calls[0].args, ('old-exit', False))
self.assertEqual(relay_calls[1].args, ('new-exit', True))
if __name__ == '__main__':
unittest.main()