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:
@@ -77,6 +77,13 @@ class CellLinkManager:
|
|||||||
if 'last_remote_update_at' not in link:
|
if 'last_remote_update_at' not in link:
|
||||||
link['last_remote_update_at'] = None
|
link['last_remote_update_at'] = None
|
||||||
changed = True
|
changed = True
|
||||||
|
# Phase 2 migration: exit-offer signaling fields
|
||||||
|
if 'exit_offered' not in link:
|
||||||
|
link['exit_offered'] = False
|
||||||
|
changed = True
|
||||||
|
if 'remote_exit_offered' not in link:
|
||||||
|
link['remote_exit_offered'] = False
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self._save(links)
|
self._save(links)
|
||||||
return links
|
return links
|
||||||
@@ -146,6 +153,7 @@ class CellLinkManager:
|
|||||||
'outbound': dict(perms.get('inbound', {})),
|
'outbound': dict(perms.get('inbound', {})),
|
||||||
'inbound': dict(perms.get('outbound', {})),
|
'inbound': dict(perms.get('outbound', {})),
|
||||||
},
|
},
|
||||||
|
'exit_offered': bool(link.get('exit_offered', False)),
|
||||||
'sent_at': datetime.utcnow().isoformat() + 'Z',
|
'sent_at': datetime.utcnow().isoformat() + 'Z',
|
||||||
}
|
}
|
||||||
payload = json.dumps(body)
|
payload = json.dumps(body)
|
||||||
@@ -230,7 +238,8 @@ class CellLinkManager:
|
|||||||
logger.warning(f"Permission push to '{cell_name}' skipped (non-fatal): {e}")
|
logger.warning(f"Permission push to '{cell_name}' skipped (non-fatal): {e}")
|
||||||
|
|
||||||
def apply_remote_permissions(self, from_public_key: str,
|
def apply_remote_permissions(self, from_public_key: str,
|
||||||
permissions: Dict[str, Any]) -> Dict[str, Any]:
|
permissions: Dict[str, Any],
|
||||||
|
exit_offered: bool = False) -> Dict[str, Any]:
|
||||||
"""Store permissions pushed by a remote cell (identified by WG public key).
|
"""Store permissions pushed by a remote cell (identified by WG public key).
|
||||||
|
|
||||||
Validates service names, persists, and re-applies local iptables rules.
|
Validates service names, persists, and re-applies local iptables rules.
|
||||||
@@ -247,6 +256,7 @@ class CellLinkManager:
|
|||||||
clean_outbound = {s: bool(out_raw.get(s, False)) for s in VALID_SERVICES}
|
clean_outbound = {s: bool(out_raw.get(s, False)) for s in VALID_SERVICES}
|
||||||
|
|
||||||
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||||
|
link['remote_exit_offered'] = bool(exit_offered)
|
||||||
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
||||||
self._save(links)
|
self._save(links)
|
||||||
|
|
||||||
@@ -445,6 +455,21 @@ class CellLinkManager:
|
|||||||
raise ValueError(f"Cell '{cell_name}' not found")
|
raise ValueError(f"Cell '{cell_name}' not found")
|
||||||
return link.get('permissions', _default_perms())
|
return link.get('permissions', _default_perms())
|
||||||
|
|
||||||
|
def set_exit_offered(self, cell_name: str, offered: bool) -> Dict[str, Any]:
|
||||||
|
"""Toggle whether THIS cell offers to route internet traffic for cell_name.
|
||||||
|
|
||||||
|
The new value is persisted locally then pushed to the remote cell so it
|
||||||
|
knows our offer changed. Returns the updated link record.
|
||||||
|
"""
|
||||||
|
links = self._load()
|
||||||
|
link = next((l for l in links if l['cell_name'] == cell_name), None)
|
||||||
|
if not link:
|
||||||
|
raise ValueError(f"Cell '{cell_name}' not found")
|
||||||
|
link['exit_offered'] = bool(offered)
|
||||||
|
self._save(links)
|
||||||
|
self._try_push(cell_name, link)
|
||||||
|
return link
|
||||||
|
|
||||||
def get_connection_status(self, cell_name: str) -> Dict[str, Any]:
|
def get_connection_status(self, cell_name: str) -> Dict[str, Any]:
|
||||||
"""Return link record enriched with live WireGuard handshake status."""
|
"""Return link record enriched with live WireGuard handshake status."""
|
||||||
links = self._load()
|
links = self._load()
|
||||||
|
|||||||
+23
-1
@@ -162,6 +162,27 @@ def update_cell_permissions(cell_name):
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/cells/<cell_name>/exit-offer', methods=['PUT'])
|
||||||
|
def set_exit_offer(cell_name):
|
||||||
|
"""Toggle whether this cell offers to route internet for a connected cell.
|
||||||
|
|
||||||
|
Body: {"exit_offered": true|false}
|
||||||
|
The new value is persisted and pushed to the remote cell.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app import cell_link_manager
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if 'exit_offered' not in data:
|
||||||
|
return jsonify({'error': 'exit_offered field required'}), 400
|
||||||
|
link = cell_link_manager.set_exit_offered(cell_name, bool(data['exit_offered']))
|
||||||
|
return jsonify({'message': f"Exit offer for '{cell_name}' updated", 'link': link})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting exit offer: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/api/cells/peer-sync/permissions', methods=['POST'])
|
@bp.route('/api/cells/peer-sync/permissions', methods=['POST'])
|
||||||
def peer_sync_permissions():
|
def peer_sync_permissions():
|
||||||
"""Machine-to-machine endpoint: a connected cell pushes its mirrored permission state.
|
"""Machine-to-machine endpoint: a connected cell pushes its mirrored permission state.
|
||||||
@@ -197,8 +218,9 @@ def peer_sync_permissions():
|
|||||||
if svc not in VALID_SERVICES:
|
if svc not in VALID_SERVICES:
|
||||||
return jsonify({'ok': False, 'error': f'unknown service: {svc!r}'}), 400
|
return jsonify({'ok': False, 'error': f'unknown service: {svc!r}'}), 400
|
||||||
|
|
||||||
|
exit_offered = bool(data.get('exit_offered', False))
|
||||||
from app import cell_link_manager
|
from app import cell_link_manager
|
||||||
cell_link_manager.apply_remote_permissions(sender_pubkey, perms)
|
cell_link_manager.apply_remote_permissions(sender_pubkey, perms, exit_offered=exit_offered)
|
||||||
return jsonify({'ok': True, 'applied_at': datetime.utcnow().isoformat()})
|
return jsonify({'ok': True, 'applied_at': datetime.utcnow().isoformat()})
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 404
|
return jsonify({'ok': False, 'error': str(e)}), 404
|
||||||
|
|||||||
@@ -719,5 +719,134 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
self.assertIn('pending_push', raw[0])
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -570,7 +570,8 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
|
|||||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||||
)
|
)
|
||||||
mock_clm.apply_remote_permissions.assert_called_once_with(
|
mock_clm.apply_remote_permissions.assert_called_once_with(
|
||||||
'officepubkey=', self._VALID_BODY['permissions']
|
'officepubkey=', self._VALID_BODY['permissions'],
|
||||||
|
exit_offered=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch('app.cell_link_manager')
|
@patch('app.cell_link_manager')
|
||||||
@@ -685,5 +686,55 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
|
|||||||
self.assertEqual(r.status_code, 404)
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetExitOffer(unittest.TestCase):
|
||||||
|
"""PUT /api/cells/<cell_name>/exit-offer"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_set_exit_offer_true_calls_set_exit_offered(self, mock_clm):
|
||||||
|
mock_clm.set_exit_offered.return_value = {'cell_name': 'remote', 'exit_offered': True}
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/remote/exit-offer',
|
||||||
|
data=json.dumps({'exit_offered': True}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
mock_clm.set_exit_offered.assert_called_once_with('remote', True)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_set_exit_offer_false_calls_set_exit_offered(self, mock_clm):
|
||||||
|
mock_clm.set_exit_offered.return_value = {'cell_name': 'remote', 'exit_offered': False}
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/remote/exit-offer',
|
||||||
|
data=json.dumps({'exit_offered': False}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
mock_clm.set_exit_offered.assert_called_once_with('remote', False)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_set_exit_offer_missing_field_returns_400(self, mock_clm):
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/remote/exit-offer',
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
mock_clm.set_exit_offered.assert_not_called()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_set_exit_offer_unknown_cell_returns_404(self, mock_clm):
|
||||||
|
mock_clm.set_exit_offered.side_effect = ValueError("Cell 'nobody' not found")
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/nobody/exit-offer',
|
||||||
|
data=json.dumps({'exit_offered': True}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user