diff --git a/api/connectivity_manager.py b/api/connectivity_manager.py index ce8d421..fc1a824 100644 --- a/api/connectivity_manager.py +++ b/api/connectivity_manager.py @@ -145,6 +145,14 @@ class ConnectivityManager(BaseServiceManager): # Only a single Tor instance is supported (one Tor container per cell). SINGLE_INSTANCE_TYPES = ("tor",) + # A cell_relay connection represents "route this peer's internet through a + # connected cell that offers its exit". It is NOT manually created — it is + # auto-derived (reconciled) from cell links that offer an exit. It needs a + # mark+table for policy routing but no local iface/redirect_port/container: + # egress happens through the cell WG tunnel, not a local exit container. + CELL_RELAY_TYPE = "cell_relay" + CELL_RELAY_NAME_PREFIX = "Cell: " + # fwmark block 0x1000–0x1FFF, stride 0x10. MARK_BASE = 0x1000 MARK_STRIDE = 0x10 @@ -208,6 +216,12 @@ class ConnectivityManager(BaseServiceManager): # Set after construction in managers.py (composer is built later) — used # to bring per-connection containers up/down out-of-process. self.service_composer = service_composer + # Set after construction in managers.py — used to derive cell_relay + # connections from cell links that offer an exit, and to drive cell + # exit routing / handshake-based health. Optional: when unset, the + # cell_relay reconcile is a no-op (no cell links to surface). + self.cell_link_manager = None + self.wireguard_manager = None # Serializes connection CRUD + resource allocation across threads. self._conn_lock = threading.RLock() @@ -384,6 +398,18 @@ class ConnectivityManager(BaseServiceManager): if self.peer_registry is None: return {'ok': False, 'error': 'peer_registry not available'} + # A cell_relay assignment must not form an exit loop (A→B→A). Reject + # before persisting so the UI surfaces the error rather than silently + # creating a cycle that apply_routes would later refuse. + target = self._connection_by_id(exit_via) + if target is not None and target.get('type') == self.CELL_RELAY_TYPE: + peer = self.peer_registry.get_peer(peer_name) if self.peer_registry else None + if peer is not None and self._cell_relay_loops(peer, target.get('cell_name')): + return {'ok': False, 'error': + f"routing {peer_name!r} via cell {target.get('cell_name')!r} " + f'would form an exit loop (that cell already uses this cell ' + f'as its exit relay)'} + try: ok = self.peer_registry.set_peer_exit_via(peer_name, exit_via) except Exception as e: @@ -397,6 +423,29 @@ class ConnectivityManager(BaseServiceManager): f"must be a connection id or 'default'"} return {'ok': False, 'error': f'peer {peer_name!r} not found'} + # Keep the legacy route_via in sync: a cell_relay assignment drives the + # cell-routing path (route_via set in apply_routes), but reassigning away + # from a cell must clear the stale route_via so the cell route is torn + # down and startup replay no longer reapplies it. + if target is None or target.get('type') != self.CELL_RELAY_TYPE: + try: + cur = self.peer_registry.get_peer(peer_name) + if cur and cur.get('route_via'): + via_cell = cur.get('route_via') + self.peer_registry.set_route_via(peer_name, None) + if self.wireguard_manager is not None: + src_ip = self._peer_source_ip(peer_name) + if src_ip: + try: + self.wireguard_manager.remove_peer_route_via(src_ip) + except Exception as e: + logger.warning(f"set_peer_exit: remove_peer_route_via " + f"{src_ip}: {e}") + logger.info(f"set_peer_exit: cleared cell route_via " + f"{via_cell!r} for {peer_name!r}") + except Exception as e: + logger.warning(f"set_peer_exit: route_via cleanup failed: {e}") + try: self.apply_routes() except Exception as e: @@ -1406,8 +1455,140 @@ class ConnectivityManager(BaseServiceManager): logger.debug(f"_connection_reference (egress): {e}") return None + # ── Cell-relay connections (derived from cell links) ────────────────── + + @staticmethod + def _offers_exit(link: Dict[str, Any]) -> bool: + """True when a cell link makes its internet available as an exit relay. + + `remote_exit_offered` is set when the remote cell pushed an offer to us + (the common case); `exit_offered` is the locally-recorded flag. Either + being true means the cell is usable as a relay from our side. + """ + return bool(link.get('remote_exit_offered') or link.get('exit_offered')) + + def _list_cell_links(self) -> List[Dict[str, Any]]: + """Return cell link records, or [] when no cell_link_manager is wired.""" + if self.cell_link_manager is None: + return [] + try: + links = self.cell_link_manager.list_connections() + except Exception as e: + logger.warning(f"connectivity: list cell links failed: {e}") + return [] + return links if isinstance(links, list) else [] + + def reconcile_cell_relays(self) -> Dict[str, Any]: + """Ensure a cell_relay connection exists for each exit-offering cell link. + + For every cell link that offers an exit, ensure exactly one cell_relay + connection named "Cell: " exists (carrying mark+table, no + iface/redirect_port/container, and cell_name set). Remove cell_relay + connections whose offer was withdrawn or whose cell link is gone, unless + a peer is still assigned to them (those are kept but reported stale so + routing falls back to default rather than silently breaking). + + Idempotent and safe to call on every connection list / cell-link change. + Returns {'created': [...], 'removed': [...]}. + """ + created: List[str] = [] + removed: List[str] = [] + if self.config_manager is None: + return {'created': created, 'removed': removed} + + with self._conn_lock: + try: + conns = self.config_manager.list_connections() + except Exception as e: + logger.warning(f"reconcile_cell_relays: list_connections failed: {e}") + return {'created': created, 'removed': removed} + + existing = {c.get('cell_name'): c for c in conns + if c.get('type') == self.CELL_RELAY_TYPE and c.get('cell_name')} + offering = {l.get('cell_name'): l for l in self._list_cell_links() + if l.get('cell_name') and self._offers_exit(l)} + + # Create cell_relay connections for newly-offered cells. + for cell_name, link in offering.items(): + if cell_name in existing: + continue + rec = self._build_cell_relay_record(cell_name) + if rec is None: + continue + try: + self.config_manager.add_connection(rec) + created.append(rec['id']) + logger.info(f"connectivity: derived cell_relay for cell " + f"{cell_name!r} → {rec['id']}") + except Exception as e: + logger.warning(f"reconcile_cell_relays: persist {cell_name!r} " + f"failed: {e}") + + # Remove cell_relay connections whose offer was withdrawn — unless a + # peer still references them (left in place so deletion never strands + # a peer; apply_routes treats an un-offered relay as down). + for cell_name, rec in existing.items(): + if cell_name in offering: + continue + if self._connection_reference(rec.get('id')): + logger.info(f"connectivity: cell_relay {rec.get('id')} for " + f"{cell_name!r} no longer offered but still " + f"referenced; keeping") + continue + try: + self.config_manager.delete_connection(rec.get('id')) + removed.append(rec.get('id')) + logger.info(f"connectivity: removed stale cell_relay " + f"{rec.get('id')} for cell {cell_name!r}") + except Exception as e: + logger.warning(f"reconcile_cell_relays: delete {cell_name!r} " + f"failed: {e}") + + return {'created': created, 'removed': removed} + + def _build_cell_relay_record(self, cell_name: str) -> Optional[Dict[str, Any]]: + """Build a new cell_relay connection record (mark+table, no iface/port).""" + conn_id = self._new_conn_id() + try: + mark, table, iface, redirect_port = self._allocate_resources( + self.CELL_RELAY_TYPE, conn_id) + except ValueError as e: + logger.warning(f"_build_cell_relay_record({cell_name}): {e}") + return None + now = self._now_iso() + return { + 'id': conn_id, + 'type': self.CELL_RELAY_TYPE, + 'name': f"{self.CELL_RELAY_NAME_PREFIX}{cell_name}", + 'enabled': True, + 'mark': mark, + 'table': table, + 'iface': iface, + 'redirect_port': redirect_port, + 'config': {}, + 'secret_refs': [], + 'cell_name': cell_name, + 'status': { + 'state': 'configured', + 'health': 'unknown', + 'last_check': None, + 'detail': None, + }, + 'created_at': now, + 'updated_at': now, + } + def list_connections(self) -> List[Dict[str, Any]]: - """Return all connection records (public form, computed status.state).""" + """Return all connection records (public form, computed status.state). + + Reconciles cell_relay connections from cell links first so the unified + list (and the assignments UI) always reflects currently-offered cell + exits without a separate refresh. + """ + try: + self.reconcile_cell_relays() + except Exception as e: + logger.warning(f"list_connections: cell_relay reconcile failed: {e}") if self.config_manager is None: return [] try: @@ -1601,7 +1782,11 @@ class ConnectivityManager(BaseServiceManager): connections = self._routing_connections() # Idempotent ip rule registration: one fwmark→table rule per instance. + # cell_relay connections are excluded: their egress is policy-routed + # inside cell-wireguard (apply_peer_route_via), not via a host mark/table. for conn in connections: + if conn.get('type') == self.CELL_RELAY_TYPE: + continue mark, table = conn.get('mark'), conn.get('table') if not isinstance(mark, int) or not isinstance(table, int): continue @@ -1644,6 +1829,11 @@ class ConnectivityManager(BaseServiceManager): src_ip = self._peer_source_ip(peer.get('peer', '')) if not src_ip: continue + # cell_relay peers egress through the connected cell's WG tunnel + # (the existing cell-exit mechanism), NOT a local exit container. + if conn.get('type') == self.CELL_RELAY_TYPE: + rules_applied += self._apply_cell_relay_for_peer(peer, conn) + continue rules_applied += self._apply_connection_for_src(src_ip, conn) marked_conn_ids.add(conn.get('id')) @@ -1701,6 +1891,18 @@ class ConnectivityManager(BaseServiceManager): return None return by_id.get(exit_via) + def _connection_by_id(self, conn_id: str) -> Optional[Dict[str, Any]]: + """Look up a raw connection record by id (cell_relay included).""" + if not conn_id or conn_id == 'default' or self.config_manager is None: + return None + try: + for c in self.config_manager.list_connections(): + if c.get('id') == conn_id: + return c + except Exception as e: + logger.warning(f"_connection_by_id({conn_id}): {e}") + return None + def _apply_connection_for_src( self, src_ip: str, conn: Dict[str, Any], ) -> int: @@ -1730,6 +1932,116 @@ class ConnectivityManager(BaseServiceManager): f"apply_routes: redirect for {src_ip}/{conn.get('id')}: {e}") return applied + def _cell_relay_link(self, conn: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Resolve a cell_relay connection's backing cell link, or None. + + Returns None when the cell link is gone or no longer offers an exit so + apply_routes treats the relay as down (no route applied → peer falls + back to its default route rather than a black hole). + """ + cell_name = conn.get('cell_name') + if not cell_name: + return None + for link in self._list_cell_links(): + if link.get('cell_name') == cell_name and self._offers_exit(link): + return link + return None + + def _cell_relay_loops(self, peer: Dict[str, Any], + cell_name: str) -> bool: + """True when routing this peer via cell_name would form an exit loop. + + A cycle exists when the target cell is itself routing traffic back to + US as its exit relay (A→B→A). We detect it from the link record: + `exit_offered`/`exit_relay_active` mean we hand our internet to that + cell, so also routing a peer *through* it closes the loop. + """ + for link in self._list_cell_links(): + if link.get('cell_name') != cell_name: + continue + if link.get('exit_offered') or link.get('exit_relay_active'): + logger.warning( + f"apply_routes: refusing to route peer " + f"{peer.get('peer')!r} via cell {cell_name!r} — we already " + f"act as that cell's exit relay (routing loop)") + return True + return False + + def _apply_cell_relay_for_peer(self, peer: Dict[str, Any], + conn: Dict[str, Any]) -> int: + """Route a peer's internet through the connected cell behind a cell_relay. + + Bridges the connection model onto the existing cell-exit mechanism: + policy-routes the peer inside cell-wireguard to the cell's WG IP + (wireguard_manager.apply_peer_route_via), keeps peer.route_via in sync + so startup replay + firewall reconciliation stay consistent, marks the + link's exit relay active, and applies the cell exit firewall rules. + Loop-forming assignments (A→B→A) are refused. Returns rules applied. + """ + cell_name = conn.get('cell_name') + link = self._cell_relay_link(conn) + if link is None: + logger.info(f"apply_routes: cell_relay {conn.get('id')} for cell " + f"{cell_name!r} no longer offers an exit; skipping") + return 0 + if self._cell_relay_loops(peer, cell_name): + return 0 + + peer_ip = (peer.get('ip') or '').split('/')[0] + via_wg_ip = link.get('dns_ip') + if not peer_ip or not via_wg_ip: + logger.warning(f"apply_routes: cell_relay {conn.get('id')} missing " + f"peer_ip/via_wg_ip; skipping") + return 0 + + applied = 0 + table = conn.get('table') if isinstance(conn.get('table'), int) else 100 + if self.wireguard_manager is not None: + try: + self.wireguard_manager.apply_peer_route_via( + peer_ip, via_wg_ip=via_wg_ip, table=table) + applied += 1 + except Exception as e: + logger.warning(f"apply_routes: cell_relay route_via " + f"{peer_ip}→{via_wg_ip}: {e}") + + # Keep the legacy route_via field in sync so the existing startup + # replay (app.py) and firewall reconciliation operate on the same peer. + if self.peer_registry is not None: + try: + if peer.get('route_via') != cell_name: + self.peer_registry.set_route_via(peer.get('peer'), cell_name) + except Exception as e: + logger.warning(f"apply_routes: set_route_via {cell_name!r}: {e}") + + # Mark the relay active on the link + apply the cell exit firewall rules + # (idempotent; mirrors apply_remote_permissions' exit_relay path). + if self.cell_link_manager is not None: + try: + self.cell_link_manager.set_exit_relay_active(cell_name, True) + except Exception as e: + logger.warning(f"apply_routes: set_exit_relay_active " + f"{cell_name!r}: {e}") + self._apply_cell_exit_firewall(link) + return applied + + def _apply_cell_exit_firewall(self, link: Dict[str, Any]) -> None: + """Apply the cell exit FORWARD rules for a cell relay link (best-effort).""" + try: + import firewall_manager as _fm + except Exception as e: + logger.debug(f"_apply_cell_exit_firewall: import failed: {e}") + return + perms = link.get('permissions') or {} + inbound = perms.get('inbound', {}) if isinstance(perms, dict) else {} + inbound_list = [s for s, v in inbound.items() if v] + try: + _fm.apply_cell_rules(link.get('cell_name'), link.get('vpn_subnet'), + inbound_list, exit_relay=True) + except Exception as e: + logger.warning(f"_apply_cell_exit_firewall({link.get('cell_name')!r}): " + f"{e}") + # ── iptables / ip rule helpers ──────────────────────────────────────── def _wg_iptables(self, args: List[str], timeout: int = 10) -> subprocess.CompletedProcess: @@ -1919,6 +2231,7 @@ class ConnectivityManager(BaseServiceManager): "sshuttle": False, "proxy": False, "tor": True, + "cell_relay": False, } def probe_health(self, connection: Dict[str, Any]) -> Tuple[str, Optional[str]]: @@ -1939,6 +2252,8 @@ class ConnectivityManager(BaseServiceManager): return self._probe_sshuttle(connection) if conn_type == 'proxy': return self._probe_proxy(connection) + if conn_type == self.CELL_RELAY_TYPE: + return self._probe_cell_relay(connection) except Exception as e: logger.warning(f"probe_health({connection.get('id')}): {e}") return 'unknown', str(e) @@ -2030,6 +2345,27 @@ class ConnectivityManager(BaseServiceManager): return 'working', f'{host}:{port} reachable' return 'down', f'upstream {host}:{port} unreachable' + def _probe_cell_relay(self, conn: Dict[str, Any]) -> Tuple[str, Optional[str]]: + """A cell_relay is working when the cell tunnel handshake is recent AND + the cell still offers its exit. + + Reuses cell_link_manager.get_connection_status (which enriches the link + with the live WireGuard handshake). 'down' when the offer is withdrawn, + the link is gone, or the handshake is stale/absent. + """ + cell_name = conn.get('cell_name') + if not cell_name or self.cell_link_manager is None: + return 'unknown', 'no cell link manager' + try: + st = self.cell_link_manager.get_connection_status(cell_name) + except Exception as e: + return 'down', f'cell link unavailable: {e}' + if not self._offers_exit(st): + return 'down', 'cell no longer offers its exit' + if not st.get('online'): + return 'down', 'cell tunnel handshake stale or absent' + return 'working', 'cell tunnel up + exit offered' + def _listener_reachable(self, container: Optional[str], port: int) -> bool: """True when a local TCP listener on `port` is up inside the exit container.""" r = self._exec_in_container( diff --git a/api/managers.py b/api/managers.py index 104f9fd..30d0b74 100644 --- a/api/managers.py +++ b/api/managers.py @@ -100,6 +100,10 @@ service_composer = ServiceComposer(config_manager=config_manager, data_dir=DATA_ # Connectivity brings one container up per connection instance via the composer; # wire it now that the composer exists (composer is built after connectivity). connectivity_manager.service_composer = service_composer +# cell_relay connections are derived from cell links and route through the cell +# WG tunnel; wire the managers that drive that path + handshake-based health. +connectivity_manager.cell_link_manager = cell_link_manager +connectivity_manager.wireguard_manager = wireguard_manager account_manager = AccountManager( service_registry=service_registry, data_dir=DATA_DIR, diff --git a/api/routes/cells.py b/api/routes/cells.py index 39bd69e..02e3b0a 100644 --- a/api/routes/cells.py +++ b/api/routes/cells.py @@ -176,6 +176,11 @@ def set_exit_offer(cell_name): 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'])) + try: + from app import connectivity_manager + connectivity_manager.reconcile_cell_relays() + except Exception as _re: + logger.warning(f"cell_relay reconcile after exit-offer failed (non-fatal): {_re}") return jsonify({'message': f"Exit offer for '{cell_name}' updated", 'link': link}) except ValueError as e: return jsonify({'error': str(e)}), 404 @@ -262,6 +267,11 @@ def peer_sync_permissions(): cell_link_manager.apply_remote_permissions(sender_pubkey, perms, exit_offered=exit_offered, use_as_exit_relay=use_as_exit_relay) + try: + from app import connectivity_manager + connectivity_manager.reconcile_cell_relays() + except Exception as _re: + logger.warning(f"cell_relay reconcile after peer-sync failed (non-fatal): {_re}") 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_connectivity_cell_relay.py b/tests/test_connectivity_cell_relay.py new file mode 100644 index 0000000..90e9058 --- /dev/null +++ b/tests/test_connectivity_cell_relay.py @@ -0,0 +1,323 @@ +""" +Phase 7 tests for ConnectivityManager — cell_relay connection type. + +A cell_relay connection unifies the cell-to-cell exit-relay concept into the +connection model: a connected cell that OFFERS its internet surfaces as an +assignable connection, and peers route through it via the existing cell-exit +mechanism (wireguard_manager.apply_peer_route_via + firewall cell rules) rather +than a local exit container. + +Covers: + - reconcile derives one cell_relay per offering cell link (no dups) and + removes it when the offer is withdrawn (unless still referenced) + - a cell_relay allocates mark+table but NO iface/redirect_port/container + - assigning a peer to a cell_relay drives the cell-routing path (route_via + + apply_peer_route_via + cell firewall rules), NOT a local container/redirect + - loop detection rejects a cycle (A→B→A) + - health derives from the cell tunnel handshake + offer + +All subprocess/docker/iptables access is mocked: the cell_link_manager, +wireguard_manager and firewall_manager are MagicMocks / patched modules, and +ConnectivityManager's own iptables/ip-rule helpers are patched out. +""" + +import os +import sys +import shutil +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) + +from config_manager import ConfigManager +from connectivity_manager import ConnectivityManager + + +def _link(cell_name='peercell', **overrides): + link = { + 'cell_name': cell_name, + 'public_key': 'PUBKEY_' + cell_name, + 'vpn_subnet': '10.9.0.0/24', + 'dns_ip': '10.9.0.1', + 'domain': f'{cell_name}.example', + 'permissions': {'inbound': {}, 'outbound': {}}, + 'exit_offered': False, + 'remote_exit_offered': True, + 'exit_relay_active': False, + 'remote_exit_relay_active': False, + } + link.update(overrides) + return link + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.cfg_file = os.path.join(self.tmp, 'cell_config.json') + self.data_dir = os.path.join(self.tmp, 'data') + os.makedirs(self.data_dir, exist_ok=True) + self.cm = ConfigManager(self.cfg_file, self.data_dir) + + self.peer_registry = MagicMock() + self.peer_registry.list_peers.return_value = [] + + self.cell_link = MagicMock() + self.cell_link.list_connections.return_value = [] + + self.wg = MagicMock() + self.wg.apply_peer_route_via.return_value = True + + with patch.object(ConnectivityManager, '_subscribe_to_events', + lambda self: None): + self.mgr = ConnectivityManager( + config_manager=self.cm, + peer_registry=self.peer_registry, + vault_manager=MagicMock(), + data_dir=self.data_dir, + config_dir=self.tmp, + ) + self.mgr.cell_link_manager = self.cell_link + self.mgr.wireguard_manager = self.wg + # Empty v2 slate (no auto-migrated Tor). + self.cm.configs['connectivity'] = {'version': 2, 'connections': []} + self.cm._save_all_configs() + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _raw_relays(self): + return [c for c in self.cm.list_connections() + if c.get('type') == 'cell_relay'] + + +# --------------------------------------------------------------------------- +# Reconcile from cell links +# --------------------------------------------------------------------------- + +class TestReconcile(_Base): + + def test_creates_relay_for_offering_link(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + res = self.mgr.reconcile_cell_relays() + self.assertEqual(len(res['created']), 1) + relays = self._raw_relays() + self.assertEqual(len(relays), 1) + self.assertEqual(relays[0]['cell_name'], 'alpha') + self.assertEqual(relays[0]['name'], 'Cell: alpha') + + def test_no_relay_when_no_offer(self): + self.cell_link.list_connections.return_value = [ + _link('alpha', remote_exit_offered=False, exit_offered=False)] + self.mgr.reconcile_cell_relays() + self.assertEqual(len(self._raw_relays()), 0) + + def test_local_exit_offered_also_surfaces(self): + self.cell_link.list_connections.return_value = [ + _link('alpha', remote_exit_offered=False, exit_offered=True)] + self.mgr.reconcile_cell_relays() + self.assertEqual(len(self._raw_relays()), 1) + + def test_idempotent_no_duplicates(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + r2 = self.mgr.reconcile_cell_relays() + self.assertEqual(r2['created'], []) + self.assertEqual(len(self._raw_relays()), 1) + + def test_removes_relay_when_offer_withdrawn(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + self.assertEqual(len(self._raw_relays()), 1) + # Offer withdrawn. + self.cell_link.list_connections.return_value = [ + _link('alpha', remote_exit_offered=False)] + res = self.mgr.reconcile_cell_relays() + self.assertEqual(len(res['removed']), 1) + self.assertEqual(len(self._raw_relays()), 0) + + def test_removes_relay_when_link_gone(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + self.cell_link.list_connections.return_value = [] + self.mgr.reconcile_cell_relays() + self.assertEqual(len(self._raw_relays()), 0) + + def test_kept_when_offer_gone_but_still_referenced(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + relay_id = self._raw_relays()[0]['id'] + # A peer references this relay → must not be auto-removed. + self.peer_registry.list_peers.return_value = [ + {'peer': 'laptop', 'exit_via': relay_id}] + self.cell_link.list_connections.return_value = [] + self.mgr.reconcile_cell_relays() + self.assertEqual(len(self._raw_relays()), 1) + + def test_list_connections_reconciles(self): + # list_connections runs reconcile, so the relay appears without an + # explicit reconcile call. + self.cell_link.list_connections.return_value = [_link('alpha')] + listed = self.mgr.list_connections() + relays = [c for c in listed if c['type'] == 'cell_relay'] + self.assertEqual(len(relays), 1) + + +# --------------------------------------------------------------------------- +# Resource allocation +# --------------------------------------------------------------------------- + +class TestAllocation(_Base): + + def test_relay_has_mark_table_no_iface_port(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + relay = self._raw_relays()[0] + self.assertIsInstance(relay['mark'], int) + self.assertIsInstance(relay['table'], int) + self.assertIsNone(relay['iface']) + self.assertIsNone(relay['redirect_port']) + + def test_relay_has_no_container(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + relay = self._raw_relays()[0] + self.assertIsNone(self.mgr.instance_container_name(relay)) + + def test_manual_create_rejected(self): + res = self.mgr.create_connection('cell_relay', 'Cell: alpha') + self.assertFalse(res['ok']) + self.assertIn('invalid type', res['error']) + + +# --------------------------------------------------------------------------- +# Routing drives the cell path, not a local container +# --------------------------------------------------------------------------- + +class TestRouting(_Base): + + def _setup_assigned_peer(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + relay = self._raw_relays()[0] + self.peer = {'peer': 'laptop', 'ip': '10.0.0.5/32', + 'exit_via': relay['id'], 'route_via': None} + self.peer_registry.list_peers.return_value = [self.peer] + self.peer_registry.get_peer.return_value = self.peer + return relay + + def test_apply_routes_drives_cell_path(self): + self._setup_assigned_peer() + with patch('firewall_manager.apply_cell_rules') as fw, \ + patch.object(self.mgr, '_ensure_chains'), \ + patch.object(self.mgr, '_flush_chain'), \ + patch.object(self.mgr, '_add_mark_rule') as add_mark, \ + patch.object(self.mgr, '_add_redirect') as add_redirect, \ + patch.object(self.mgr, '_add_ip_rule'), \ + patch.object(self.mgr, '_remove_ip_rule'): + self.mgr.apply_routes() + + # Cell path was driven. + self.wg.apply_peer_route_via.assert_called_once() + args, kwargs = self.wg.apply_peer_route_via.call_args + self.assertEqual(args[0], '10.0.0.5') + self.assertEqual(kwargs.get('via_wg_ip'), '10.9.0.1') + fw.assert_called_once() + self.assertTrue(fw.call_args.kwargs.get('exit_relay')) + self.cell_link.set_exit_relay_active.assert_called_once_with('alpha', True) + # route_via kept in sync for startup-replay parity. + self.peer_registry.set_route_via.assert_called_once_with('laptop', 'alpha') + + # NOT the local exit container / redirect path. + add_mark.assert_not_called() + add_redirect.assert_not_called() + + def test_apply_routes_skips_when_offer_withdrawn(self): + self._setup_assigned_peer() + # Offer withdrawn after assignment: routing must skip (peer falls back). + self.cell_link.list_connections.return_value = [ + _link('alpha', remote_exit_offered=False)] + with patch('firewall_manager.apply_cell_rules') as fw, \ + patch.object(self.mgr, '_ensure_chains'), \ + patch.object(self.mgr, '_flush_chain'), \ + patch.object(self.mgr, '_add_ip_rule'), \ + patch.object(self.mgr, '_remove_ip_rule'): + self.mgr.apply_routes() + self.wg.apply_peer_route_via.assert_not_called() + fw.assert_not_called() + + +# --------------------------------------------------------------------------- +# Loop detection +# --------------------------------------------------------------------------- + +class TestLoopDetection(_Base): + + def test_set_peer_exit_rejects_loop(self): + # We already act as alpha's exit relay (exit_offered True) → routing a + # peer THROUGH alpha would close the loop A→B→A. + self.cell_link.list_connections.return_value = [ + _link('alpha', exit_offered=True)] + self.mgr.reconcile_cell_relays() + relay = self._raw_relays()[0] + peer = {'peer': 'laptop', 'ip': '10.0.0.5/32', 'route_via': None} + self.peer_registry.get_peer.return_value = peer + + res = self.mgr.set_peer_exit('laptop', relay['id']) + self.assertFalse(res['ok']) + self.assertIn('loop', res['error']) + self.peer_registry.set_peer_exit_via.assert_not_called() + + def test_apply_routes_refuses_loop(self): + self.cell_link.list_connections.return_value = [ + _link('alpha', exit_offered=True)] + self.mgr.reconcile_cell_relays() + relay = self._raw_relays()[0] + peer = {'peer': 'laptop', 'ip': '10.0.0.5/32', + 'exit_via': relay['id'], 'route_via': None} + self.peer_registry.list_peers.return_value = [peer] + self.peer_registry.get_peer.return_value = peer + with patch('firewall_manager.apply_cell_rules'), \ + patch.object(self.mgr, '_ensure_chains'), \ + patch.object(self.mgr, '_flush_chain'), \ + patch.object(self.mgr, '_add_ip_rule'), \ + patch.object(self.mgr, '_remove_ip_rule'): + self.mgr.apply_routes() + self.wg.apply_peer_route_via.assert_not_called() + + +# --------------------------------------------------------------------------- +# Health derives from cell handshake + offer +# --------------------------------------------------------------------------- + +class TestHealth(_Base): + + def _relay(self): + self.cell_link.list_connections.return_value = [_link('alpha')] + self.mgr.reconcile_cell_relays() + return self._raw_relays()[0] + + def test_working_when_online_and_offered(self): + relay = self._relay() + self.cell_link.get_connection_status.return_value = { + **_link('alpha'), 'online': True} + health, _ = self.mgr.probe_health(relay) + self.assertEqual(health, 'working') + + def test_down_when_handshake_stale(self): + relay = self._relay() + self.cell_link.get_connection_status.return_value = { + **_link('alpha'), 'online': False} + health, _ = self.mgr.probe_health(relay) + self.assertEqual(health, 'down') + + def test_down_when_offer_withdrawn(self): + relay = self._relay() + self.cell_link.get_connection_status.return_value = { + **_link('alpha', remote_exit_offered=False), 'online': True} + health, _ = self.mgr.probe_health(relay) + self.assertEqual(health, 'down') + + +if __name__ == '__main__': + unittest.main() diff --git a/webui/src/pages/connectivity/AssignmentsPage.jsx b/webui/src/pages/connectivity/AssignmentsPage.jsx index 2f37a40..b4fb0d3 100644 --- a/webui/src/pages/connectivity/AssignmentsPage.jsx +++ b/webui/src/pages/connectivity/AssignmentsPage.jsx @@ -70,7 +70,7 @@ function FailopenControl({ value, onChange, saving }) { export default function AssignmentsPage() { const toasts = useToasts(); const { - connections, peerExits, peerFailopen, serviceEgress, installed, peers, cells, + connections, peerExits, peerFailopen, serviceEgress, installed, peers, loading, error, reload, setPeerExits, setPeerFailopen, setServiceEgress, } = useConnectivityData(); @@ -78,7 +78,9 @@ export default function AssignmentsPage() { const [savingFailopen, setSavingFailopen] = useState({}); const [savingService, setSavingService] = useState({}); - // Build grouped options from connection instances + cell-relay placeholders. + // Build grouped options from connection instances. cell_relay connections + // (derived from cell links that offer an exit) flow through the same + // `connections` list and land in the "Cells" group automatically. const options = (() => { const groups = []; Object.keys(GROUP_TYPES).forEach((group) => { @@ -87,17 +89,6 @@ export default function AssignmentsPage() { .map((c) => ({ id: c.id, label: `${c.name} (${typeMeta(c.type).short})` })); if (items.length) groups.push({ label: GROUP_LABELS[group], items }); }); - // Cell-relay: remote cells that offer their internet. Backend wiring for - // cell-relay exits lands in P7; surface them disabled so the option is - // discoverable without breaking assignment. - const relayItems = (cells || []) - .filter((c) => c.remote_exit_offered || c.exit_offered) - .map((c) => ({ - id: `cell:${c.cell_name}`, - label: `${c.cell_name} (cell relay — coming soon)`, - disabled: true, - })); - if (relayItems.length) groups.push({ label: 'Cell relay', items: relayItems }); return { groups }; })(); diff --git a/webui/src/pages/connectivity/shared.jsx b/webui/src/pages/connectivity/shared.jsx index 0122b91..1009af4 100644 --- a/webui/src/pages/connectivity/shared.jsx +++ b/webui/src/pages/connectivity/shared.jsx @@ -50,6 +50,14 @@ export const TYPE_META = { group: 'tor', service: 'tor', }, + cell_relay: { + label: 'Cell relay', + short: 'Cell', + Icon: Network, + color: 'gray', + group: 'cells', + service: null, + }, }; // Subpage groups → which connection types they contain. @@ -58,6 +66,7 @@ export const GROUP_TYPES = { proxies: ['proxy'], ssh: ['sshuttle'], tor: ['tor'], + cells: ['cell_relay'], }; export const GROUP_LABELS = { @@ -65,6 +74,7 @@ export const GROUP_LABELS = { proxies: 'Proxies', ssh: 'SSH', tor: 'Tor', + cells: 'Cells', }; export function typeMeta(type) {