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
|
||||
|
||||
Reference in New Issue
Block a user