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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user