diff --git a/api/cell_link_manager.py b/api/cell_link_manager.py index 91344bb..5052189 100644 --- a/api/cell_link_manager.py +++ b/api/cell_link_manager.py @@ -77,6 +77,13 @@ class CellLinkManager: if 'last_remote_update_at' not in link: link['last_remote_update_at'] = None 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: self._save(links) return links @@ -146,6 +153,7 @@ class CellLinkManager: 'outbound': dict(perms.get('inbound', {})), 'inbound': dict(perms.get('outbound', {})), }, + 'exit_offered': bool(link.get('exit_offered', False)), 'sent_at': datetime.utcnow().isoformat() + 'Z', } payload = json.dumps(body) @@ -230,7 +238,8 @@ class CellLinkManager: logger.warning(f"Permission push to '{cell_name}' skipped (non-fatal): {e}") 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). 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} link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound} + link['remote_exit_offered'] = bool(exit_offered) link['last_remote_update_at'] = datetime.utcnow().isoformat() self._save(links) @@ -445,6 +455,21 @@ class CellLinkManager: raise ValueError(f"Cell '{cell_name}' not found") 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]: """Return link record enriched with live WireGuard handshake status.""" links = self._load() diff --git a/api/routes/cells.py b/api/routes/cells.py index b787ceb..27637f5 100644 --- a/api/routes/cells.py +++ b/api/routes/cells.py @@ -162,6 +162,27 @@ def update_cell_permissions(cell_name): return jsonify({'error': str(e)}), 500 +@bp.route('/api/cells//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']) def peer_sync_permissions(): """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: 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 - 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()}) except ValueError as e: return jsonify({'ok': False, 'error': str(e)}), 404 diff --git a/tests/test_cell_link_manager.py b/tests/test_cell_link_manager.py index 5098904..5e5f5f7 100644 --- a/tests/test_cell_link_manager.py +++ b/tests/test_cell_link_manager.py @@ -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() diff --git a/tests/test_cells_endpoints.py b/tests/test_cells_endpoints.py index 6240606..6a539b9 100644 --- a/tests/test_cells_endpoints.py +++ b/tests/test_cells_endpoints.py @@ -570,7 +570,8 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase): environ_base={'REMOTE_ADDR': '10.1.0.5'}, ) 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') @@ -685,5 +686,55 @@ class TestPeerSyncPermissionsEndpoint(unittest.TestCase): self.assertEqual(r.status_code, 404) +class TestSetExitOffer(unittest.TestCase): + """PUT /api/cells//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__': unittest.main()