feat: connectivity redesign phase 7 — cell-relay as a connection type
Unit Tests / test (push) Successful in 13m22s
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:
+337
-1
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user