feat(cells): Phase 2 — exit-offer signaling between connected cells

Adds the ability for a cell to signal to a peer that it's willing to
route internet traffic on their behalf.  This is the signaling layer
for Phase 3 (per-peer routing via exit cell).

Changes:
- cell_links.json: exit_offered (bool) + remote_exit_offered (bool)
  fields with lazy migration (default false for existing records)
- _push_permissions_to_remote: includes exit_offered in the push body
- apply_remote_permissions: accepts exit_offered kwarg; stores it as
  remote_exit_offered on the matching cell link
- peer-sync receiver: passes exit_offered from body to apply_remote_permissions
- CellLinkManager.set_exit_offered(cell_name, offered): persists +
  triggers push so the remote learns of our offer immediately
- PUT /api/cells/<name>/exit-offer: REST endpoint to toggle the flag
- 12 new tests covering all new paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 15:49:21 -04:00
parent 7da0cbb714
commit dcee03dd3f
4 changed files with 230 additions and 3 deletions
+129
View File
@@ -719,5 +719,134 @@ class TestPermissionSync(unittest.TestCase):
self.assertIn('pending_push', raw[0])
class TestExitOffer(unittest.TestCase):
"""Tests for Phase 2: exit-offer signaling."""
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, push_ok=True):
push_result = {'ok': push_ok, 'error': None if push_ok else 'timeout'}
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=push_result), \
patch('cell_link_manager.CellLinkManager._local_wg_ip', return_value='10.0.0.1'), \
patch('firewall_manager.apply_cell_rules'):
self.mgr.add_connection(self.INVITE)
def test_new_links_default_exit_offered_false(self):
self._add_office()
link = self.mgr.list_connections()[0]
self.assertFalse(link.get('exit_offered'))
def test_new_links_default_remote_exit_offered_false(self):
self._add_office()
link = self.mgr.list_connections()[0]
self.assertFalse(link.get('remote_exit_offered'))
def test_set_exit_offered_persists(self):
self._add_office()
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}), \
patch('cell_link_manager.CellLinkManager._local_wg_ip', return_value='10.0.0.1'):
result = self.mgr.set_exit_offered('office', True)
self.assertTrue(result['exit_offered'])
link = self.mgr.list_connections()[0]
self.assertTrue(link['exit_offered'])
def test_set_exit_offered_triggers_push(self):
self._add_office()
push_mock = MagicMock(return_value={'ok': True, 'error': None})
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', push_mock), \
patch('cell_link_manager.CellLinkManager._local_wg_ip', return_value='10.0.0.1'):
self.mgr.set_exit_offered('office', True)
push_mock.assert_called_once()
def test_set_exit_offered_unknown_cell_raises(self):
with self.assertRaises(ValueError):
self.mgr.set_exit_offered('nobody', True)
def test_push_includes_exit_offered(self):
self._add_office()
link = self.mgr.list_connections()[0]
link['exit_offered'] = True
captured = {}
def fake_run(cmd, **kwargs):
if '-d' not in cmd:
r = MagicMock()
r.returncode = 0
r.stdout = ' inet 10.0.0.1/24 scope global wg0\n'
return r
import json as _j
d_idx = cmd.index('-d')
captured['body'] = _j.loads(cmd[d_idx + 1])
r = MagicMock()
r.returncode = 0
r.stdout = '200'
r.stderr = ''
return r
with patch('subprocess.run', fake_run):
self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertTrue(captured['body']['exit_offered'])
def test_apply_remote_permissions_stores_remote_exit_offered(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules'):
self.mgr.apply_remote_permissions(
'officepubkey=',
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {}},
exit_offered=True,
)
link = self.mgr.list_connections()[0]
self.assertTrue(link['remote_exit_offered'])
def test_migration_adds_exit_fields_to_existing_links(self):
"""Existing cell_links.json without exit 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}]
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_offered', links[0])
self.assertIn('remote_exit_offered', links[0])
self.assertFalse(links[0]['exit_offered'])
self.assertFalse(links[0]['remote_exit_offered'])
if __name__ == '__main__':
unittest.main()