feat: connectivity redesign phase 7 — cell-relay as a connection type
Unit Tests / test (push) Successful in 13m22s

cell exits surface as cell_relay connections via reconcile, bridged onto
the existing cell route_via mechanism, health from handshake, loop
detection, assignable in the unified UI

- CELL_RELAY_TYPE constant; not manually creatable
- reconcile_cell_relays() derives connections from cell links offering an
  exit (name "Cell: <cellname>", mark+table only, no iface/port/container)
- apply_routes bridges cell_relay to existing route_via path via
  apply_peer_route_via + cell firewall rules + set_exit_relay_active;
  keeps peer.route_via in sync
- _probe_cell_relay health from cell handshake + offer state
- _cell_relay_loops loop detection at assign and apply time
- FAILOPEN_DEFAULTS cell_relay=False
- set_peer_exit clears stale route_via on reassignment
- reconcile hooked into PUT /exit-offer and peer-sync/permissions handlers
- cell_link_manager + wireguard_manager wired into connectivity_manager
- UI: cell_relay in TYPE_META/GROUP_TYPES/GROUP_LABELS (Cells optgroup),
  removed "coming soon" placeholder
- 18 new tests in tests/test_connectivity_cell_relay.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:58:19 -04:00
parent 391d8ede48
commit 743b026b01
6 changed files with 688 additions and 14 deletions
+337 -1
View File
@@ -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: <cellname>" 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(
+4
View File
@@ -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,
+10
View File
@@ -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
+323
View File
@@ -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 (ABA)
- 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()
@@ -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 };
})();
+10
View File
@@ -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) {