import os import sys import time import logging import asyncio import base64 import hmac import hmac as _hmac_mod import secrets import hashlib as _hashlib_mod from dataclasses import dataclass, field from typing import Any from json import JSONDecodeError APP_VERSION = "0.9.6" logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) _start_time = time.time() _MESH_ONLY = os.environ.get("MESH_ONLY", "").strip().lower() in ("1", "true", "yes") # --------------------------------------------------------------------------- # Docker Swarm Secrets support # For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY), # the file is read and its trimmed content is placed into VAR. # This MUST run before service imports — modules read os.environ at import time. # --------------------------------------------------------------------------- _SECRET_VARS = [ "AIS_API_KEY", "OPENSKY_CLIENT_ID", "OPENSKY_CLIENT_SECRET", "LTA_ACCOUNT_KEY", "CORS_ORIGINS", "ADMIN_KEY", "SHODAN_API_KEY", "FINNHUB_API_KEY", ] for _var in _SECRET_VARS: _file_var = f"{_var}_FILE" _file_path = os.environ.get(_file_var) if _file_path: try: with open(_file_path, "r") as _f: _value = _f.read().strip() if _value: os.environ[_var] = _value logger.info(f"Loaded secret {_var} from {_file_path}") else: logger.warning(f"Secret file {_file_path} for {_var} is empty") except FileNotFoundError: logger.error(f"Secret file {_file_path} for {_var} not found") except Exception as _e: logger.error(f"Failed to read secret file {_file_path} for {_var}: {_e}") from fastapi import FastAPI, Request, Response, Query, Depends, HTTPException from fastapi.responses import JSONResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from starlette.background import BackgroundTask from contextlib import asynccontextmanager from services.data_fetcher import ( start_scheduler, stop_scheduler, get_latest_data, ) from services.ais_stream import start_ais_stream, stop_ais_stream from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from services.schemas import HealthResponse, RefreshResponse from services.config import get_settings import uvicorn import hashlib import math import json as json_mod import orjson import socket from cachetools import TTLCache import threading from services.mesh.mesh_crypto import ( _derive_peer_key, build_signature_payload, derive_node_id, normalize_peer_url, verify_signature, verify_node_binding, parse_public_key_algo, ) from services.mesh.mesh_protocol import ( PROTOCOL_VERSION, normalize_dm_message_payload_legacy, normalize_payload, ) from services.mesh.mesh_schema import validate_event_payload from services.mesh.mesh_infonet_sync_support import ( SyncWorkerState, begin_sync, eligible_sync_peers, finish_sync, should_run_sync, ) from services.mesh.mesh_router import ( authenticated_push_peer_urls, configured_relay_peer_urls, peer_transport_kind, ) limiter = Limiter(key_func=get_remote_address) # --------------------------------------------------------------------------- # Admin authentication — protects settings & system endpoints # Set ADMIN_KEY in .env or Docker secrets. If unset, endpoints remain open # for local-dev convenience but will log a startup warning. # --------------------------------------------------------------------------- def _current_admin_key() -> str: try: return str(get_settings().ADMIN_KEY or "").strip() except Exception: return os.environ.get("ADMIN_KEY", "").strip() def _allow_insecure_admin() -> bool: try: settings = get_settings() return bool(getattr(settings, "ALLOW_INSECURE_ADMIN", False)) and bool( getattr(settings, "MESH_DEBUG_MODE", False) ) except Exception: return False def _debug_mode_enabled() -> bool: try: return bool(getattr(get_settings(), "MESH_DEBUG_MODE", False)) except Exception: return False def _admin_key_required_in_production() -> bool: try: settings = get_settings() return not bool(getattr(settings, "MESH_DEBUG_MODE", False)) and not bool(_current_admin_key()) except Exception: return False def _scoped_admin_tokens() -> dict[str, list[str]]: raw = str(get_settings().MESH_SCOPED_TOKENS or "").strip() if not raw: return {} try: parsed = json_mod.loads(raw) except Exception as exc: logger.warning("failed to parse MESH_SCOPED_TOKENS: %s", exc) return {} if not isinstance(parsed, dict): logger.warning("MESH_SCOPED_TOKENS must decode to an object mapping token -> scopes") return {} normalized: dict[str, list[str]] = {} for token, scopes in parsed.items(): token_key = str(token or "").strip() if not token_key: continue values = scopes if isinstance(scopes, list) else [scopes] normalized[token_key] = [str(scope or "").strip() for scope in values if str(scope or "").strip()] return normalized def _required_scope_for_request(request: Request) -> str: path = str(request.url.path or "") if path.startswith("/api/wormhole/gate/"): return "gate" if path.startswith("/api/wormhole/dm/"): return "dm" if path.startswith("/api/wormhole") or path in {"/api/settings/wormhole", "/api/settings/privacy-profile"}: return "wormhole" if path.startswith("/api/mesh/"): return "mesh" return "admin" def _scope_allows(required_scope: str, allowed_scopes: list[str]) -> bool: for scope in allowed_scopes: normalized = str(scope or "").strip() if not normalized: continue if normalized == "*" or required_scope == normalized: return True if required_scope.startswith(f"{normalized}.") or required_scope.startswith(f"{normalized}/"): return True return False def _check_scoped_auth(request: Request, required_scope: str) -> tuple[bool, str]: admin_key = _current_admin_key() scoped_tokens = _scoped_admin_tokens() presented = str(request.headers.get("X-Admin-Key", "") or "").strip() host = (request.client.host or "").lower() if request.client else "" if admin_key and hmac.compare_digest(presented.encode(), admin_key.encode()): return True, "ok" if presented: presented_bytes = presented.encode() for token_value, scopes in scoped_tokens.items(): if hmac.compare_digest(presented_bytes, str(token_value or "").encode()): if _scope_allows(required_scope, scopes): return True, "ok" return False, "insufficient scope" if not admin_key and not scoped_tokens: if _allow_insecure_admin() or (_debug_mode_enabled() and host == "test"): return True, "ok" return False, "Forbidden — admin key not configured" return False, "Forbidden — invalid or missing admin key" def _public_mesh_log_entry(entry: dict[str, Any]) -> dict[str, Any] | None: tier_str = str((entry or {}).get("trust_tier", "public_degraded") or "public_degraded").strip().lower() if tier_str.startswith("private_"): return None return { "sender": str((entry or {}).get("sender", "") or ""), "destination": str((entry or {}).get("destination", "") or ""), "routed_via": str((entry or {}).get("routed_via", "") or ""), "priority": str((entry or {}).get("priority", "") or ""), "route_reason": str((entry or {}).get("route_reason", "") or ""), "timestamp": float((entry or {}).get("timestamp", 0) or 0), } def _public_mesh_log_size(entries: list[dict[str, Any]]) -> int: return sum(1 for item in entries if _public_mesh_log_entry(item) is not None) _WORMHOLE_PUBLIC_SETTINGS_FIELDS = {"enabled", "transport", "anonymous_mode"} _WORMHOLE_PUBLIC_PROFILE_FIELDS = {"profile", "wormhole_enabled"} _PRIVATE_LANE_CONTROL_FIELDS = {"private_lane_tier", "private_lane_policy"} _PUBLIC_RNS_STATUS_FIELDS = {"enabled", "ready", "configured_peers", "active_peers"} _NODE_RUNTIME_LOCK = threading.RLock() _NODE_SYNC_STOP = threading.Event() _NODE_SYNC_STATE = SyncWorkerState() _NODE_BOOTSTRAP_STATE: dict[str, Any] = { "node_mode": "participant", "manifest_loaded": False, "manifest_signer_id": "", "manifest_valid_until": 0, "bootstrap_peer_count": 0, "sync_peer_count": 0, "push_peer_count": 0, "operator_peer_count": 0, "last_bootstrap_error": "", } _NODE_PUSH_STATE: dict[str, Any] = { "last_event_id": "", "last_push_ok_at": 0, "last_push_error": "", "last_results": [], } _NODE_PUBLIC_EVENT_HOOK_REGISTERED = False def _current_node_mode() -> str: mode = str(get_settings().MESH_NODE_MODE or "participant").strip().lower() if mode not in {"participant", "relay", "perimeter"}: return "participant" return mode def _node_runtime_supported() -> bool: return _current_node_mode() in {"participant", "relay"} def _node_activation_enabled() -> bool: from services.node_settings import read_node_settings try: settings = read_node_settings() except Exception: return False return bool(settings.get("enabled", False)) def _participant_node_enabled() -> bool: return _node_runtime_supported() and _node_activation_enabled() def _node_runtime_snapshot() -> dict[str, Any]: with _NODE_RUNTIME_LOCK: return { "node_mode": _NODE_BOOTSTRAP_STATE.get("node_mode", "participant"), "node_enabled": _participant_node_enabled(), "bootstrap": dict(_NODE_BOOTSTRAP_STATE), "sync_runtime": _NODE_SYNC_STATE.to_dict(), "push_runtime": dict(_NODE_PUSH_STATE), } def _set_node_sync_disabled_state(*, current_head: str = "") -> SyncWorkerState: return SyncWorkerState( current_head=str(current_head or ""), last_outcome="disabled", ) def _set_participant_node_enabled(enabled: bool) -> dict[str, Any]: from services.mesh.mesh_hashchain import infonet from services.node_settings import write_node_settings settings = write_node_settings(enabled=bool(enabled)) current_head = str(infonet.head_hash or "") with _NODE_RUNTIME_LOCK: _NODE_BOOTSTRAP_STATE["node_mode"] = _current_node_mode() globals()["_NODE_SYNC_STATE"] = ( SyncWorkerState(current_head=current_head) if bool(enabled) and _node_runtime_supported() else _set_node_sync_disabled_state(current_head=current_head) ) return { **settings, "node_mode": _current_node_mode(), "node_enabled": _participant_node_enabled(), } def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]: from services.mesh.mesh_bootstrap_manifest import load_bootstrap_manifest_from_settings from services.mesh.mesh_peer_store import ( DEFAULT_PEER_STORE_PATH, PeerStore, make_bootstrap_peer_record, make_push_peer_record, make_sync_peer_record, ) timestamp = int(now if now is not None else time.time()) mode = _current_node_mode() store = PeerStore(DEFAULT_PEER_STORE_PATH) try: store.load() except Exception: store = PeerStore(DEFAULT_PEER_STORE_PATH) operator_peers = configured_relay_peer_urls() for peer_url in operator_peers: transport = peer_transport_kind(peer_url) if not transport: continue store.upsert( make_sync_peer_record( peer_url=peer_url, transport=transport, role="relay", source="operator", now=timestamp, ) ) store.upsert( make_push_peer_record( peer_url=peer_url, transport=transport, role="relay", source="operator", now=timestamp, ) ) manifest = None bootstrap_error = "" try: manifest = load_bootstrap_manifest_from_settings(now=timestamp) except Exception as exc: bootstrap_error = str(exc or "").strip() if manifest is not None: for peer in manifest.peers: store.upsert( make_bootstrap_peer_record( peer_url=peer.peer_url, transport=peer.transport, role=peer.role, label=peer.label, signer_id=manifest.signer_id, now=timestamp, ) ) store.upsert( make_sync_peer_record( peer_url=peer.peer_url, transport=peer.transport, role=peer.role, source="bootstrap_promoted", label=peer.label, signer_id=manifest.signer_id, now=timestamp, ) ) store.save() snapshot = { "node_mode": mode, "manifest_loaded": manifest is not None, "manifest_signer_id": manifest.signer_id if manifest is not None else "", "manifest_valid_until": int(manifest.valid_until or 0) if manifest is not None else 0, "bootstrap_peer_count": len(store.records_for_bucket("bootstrap")), "sync_peer_count": len(store.records_for_bucket("sync")), "push_peer_count": len(store.records_for_bucket("push")), "operator_peer_count": len(operator_peers), "last_bootstrap_error": bootstrap_error, } with _NODE_RUNTIME_LOCK: _NODE_BOOTSTRAP_STATE.update(snapshot) return snapshot def _materialize_local_infonet_state() -> None: from services.mesh.mesh_hashchain import infonet infonet.ensure_materialized() def _peer_sync_response(peer_url: str, body: dict[str, Any]) -> dict[str, Any]: import requests as _requests from services.wormhole_supervisor import _check_arti_ready normalized = normalize_peer_url(peer_url) if not normalized: raise ValueError("invalid peer URL") timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10) kwargs: dict[str, Any] = { "json": body, "timeout": timeout, "headers": {"Content-Type": "application/json"}, } if peer_transport_kind(normalized) == "onion": if not bool(get_settings().MESH_ARTI_ENABLED): raise RuntimeError("onion sync requires Arti to be enabled") if not _check_arti_ready(): raise RuntimeError("onion sync requires a ready Arti transport") socks_port = int(get_settings().MESH_ARTI_SOCKS_PORT or 9050) proxy = f"socks5h://127.0.0.1:{socks_port}" kwargs["proxies"] = {"http": proxy, "https": proxy} response = _requests.post(f"{normalized}/api/mesh/infonet/sync", **kwargs) try: payload = response.json() except Exception as exc: raise ValueError(f"peer sync returned non-JSON response ({response.status_code})") from exc if response.status_code != 200: detail = str(payload.get("detail", "") or f"HTTP {response.status_code}").strip() raise ValueError(detail or f"HTTP {response.status_code}") if not isinstance(payload, dict): raise ValueError("peer sync returned malformed payload") return payload def _hydrate_gate_store_from_chain(events: list[dict]) -> int: """Copy any gate_message chain events into the local gate_store for read/decrypt.""" import copy from services.mesh.mesh_hashchain import gate_store count = 0 gate_ids_updated: set[str] = set() for evt in events: if evt.get("event_type") != "gate_message": continue payload = evt.get("payload") or {} gate_id = str(payload.get("gate", "") or "").strip() if not gate_id: continue try: # Deep copy so gate_store mutations (e.g. adding gate_envelope) # don't corrupt the chain event's payload hash. gate_store.append(gate_id, copy.deepcopy(evt)) count += 1 gate_ids_updated.add(gate_id) except Exception: pass # Notify SSE clients so frontends refresh immediately. for gid in gate_ids_updated: try: _broadcast_gate_events(gid, [{"hydrated": True}]) except Exception: pass return count def _sync_from_peer(peer_url: str, *, page_limit: int = 100, max_rounds: int = 5) -> tuple[bool, str, bool]: from services.mesh.mesh_hashchain import infonet rounds = 0 while rounds < max_rounds: body = { "protocol_version": PROTOCOL_VERSION, "locator": infonet.get_locator(), "limit": page_limit, } payload = _peer_sync_response(peer_url, body) if bool(payload.get("forked")): # Auto-recover small local forks: if the local chain is tiny # (< 20 events) and the remote has a longer chain, reset local # state and re-sync from genesis instead of failing forever. remote_count = int(payload.get("count", 0) or 0) local_count = len(infonet.events) if local_count < 20: logger.warning( "Fork detected with small local chain (%d events). " "Resetting to re-sync from peer (remote has %d events).", local_count, remote_count, ) infonet.reset_chain() continue # retry sync with clean genesis locator return False, "fork detected", True events = payload.get("events", []) if not isinstance(events, list): return False, "peer sync events must be a list", False if not events: return True, "", False result = infonet.ingest_events(events) _hydrate_gate_store_from_chain(events) rejected = list(result.get("rejected", []) or []) if rejected: return False, f"sync ingest rejected {len(rejected)} event(s)", False if int(result.get("accepted", 0) or 0) == 0 and int(result.get("duplicates", 0) or 0) >= len(events): return True, "", False if len(events) < page_limit: return True, "", False rounds += 1 return True, "", False def _run_public_sync_cycle() -> SyncWorkerState: from services.mesh.mesh_hashchain import infonet from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore if not _participant_node_enabled(): updated = _set_node_sync_disabled_state(current_head=infonet.head_hash) with _NODE_RUNTIME_LOCK: globals()["_NODE_SYNC_STATE"] = updated return updated store = PeerStore(DEFAULT_PEER_STORE_PATH) try: store.load() except Exception: store = PeerStore(DEFAULT_PEER_STORE_PATH) peers = eligible_sync_peers(store.records(), now=time.time()) current_state = _NODE_SYNC_STATE if not peers: updated = finish_sync( current_state, ok=False, error="no active sync peers", now=time.time(), current_head=infonet.head_hash, failure_backoff_s=int(get_settings().MESH_SYNC_FAILURE_BACKOFF_S or 60), ) with _NODE_RUNTIME_LOCK: globals()["_NODE_SYNC_STATE"] = updated return updated last_error = "sync failed" for record in peers: started = begin_sync( current_state, peer_url=record.peer_url, current_head=infonet.head_hash, now=time.time(), ) with _NODE_RUNTIME_LOCK: globals()["_NODE_SYNC_STATE"] = started try: ok, error, forked = _sync_from_peer(record.peer_url) except Exception as exc: ok = False error = str(exc or type(exc).__name__) forked = False if ok: store.mark_seen(record.peer_url, "sync", now=time.time()) store.mark_sync_success(record.peer_url, now=time.time()) store.save() updated = finish_sync( started, ok=True, peer_url=record.peer_url, current_head=infonet.head_hash, now=time.time(), interval_s=int(get_settings().MESH_SYNC_INTERVAL_S or 300), ) with _NODE_RUNTIME_LOCK: globals()["_NODE_SYNC_STATE"] = updated return updated last_error = error store.mark_failure( record.peer_url, "sync", error=error, cooldown_s=int(get_settings().MESH_RELAY_FAILURE_COOLDOWN_S or 120), now=time.time(), ) store.save() updated = finish_sync( started, ok=False, peer_url=record.peer_url, current_head=infonet.head_hash, error=error, fork_detected=forked, now=time.time(), interval_s=int(get_settings().MESH_SYNC_INTERVAL_S or 300), failure_backoff_s=int(get_settings().MESH_SYNC_FAILURE_BACKOFF_S or 60), ) with _NODE_RUNTIME_LOCK: globals()["_NODE_SYNC_STATE"] = updated if forked: return updated current_state = updated return updated if peers else finish_sync( current_state, ok=False, error=last_error, now=time.time(), current_head=infonet.head_hash, failure_backoff_s=int(get_settings().MESH_SYNC_FAILURE_BACKOFF_S or 60), ) def _public_infonet_sync_loop() -> None: from services.mesh.mesh_hashchain import infonet while not _NODE_SYNC_STOP.is_set(): try: if not _node_runtime_supported(): _NODE_SYNC_STOP.wait(5.0) continue if not _participant_node_enabled(): disabled = _set_node_sync_disabled_state(current_head=infonet.head_hash) with _NODE_RUNTIME_LOCK: globals()["_NODE_SYNC_STATE"] = disabled _NODE_SYNC_STOP.wait(5.0) continue state = _NODE_SYNC_STATE if should_run_sync(state, now=time.time()): _run_public_sync_cycle() except Exception: logger.exception("public infonet sync loop failed") _NODE_SYNC_STOP.wait(5.0) def _record_public_push_result(event_id: str, *, ok: bool, error: str = "", results: list[dict[str, Any]] | None = None) -> None: snapshot = { "last_event_id": str(event_id or ""), "last_push_ok_at": int(time.time()) if ok else int(_NODE_PUSH_STATE.get("last_push_ok_at", 0) or 0), "last_push_error": "" if ok else str(error or "").strip(), "last_results": list(results or []), } with _NODE_RUNTIME_LOCK: _NODE_PUSH_STATE.update(snapshot) def _propagate_public_event_to_peers(event_dict: dict[str, Any]) -> None: from services.mesh.mesh_router import MeshEnvelope, mesh_router if not _participant_node_enabled(): return if not authenticated_push_peer_urls(): return envelope = MeshEnvelope( sender_id=str(event_dict.get("node_id", "") or ""), destination="broadcast", payload=json_mod.dumps(event_dict, sort_keys=True, separators=(",", ":"), ensure_ascii=False), trust_tier="public_degraded", ) results = [] for transport in (mesh_router.internet, mesh_router.tor_arti): try: if transport.can_reach(envelope): result = transport.send(envelope, {}) results.append(result.to_dict()) except Exception as exc: results.append({"ok": False, "transport": getattr(transport, "NAME", "unknown"), "detail": type(exc).__name__}) ok = any(bool(result.get("ok")) for result in results) _record_public_push_result( str(event_dict.get("event_id", "") or ""), ok=ok, error="" if ok else "all push peers failed", results=results, ) def _schedule_public_event_propagation(event_dict: dict[str, Any]) -> None: threading.Thread( target=_propagate_public_event_to_peers, args=(dict(event_dict),), daemon=True, ).start() # ─── Background HTTP Peer Push Worker ──────────────────────────────────── # Runs alongside the sync loop. Every PUSH_INTERVAL seconds, batches new # Infonet events and sends them via HMAC-authenticated POST to push peers. _PEER_PUSH_INTERVAL_S = 10 _PEER_PUSH_BATCH_SIZE = 50 _peer_push_last_index: dict[str, int] = {} # peer_url → last pushed event index def _http_peer_push_loop() -> None: """Background thread: push new Infonet events to HTTP peers.""" import requests as _requests from services.mesh.mesh_hashchain import infonet from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore while not _NODE_SYNC_STOP.is_set(): try: if not _participant_node_enabled(): _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() if not secret: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue peers = authenticated_push_peer_urls() if not peers: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue all_events = infonet.events total = len(all_events) for peer_url in peers: normalized = normalize_peer_url(peer_url) if not normalized: continue last_idx = _peer_push_last_index.get(normalized, 0) if last_idx >= total: continue # nothing new batch = all_events[last_idx : last_idx + _PEER_PUSH_BATCH_SIZE] if not batch: continue try: body_bytes = json_mod.dumps( {"events": batch}, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") peer_key = _derive_peer_key(secret, normalized) if not peer_key: continue import hmac as _hmac_mod2 import hashlib as _hashlib_mod2 hmac_hex = _hmac_mod2.new(peer_key, body_bytes, _hashlib_mod2.sha256).hexdigest() timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10) resp = _requests.post( f"{normalized}/api/mesh/infonet/peer-push", data=body_bytes, headers={ "Content-Type": "application/json", "X-Peer-HMAC": hmac_hex, }, timeout=timeout, ) if resp.status_code == 200: _peer_push_last_index[normalized] = last_idx + len(batch) logger.info( f"Pushed {len(batch)} event(s) to {normalized[:40]} " f"(idx {last_idx}→{last_idx + len(batch)})" ) else: logger.warning(f"Peer push to {normalized[:40]} returned {resp.status_code}") except Exception as exc: logger.warning(f"Peer push to {normalized[:40]} failed: {exc}") except Exception: logger.exception("HTTP peer push loop error") _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) # ─── Background Gate Message Pull Worker ───────────────────────────────── # Periodically pulls gate events from relay peers that this node is missing. # Complements the push loop: push sends OUR events to peers, pull fetches # THEIR events from peers (needed when this node is behind NAT). _GATE_PULL_INTERVAL_S = 10 _gate_pull_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → known count} def _http_gate_pull_loop() -> None: """Background thread: pull new gate messages from HTTP relay peers.""" import requests as _requests from services.mesh.mesh_hashchain import gate_store while not _NODE_SYNC_STOP.is_set(): try: if not _participant_node_enabled(): _NODE_SYNC_STOP.wait(_GATE_PULL_INTERVAL_S) continue secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() if not secret: _NODE_SYNC_STOP.wait(_GATE_PULL_INTERVAL_S) continue peers = authenticated_push_peer_urls() if not peers: _NODE_SYNC_STOP.wait(_GATE_PULL_INTERVAL_S) continue for peer_url in peers: normalized = normalize_peer_url(peer_url) if not normalized: continue peer_key = _derive_peer_key(secret, normalized) if not peer_key: continue peer_counts = _gate_pull_last_count.setdefault(normalized, {}) try: # Step 1: Ask the peer which gates it has and how many events each discovery_body = json_mod.dumps( {"gate_id": "", "after_count": 0}, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") import hmac as _hmac_pull import hashlib as _hashlib_pull discovery_hmac = _hmac_pull.new(peer_key, discovery_body, _hashlib_pull.sha256).hexdigest() timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10) resp = _requests.post( f"{normalized}/api/mesh/gate/peer-pull", data=discovery_body, headers={ "Content-Type": "application/json", "X-Peer-HMAC": discovery_hmac, }, timeout=timeout, ) if resp.status_code != 200: continue discovery = resp.json() if not discovery.get("ok"): continue remote_gates: dict[str, int] = discovery.get("gates", {}) if not remote_gates: continue # Step 2: For each gate with new events, pull the batch for gate_id, remote_total in remote_gates.items(): local_known = peer_counts.get(gate_id, 0) # Also account for what we already have locally with gate_store._lock: local_count = len(gate_store._gates.get(gate_id, [])) effective_cursor = max(local_known, local_count) if effective_cursor >= remote_total: continue pull_body = json_mod.dumps( {"gate_id": gate_id, "after_count": effective_cursor}, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") pull_hmac = _hmac_pull.new(peer_key, pull_body, _hashlib_pull.sha256).hexdigest() pull_resp = _requests.post( f"{normalized}/api/mesh/gate/peer-pull", data=pull_body, headers={ "Content-Type": "application/json", "X-Peer-HMAC": pull_hmac, }, timeout=timeout, ) if pull_resp.status_code != 200: continue pull_data = pull_resp.json() if not pull_data.get("ok"): continue events = pull_data.get("events", []) if not events: peer_counts[gate_id] = remote_total continue result = gate_store.ingest_peer_events(gate_id, events) accepted = int(result.get("accepted", 0) or 0) dups = int(result.get("duplicates", 0) or 0) if accepted > 0: _broadcast_gate_events(gate_id, events[:accepted]) logger.info( "Gate pull: %d new event(s) for %s from %s", accepted, gate_id[:12], normalized[:40], ) peer_counts[gate_id] = effective_cursor + len(events) except Exception as exc: logger.warning("Gate pull from %s failed: %s", normalized[:40], exc) except Exception: logger.exception("HTTP gate pull loop error") _NODE_SYNC_STOP.wait(_GATE_PULL_INTERVAL_S) # ─── SSE Gate Event Broadcast ───────────────────────────────────────────── # All connected SSE clients receive every gate event (encrypted blobs). # Clients filter locally by gate_id — the server never learns which gates # a client cares about (privacy-preserving broadcast). _gate_sse_clients: set[asyncio.Queue] = set() _gate_sse_lock = threading.Lock() def _broadcast_gate_events(gate_id: str, events: list[dict]) -> None: """Notify all connected SSE clients about new gate events (non-blocking). Called from background daemon threads (push/pull loops) AND the FastAPI event-loop thread. asyncio.Queue.put_nowait() is NOT thread-safe, so background callers schedule via loop.call_soon_threadsafe(). """ if not events: return payload = json_mod.dumps( {"gate_id": gate_id, "count": len(events), "ts": time.time()}, separators=(",", ":"), ensure_ascii=False, ) # Detect whether we're already on the event-loop thread. try: asyncio.get_running_loop() _in_loop = True except RuntimeError: _in_loop = False _loop: asyncio.AbstractEventLoop | None = None if not _in_loop: try: _loop = asyncio.get_event_loop() if not _loop.is_running(): _loop = None except RuntimeError: _loop = None with _gate_sse_lock: dead: list[asyncio.Queue] = [] for q in _gate_sse_clients: try: if _in_loop: q.put_nowait(payload) elif _loop is not None: _loop.call_soon_threadsafe(q.put_nowait, payload) else: q.put_nowait(payload) # best-effort fallback except (asyncio.QueueFull, Exception): dead.append(q) for q in dead: _gate_sse_clients.discard(q) # ─── Background Gate Message Push Worker ───────────────────────────────── _gate_push_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → count} def _http_gate_push_loop() -> None: """Background thread: push new gate messages to HTTP peers.""" import requests as _requests from services.mesh.mesh_hashchain import gate_store while not _NODE_SYNC_STOP.is_set(): try: if not _participant_node_enabled(): _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() if not secret: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue peers = authenticated_push_peer_urls() if not peers: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue with gate_store._lock: gate_ids = list(gate_store._gates.keys()) for peer_url in peers: normalized = normalize_peer_url(peer_url) if not normalized: continue peer_key = _derive_peer_key(secret, normalized) if not peer_key: continue peer_counts = _gate_push_last_count.setdefault(normalized, {}) for gate_id in gate_ids: with gate_store._lock: all_events = list(gate_store._gates.get(gate_id, [])) total = len(all_events) last = peer_counts.get(gate_id, 0) if last >= total: continue batch = all_events[last : last + _PEER_PUSH_BATCH_SIZE] if not batch: continue try: body_bytes = json_mod.dumps( {"events": batch}, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") import hmac as _hmac_mod3 import hashlib as _hashlib_mod3 hmac_hex = _hmac_mod3.new(peer_key, body_bytes, _hashlib_mod3.sha256).hexdigest() timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10) resp = _requests.post( f"{normalized}/api/mesh/gate/peer-push", data=body_bytes, headers={ "Content-Type": "application/json", "X-Peer-HMAC": hmac_hex, }, timeout=timeout, ) if resp.status_code == 200: peer_counts[gate_id] = last + len(batch) logger.info( f"Gate push: {len(batch)} event(s) for {gate_id[:12]} " f"to {normalized[:40]}" ) else: logger.warning( f"Gate push to {normalized[:40]} returned {resp.status_code}" ) except Exception as exc: logger.warning(f"Gate push to {normalized[:40]} failed: {exc}") except Exception: logger.exception("HTTP gate push loop error") _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) def _scoped_view_authenticated(request: Request, scope: str) -> bool: ok, _detail = _check_scoped_auth(request, scope) if ok: return True return _is_debug_test_request(request) def _redacted_gate_timestamp(event: dict[str, Any]) -> float: raw_ts = float((event or {}).get("timestamp", 0) or 0.0) if raw_ts <= 0: return 0.0 try: jitter_window = max(0, int(get_settings().MESH_GATE_TIMESTAMP_JITTER_S or 0)) except Exception: jitter_window = 0 if jitter_window <= 0: return raw_ts event_id = str((event or {}).get("event_id", "") or "") seed = _hashlib_mod.sha256(f"{event_id}|{int(raw_ts)}".encode("utf-8")).digest() fraction = int.from_bytes(seed[:8], "big") / float(2**64 - 1) return max(0.0, raw_ts - (fraction * float(jitter_window))) def _redact_wormhole_settings(settings: dict[str, Any], authenticated: bool) -> dict[str, Any]: if authenticated: return dict(settings) return { key: settings.get(key) for key in _WORMHOLE_PUBLIC_SETTINGS_FIELDS if key in settings } def _redact_privacy_profile_settings( settings: dict[str, Any], authenticated: bool, ) -> dict[str, Any]: profile = { "profile": settings.get("privacy_profile", "default"), "wormhole_enabled": bool(settings.get("enabled")), "transport": settings.get("transport", "direct"), "anonymous_mode": bool(settings.get("anonymous_mode")), } if authenticated: return profile return { key: profile.get(key) for key in _WORMHOLE_PUBLIC_PROFILE_FIELDS } def _redact_private_lane_control_fields( payload: dict[str, Any], authenticated: bool, ) -> dict[str, Any]: redacted = dict(payload) if authenticated: return redacted for field in _PRIVATE_LANE_CONTROL_FIELDS: redacted.pop(field, None) return redacted def _redact_public_rns_status( payload: dict[str, Any], authenticated: bool, ) -> dict[str, Any]: redacted = _redact_private_lane_control_fields(payload, authenticated=authenticated) if authenticated: return redacted return { key: redacted.get(key) for key in _PUBLIC_RNS_STATUS_FIELDS if key in redacted } def _redact_public_mesh_status( payload: dict[str, Any], authenticated: bool, ) -> dict[str, Any]: if authenticated: return dict(payload) return { "message_log_size": int(payload.get("message_log_size", 0) or 0), } def _redact_public_oracle_profile( payload: dict[str, Any], authenticated: bool, ) -> dict[str, Any]: redacted = dict(payload) if authenticated: return redacted redacted["active_stakes"] = [] redacted["prediction_history"] = [] return redacted def _redact_public_oracle_predictions( predictions: list[dict[str, Any]], authenticated: bool, ) -> dict[str, Any]: if authenticated: return {"predictions": list(predictions)} return { "predictions": [], "count": len(predictions), } def _redact_public_oracle_stakes( payload: dict[str, Any], authenticated: bool, ) -> dict[str, Any]: redacted = dict(payload) if authenticated: return redacted redacted["truth_stakers"] = [] redacted["false_stakers"] = [] return redacted def _redact_public_node_history( events: list[dict[str, Any]], authenticated: bool, ) -> list[dict[str, Any]]: if authenticated: return [dict(event) for event in events] return [ { "event_id": str(event.get("event_id", "") or ""), "event_type": str(event.get("event_type", "") or ""), "timestamp": float(event.get("timestamp", 0) or 0), } for event in events ] def _redact_composed_gate_message(payload: dict[str, Any]) -> dict[str, Any]: safe = { "ok": bool(payload.get("ok")), "gate_id": str(payload.get("gate_id", "") or ""), "identity_scope": str(payload.get("identity_scope", "") or ""), "ciphertext": str(payload.get("ciphertext", "") or ""), "nonce": str(payload.get("nonce", "") or ""), "sender_ref": str(payload.get("sender_ref", "") or ""), "format": str(payload.get("format", "mls1") or "mls1"), "timestamp": float(payload.get("timestamp", 0) or 0), } epoch = payload.get("epoch", 0) if epoch: safe["epoch"] = int(epoch or 0) if payload.get("detail"): safe["detail"] = str(payload.get("detail", "") or "") if payload.get("key_commitment"): safe["key_commitment"] = str(payload.get("key_commitment", "") or "") return safe def _validate_admin_startup() -> None: admin_key = _current_admin_key() debug_mode = False try: debug_mode = bool(getattr(get_settings(), "MESH_DEBUG_MODE", False)) except Exception: debug_mode = False if not admin_key: logger.warning( "ADMIN_KEY is not set — admin/mesh endpoints will be unavailable. " "Set ADMIN_KEY in your .env file to enable them." ) if admin_key: if len(admin_key) < 16: message = ( f"ADMIN_KEY is too short ({len(admin_key)} chars, minimum 16). " "Use a strong key." ) if debug_mode: logger.warning("%s Debug mode allows startup.", message) else: logger.critical("%s Refusing to start.", message) sys.exit(1) elif len(admin_key) < 32: logger.warning( "ADMIN_KEY is short (%s chars). Consider using at least 32 characters for production.", len(admin_key), ) def require_admin(request: Request): """FastAPI dependency that rejects requests without a valid X-Admin-Key header.""" required_scope = _required_scope_for_request(request) ok, detail = _check_scoped_auth(request, required_scope) if ok: return if detail == "insufficient scope": raise HTTPException(status_code=403, detail="Forbidden — insufficient scope") raise HTTPException(status_code=403, detail=detail) def _is_local_or_docker(host: str) -> bool: """Return True if the IP is loopback or a Docker-internal private network.""" if host in {"127.0.0.1", "::1", "localhost"}: return True # Docker bridge networks use 172.x.x.x or 192.168.x.x ranges if host.startswith("172.") or host.startswith("192.168.") or host.startswith("10."): return True return False def require_local_operator(request: Request): """Allow local tooling on loopback / Docker internal network, or a valid admin key.""" host = (request.client.host or "").lower() if request.client else "" if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"): return admin_key = _current_admin_key() presented = str(request.headers.get("X-Admin-Key", "") or "").strip() if admin_key and hmac.compare_digest(presented.encode(), admin_key.encode()): return raise HTTPException(status_code=403, detail="Forbidden — local operator access only") def _build_cors_origins(): """Build a CORS origins whitelist: localhost + LAN IPs + env overrides. Falls back to wildcard only if auto-detection fails entirely.""" origins = [ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8000", "http://127.0.0.1:8000", ] # Add this machine's LAN IPs (covers common home/office setups) try: hostname = socket.gethostname() for info in socket.getaddrinfo(hostname, None, socket.AF_INET): ip = info[4][0] if ip not in ("127.0.0.1", "0.0.0.0"): origins.append(f"http://{ip}:3000") origins.append(f"http://{ip}:8000") except Exception: pass # Allow user override via CORS_ORIGINS env var (comma-separated) extra = os.environ.get("CORS_ORIGINS", "") if extra: origins.extend([o.strip() for o in extra.split(",") if o.strip()]) return list(set(origins)) # deduplicate def _safe_int(val, default=0): try: return int(val) except (TypeError, ValueError): return default def _safe_float(val, default=0.0): try: parsed = float(val) if not math.isfinite(parsed): return default return parsed except (TypeError, ValueError): return default @asynccontextmanager async def lifespan(app: FastAPI): _validate_admin_startup() # Validate environment variables before starting anything from services.env_check import validate_env validate_env(strict=not _MESH_ONLY) if _MESH_ONLY: logger.info("MESH_ONLY enabled — skipping global data fetchers/schedulers.") else: # Start AIS stream first — it loads the disk cache (instant ships) then # begins accumulating live vessel data via WebSocket in the background. start_ais_stream() # Carrier tracker runs its own initial update_carrier_positions() internally # in _scheduler_loop, so we do NOT call it again in the preload thread. start_carrier_tracker() # Start SIGINT grid eagerly — APRS-IS TCP + Meshtastic MQTT connections # take a few seconds to handshake and start receiving packets. By starting # now, the bridges are already accumulating signals by the time the first # fetch_sigint() reads them during the preload cycle. from services.sigint_bridge import sigint_grid sigint_grid.start() # Start Reticulum bridge (optional) try: from services.mesh.mesh_rns import rns_bridge rns_bridge.start() except Exception as e: logger.warning(f"RNS bridge failed to start: {e}") # Start periodic Infonet verifier def _verify_loop(): from services.mesh.mesh_hashchain import infonet while True: try: interval = int(get_settings().MESH_VERIFY_INTERVAL_S or 0) if interval <= 0: time.sleep(30) continue verify_signatures = bool(get_settings().MESH_VERIFY_SIGNATURES) valid, reason = infonet.validate_chain_incremental(verify_signatures=verify_signatures) if not valid: logger.error(f"Infonet validation failed: {reason}") try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("infonet_validate_failed") except Exception: pass time.sleep(max(5, interval)) except Exception: time.sleep(30) threading.Thread(target=_verify_loop, daemon=True).start() # Only the primary backend supervises Wormhole. The Wormhole process itself # runs this same app in MESH_ONLY mode and must not recurse into spawning. if not _MESH_ONLY: try: from services.wormhole_supervisor import sync_wormhole_with_settings sync_wormhole_with_settings() except Exception as e: logger.warning(f"Wormhole supervisor failed to sync: {e}") try: from services.mesh.mesh_hashchain import register_public_event_append_hook _materialize_local_infonet_state() _refresh_node_peer_store() if _node_runtime_supported(): if not _participant_node_enabled(): globals()["_NODE_SYNC_STATE"] = _set_node_sync_disabled_state() _NODE_SYNC_STOP.clear() threading.Thread(target=_public_infonet_sync_loop, daemon=True).start() threading.Thread(target=_http_peer_push_loop, daemon=True).start() threading.Thread(target=_http_gate_push_loop, daemon=True).start() threading.Thread(target=_http_gate_pull_loop, daemon=True).start() global _NODE_PUBLIC_EVENT_HOOK_REGISTERED if not _NODE_PUBLIC_EVENT_HOOK_REGISTERED: register_public_event_append_hook(_schedule_public_event_propagation) _NODE_PUBLIC_EVENT_HOOK_REGISTERED = True except Exception as e: logger.warning(f"Node bootstrap runtime failed to initialize: {e}") if not _MESH_ONLY: # Start the recurring scheduler (fast=60s, slow=30min). start_scheduler() # Kick off the full data preload in a background thread so the server # is listening on port 8000 instantly. The frontend's adaptive polling # (retries every 3s) will pick up data piecemeal as each fetcher finishes. def _background_preload(): logger.info("=== PRELOADING DATA (background — server already accepting requests) ===") try: update_all_data(startup_mode=True) logger.info("=== PRELOAD COMPLETE ===") except Exception as e: logger.error(f"Data preload failed (non-fatal): {e}") threading.Thread(target=_background_preload, daemon=True).start() yield if not _MESH_ONLY: # Shutdown: Stop all background services _NODE_SYNC_STOP.set() stop_ais_stream() stop_scheduler() stop_carrier_tracker() try: sigint_grid.stop() except Exception: pass if not _MESH_ONLY: try: from services.wormhole_supervisor import shutdown_wormhole_supervisor shutdown_wormhole_supervisor() except Exception: pass app = FastAPI(title="Live Risk Dashboard API", lifespan=lifespan) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @app.exception_handler(JSONDecodeError) async def json_decode_error_handler(_request: Request, _exc: JSONDecodeError): return JSONResponse(status_code=422, content={"ok": False, "detail": "invalid JSON body"}) from fastapi.middleware.gzip import GZipMiddleware app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware( CORSMiddleware, allow_origins=_build_cors_origins(), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) _NO_STORE_HEADERS = { "Cache-Control": "no-store, max-age=0", "Pragma": "no-cache", } _SECURITY_HEADERS_PROD = { "Content-Security-Policy": ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' blob:; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' ws: wss: https:; " "font-src 'self' data:; " "object-src 'none'; " "frame-ancestors 'none'; " "base-uri 'self'" ), "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", } _SECURITY_HEADERS_DEBUG = { **_SECURITY_HEADERS_PROD, "Content-Security-Policy": ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob: https:; " "connect-src 'self' ws: wss: http://127.0.0.1:8000 http://127.0.0.1:8787 https:; " "font-src 'self' data:; " "object-src 'none'; " "frame-ancestors 'none'; " "base-uri 'self'" ), } def _security_headers() -> dict[str, str]: return _SECURITY_HEADERS_DEBUG if _debug_mode_enabled() else _SECURITY_HEADERS_PROD @app.middleware("http") async def mesh_security_headers(request: Request, call_next): response = await call_next(request) for header, value in _security_headers().items(): response.headers.setdefault(header, value) return response @app.middleware("http") async def mesh_no_store_headers(request: Request, call_next): response = await call_next(request) if request.url.path.startswith("/api/mesh/"): response.headers["Cache-Control"] = "no-store, max-age=0" response.headers["Pragma"] = "no-cache" return response def _is_anonymous_mesh_write_path(path: str, method: str) -> bool: if method.upper() not in {"POST", "PUT", "DELETE"}: return False if path == "/api/mesh/send": return True if path in { "/api/mesh/vote", "/api/mesh/report", "/api/mesh/trust/vouch", "/api/mesh/gate/create", "/api/mesh/oracle/predict", "/api/mesh/oracle/resolve", "/api/mesh/oracle/stake", "/api/mesh/oracle/resolve-stakes", }: return True if path.startswith("/api/mesh/gate/") and path.endswith("/message"): return True return False def _is_anonymous_dm_action_path(path: str, method: str) -> bool: method_name = method.upper() if method_name == "POST" and path in { "/api/mesh/dm/register", "/api/mesh/dm/send", "/api/mesh/dm/poll", "/api/mesh/dm/count", "/api/mesh/dm/block", "/api/mesh/dm/witness", }: return True if method_name == "GET" and path in { "/api/mesh/dm/pubkey", "/api/mesh/dm/prekey-bundle", }: return True return False def _is_anonymous_wormhole_gate_admin_path(path: str, method: str) -> bool: if method.upper() != "POST": return False return path in { "/api/wormhole/gate/enter", "/api/wormhole/gate/persona/create", "/api/wormhole/gate/persona/activate", "/api/wormhole/gate/persona/retire", } def _is_private_infonet_write_path(path: str, method: str) -> bool: if method.upper() != "POST": return False if path in { "/api/mesh/gate/create", "/api/mesh/vote", }: return True return path.startswith("/api/mesh/gate/") and path.endswith("/message") def _validate_gate_vote_context(voter_id: str, gate_id: str) -> tuple[bool, str]: gate_key = str(gate_id or "").strip().lower() if not gate_key: return True, "" try: from services.mesh.mesh_reputation import gate_manager except Exception as exc: return False, f"Gate validation unavailable: {exc}" gate = gate_manager.get_gate(gate_key) if not gate: return False, f"Gate '{gate_key}' does not exist" can_enter, reason = gate_manager.can_enter(voter_id, gate_key) if not can_enter: return False, f"Gate vote denied: {reason}" try: from services.mesh.mesh_hashchain import gate_store if not gate_store.get_messages(gate_key, limit=1): return False, f"Gate '{gate_key}' has no activity" except Exception: pass return True, gate_key def _anonymous_mode_state() -> dict[str, Any]: try: from services.wormhole_settings import read_wormhole_settings from services.wormhole_status import read_wormhole_status settings = read_wormhole_settings() status = read_wormhole_status() enabled = bool(settings.get("enabled")) anonymous_mode = bool(settings.get("anonymous_mode")) transport_configured = str(settings.get("transport", "direct") or "direct").lower() transport_active = str(status.get("transport_active", "") or "").lower() effective_transport = transport_active or transport_configured ready = bool(status.get("running")) and bool(status.get("ready")) hidden_transport_ready = enabled and ready and effective_transport in { "tor", "tor_arti", "i2p", "mixnet", } return { "enabled": anonymous_mode, "wormhole_enabled": enabled, "ready": hidden_transport_ready, "effective_transport": effective_transport or "direct", } except Exception: return { "enabled": False, "wormhole_enabled": False, "ready": False, "effective_transport": "direct", } def _is_sensitive_no_store_path(path: str) -> bool: if not path.startswith("/api/"): return False if path.startswith("/api/wormhole/"): return True if path.startswith("/api/settings/"): return True if path.startswith("/api/mesh/dm/"): return True if path in { "/api/refresh", "/api/debug-latest", "/api/system/update", "/api/mesh/infonet/ingest", }: return True return False def _private_infonet_required_tier(path: str, method: str) -> str: method_name = method.upper() if path in { "/api/mesh/dm/register", "/api/mesh/dm/send", "/api/mesh/dm/poll", "/api/mesh/dm/count", "/api/mesh/dm/block", "/api/mesh/dm/witness", } and method_name in {"GET", "POST"}: return "strong" if not _is_private_infonet_write_path(path, method): return "" if method_name != "POST": return "" # Current release policy: non-DM private gate actions are allowed in # PRIVATE / TRANSITIONAL once Wormhole is ready. Strong-mode-only actions # should be added here explicitly instead of being implied elsewhere. return "transitional" _TRANSPORT_TIER_ORDER = { "public_degraded": 0, "private_transitional": 1, "private_strong": 2, } def _current_private_lane_tier(wormhole: dict | None) -> str: from services.wormhole_supervisor import transport_tier_from_state return transport_tier_from_state(wormhole) def _transport_tier_is_sufficient(current_tier: str, required_tier: str) -> bool: return _TRANSPORT_TIER_ORDER.get(current_tier, 0) >= _TRANSPORT_TIER_ORDER.get(required_tier, 0) _GATE_REDACT_FIELDS = ("sender_ref", "epoch", "nonce") _KEY_ROTATE_REDACT_FIELDS = { "old_node_id", "old_public_key", "old_public_key_algo", "old_signature", } def _redact_gate_metadata(event: dict) -> dict: """Strip MLS-internal fields from gate_message events in public sync responses.""" if not isinstance(event, dict): return event event_type = str(event.get("event_type", "") or "") if event_type != "gate_message": return event redacted = dict(event) for field in ("node_id", "sequence"): redacted.pop(field, None) if isinstance(redacted.get("payload"), dict): payload = dict(redacted.get("payload") or {}) for field in _GATE_REDACT_FIELDS: payload.pop(field, None) redacted["payload"] = payload return redacted for field in _GATE_REDACT_FIELDS: redacted.pop(field, None) return redacted def _redact_key_rotate_payload(event: dict) -> dict: """Strip identity-linking fields from key_rotate events in public responses.""" if not isinstance(event, dict): return event if str(event.get("event_type", "") or "") != "key_rotate": return event redacted = dict(event) payload = redacted.get("payload") if isinstance(payload, dict): payload = dict(payload) for field in _KEY_ROTATE_REDACT_FIELDS: payload.pop(field, None) redacted["payload"] = payload return redacted def _redact_vote_gate(event: dict) -> dict: """Strip gate label from vote events in public responses.""" if not isinstance(event, dict): return event if str(event.get("event_type", "") or "") != "vote": return event redacted = dict(event) payload = redacted.get("payload") if isinstance(payload, dict): payload = dict(payload) payload.pop("gate", None) redacted["payload"] = payload return redacted def _redact_public_event(event: dict) -> dict: """Apply all public-response redactions for public chain endpoints.""" return _redact_vote_gate(_redact_key_rotate_payload(_redact_gate_metadata(event))) def _is_debug_test_request(request: Request) -> bool: if not _debug_mode_enabled(): return False client_host = (request.client.host or "").lower() if request.client else "" url_host = (request.url.hostname or "").lower() if request.url else "" return client_host == "test" or url_host == "test" def _strip_gate_identity(event: dict) -> dict: """Return the private-plane gate event shape exposed to API consumers.""" if not isinstance(event, dict): event = {} payload = event.get("payload") if not isinstance(payload, dict): payload = {} node_id = str(event.get("node_id", "") or "") public_key = str(event.get("public_key", "") or "") public_key_algo = str(event.get("public_key_algo", "") or "") # If the event doesn't carry a public_key but has a node_id, resolve it # from the local persona/session store so the frontend can display it. if node_id and not public_key: gate_id = str(payload.get("gate", "") or "") if gate_id: try: binding = _lookup_gate_member_binding(gate_id, node_id) if binding: public_key, public_key_algo = binding except Exception: pass return { "event_id": str(event.get("event_id", "") or ""), "event_type": "gate_message", "timestamp": _redacted_gate_timestamp(event), "node_id": node_id, "sequence": int(event.get("sequence", 0) or 0), "signature": str(event.get("signature", "") or ""), "public_key": public_key, "public_key_algo": public_key_algo, "protocol_version": str(event.get("protocol_version", "") or ""), "payload": { "gate": str(payload.get("gate", "") or ""), "ciphertext": str(payload.get("ciphertext", "") or ""), "format": str(payload.get("format", "") or ""), "nonce": str(payload.get("nonce", "") or ""), "sender_ref": str(payload.get("sender_ref", "") or ""), "gate_envelope": str(payload.get("gate_envelope", "") or ""), "reply_to": str(payload.get("reply_to", "") or ""), }, } def _lookup_gate_member_binding(gate_id: str, node_id: str) -> tuple[str, str] | None: gate_key = str(gate_id or "").strip().lower() candidate = str(node_id or "").strip() if not gate_key or not candidate: return None try: from services.mesh.mesh_wormhole_persona import ( bootstrap_wormhole_persona_state, read_wormhole_persona_state, ) bootstrap_wormhole_persona_state() state = read_wormhole_persona_state() except Exception: return None for persona in list(state.get("gate_personas", {}).get(gate_key) or []): if str(persona.get("node_id", "") or "").strip() != candidate: continue public_key = str(persona.get("public_key", "") or "").strip() public_key_algo = str(persona.get("public_key_algo", "Ed25519") or "Ed25519").strip() if public_key and public_key_algo: return public_key, public_key_algo session = dict(state.get("gate_sessions", {}).get(gate_key) or {}) if str(session.get("node_id", "") or "").strip() == candidate: public_key = str(session.get("public_key", "") or "").strip() public_key_algo = str(session.get("public_key_algo", "Ed25519") or "Ed25519").strip() if public_key and public_key_algo: return public_key, public_key_algo return None def _resolve_gate_proof_identity(gate_id: str) -> dict[str, Any] | None: from services.mesh.mesh_wormhole_persona import ( bootstrap_wormhole_persona_state, read_wormhole_persona_state, ) gate_key = str(gate_id or "").strip().lower() if not gate_key: return None bootstrap_wormhole_persona_state() state = read_wormhole_persona_state() active_persona_id = str(state.get("active_gate_personas", {}).get(gate_key, "") or "") for persona in list(state.get("gate_personas", {}).get(gate_key) or []): if str(persona.get("persona_id", "") or "") == active_persona_id: return dict(persona or {}) for persona in list(state.get("gate_personas", {}).get(gate_key) or []): if persona.get("private_key"): return dict(persona or {}) session_identity = dict(state.get("gate_sessions", {}).get(gate_key) or {}) if session_identity.get("private_key"): return session_identity return None def _sign_gate_access_proof(gate_id: str) -> dict[str, Any]: gate_key = str(gate_id or "").strip().lower() if not gate_key: return {"ok": False, "detail": "gate_id required"} identity = _resolve_gate_proof_identity(gate_key) if not identity: return {"ok": False, "detail": "gate_access_proof_unavailable"} private_key_b64 = str(identity.get("private_key", "") or "").strip() node_id = str(identity.get("node_id", "") or "").strip() public_key = str(identity.get("public_key", "") or "").strip() public_key_algo = str(identity.get("public_key_algo", "Ed25519") or "Ed25519").strip() if not (private_key_b64 and node_id and public_key and public_key_algo): return {"ok": False, "detail": "gate_access_proof_unavailable"} try: from cryptography.hazmat.primitives.asymmetric import ec, ed25519 ts = int(time.time()) challenge = f"{gate_key}:{ts}" key_bytes = base64.b64decode(private_key_b64) algo = parse_public_key_algo(public_key_algo) if algo == "Ed25519": signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(key_bytes) signature = signing_key.sign(challenge.encode("utf-8")) elif algo == "ECDSA_P256": from cryptography.hazmat.primitives import hashes signing_key = ec.derive_private_key(int.from_bytes(key_bytes, "big"), ec.SECP256R1()) signature = signing_key.sign(challenge.encode("utf-8"), ec.ECDSA(hashes.SHA256())) else: return {"ok": False, "detail": "gate_access_proof_unsupported_algo"} except Exception as exc: logger.warning("Gate access proof signing failed: %s", type(exc).__name__) return {"ok": False, "detail": "gate_access_proof_failed"} return { "ok": True, "gate_id": gate_key, "node_id": node_id, "ts": ts, "proof": base64.b64encode(signature).decode("ascii"), } def _verify_gate_access(request: Request, gate_id: str) -> bool: """Verify the requester has access to a gate's private message feed.""" ok, _detail = _check_scoped_auth(request, "gate") if ok: return True gate_key = str(gate_id or "").strip().lower() node_id = str(request.headers.get("x-wormhole-node-id", "") or "").strip() proof_b64 = str(request.headers.get("x-wormhole-gate-proof", "") or "").strip() ts_str = str(request.headers.get("x-wormhole-gate-ts", "") or "").strip() if not gate_key or not node_id or not proof_b64 or not ts_str: return False try: ts = int(ts_str) except (TypeError, ValueError): return False if abs(int(time.time()) - ts) > 60: return False binding = _lookup_gate_member_binding(gate_key, node_id) if not binding: return False public_key, public_key_algo = binding if not verify_node_binding(node_id, public_key): return False try: signature_hex = base64.b64decode(proof_b64, validate=True).hex() except Exception: return False challenge = f"{gate_key}:{ts_str}" return verify_signature( public_key_b64=public_key, public_key_algo=public_key_algo, signature_hex=signature_hex, payload=challenge, ) def _peer_hmac_url_from_request(request: Request) -> str: header_url = normalize_peer_url(str(request.headers.get("x-peer-url", "") or "")) if header_url: return header_url if not request.url: return "" base_url = f"{request.url.scheme}://{request.url.netloc}".rstrip("/") return normalize_peer_url(base_url) def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool: """Verify HMAC-SHA256 peer authentication on push requests.""" secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() if not secret: return False provided = str(request.headers.get("x-peer-hmac", "") or "").strip() if not provided: return False peer_url = _peer_hmac_url_from_request(request) allowed_peers = set(authenticated_push_peer_urls()) if not peer_url or peer_url not in allowed_peers: return False peer_key = _derive_peer_key(secret, peer_url) if not peer_key: return False expected = _hmac_mod.new( peer_key, body_bytes, _hashlib_mod.sha256, ).hexdigest() return _hmac_mod.compare_digest(provided.lower(), expected.lower()) def _minimum_transport_tier(path: str, method: str) -> str: method_name = method.upper() private_infonet = _private_infonet_required_tier(path, method) if private_infonet == "transitional": return "private_transitional" if private_infonet == "strong": return "private_strong" if method_name == "GET" and path in { "/api/mesh/dm/prekey-bundle", }: return "private_transitional" if method_name == "POST" and path in { "/api/wormhole/dm/compose", "/api/mesh/report", "/api/mesh/trust/vouch", "/api/mesh/oracle/predict", "/api/mesh/oracle/resolve", "/api/mesh/oracle/stake", "/api/mesh/oracle/resolve-stakes", "/api/wormhole/gate/enter", "/api/wormhole/gate/leave", "/api/wormhole/gate/persona/create", "/api/wormhole/gate/persona/activate", "/api/wormhole/gate/persona/clear", "/api/wormhole/gate/persona/retire", "/api/wormhole/gate/key/grant", "/api/wormhole/gate/key/rotate", "/api/wormhole/gate/message/compose", "/api/wormhole/gate/message/decrypt", "/api/wormhole/gate/messages/decrypt", "/api/wormhole/dm/decrypt", }: return "private_transitional" if method_name == "POST" and path in { "/api/wormhole/dm/register-key", "/api/wormhole/dm/prekey/register", "/api/wormhole/dm/bootstrap-encrypt", "/api/wormhole/dm/bootstrap-decrypt", "/api/wormhole/dm/sender-token", "/api/wormhole/dm/open-seal", "/api/wormhole/dm/build-seal", "/api/wormhole/dm/dead-drop-token", "/api/wormhole/dm/pairwise-alias", "/api/wormhole/dm/pairwise-alias/rotate", "/api/wormhole/dm/dead-drop-tokens", "/api/wormhole/dm/sas", "/api/wormhole/dm/encrypt", "/api/wormhole/dm/decrypt", "/api/wormhole/dm/reset", }: return "private_strong" return "" def _transport_tier_precondition(required_tier: str, current_tier: str) -> JSONResponse: return JSONResponse( status_code=428, content={ "ok": False, "detail": "transport tier insufficient", "required": required_tier, "current": current_tier, }, ) def _private_infonet_policy_snapshot() -> dict[str, Any]: return { "gate_actions": { "post_message": "private_transitional", "vote": "private_transitional", "create_gate": "private_transitional", }, "gate_chat": { "trust_tier": "private_transitional", "wormhole_required": True, "content_private": True, "storage_model": "private_gate_store_encrypted_envelope", "notes": [ "Gate messages stay off the public hashchain and live on the private gate plane.", "Anonymous gate sessions use rotating gate-scoped public keys and can participate on the private gate lane.", "Use the DM/Dead Drop lane for the strongest transport posture currently available.", ], }, "dm_lane": { "trust_tier_when_wormhole_ready": "private_transitional", "trust_tier_when_rns_ready": "private_strong", "reticulum_preferred": True, "relay_fallback": True, "public_transports_excluded": True, "notes": [ "Private DMs stay off the public hashchain.", "Public perimeter transports are excluded from secure DM carriage.", ], }, "reserved_for_private_strong": [], "notes": [ "Non-DM gate chat and gate lifecycle actions are currently allowed in PRIVATE / TRANSITIONAL once Wormhole is ready.", "DM policy remains stricter and is intentionally managed separately from gate-chat policy.", ], } @app.middleware("http") async def enforce_high_privacy_mesh(request: Request, call_next): path = request.url.path if path.startswith("/api/mesh") or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"): current_tier = "public_degraded" required_tier = _minimum_transport_tier(path, request.method) if required_tier: try: from services.wormhole_supervisor import get_wormhole_state wormhole = get_wormhole_state() except Exception: wormhole = {"configured": False, "ready": False, "rns_ready": False} current_tier = _current_private_lane_tier(wormhole) if not _transport_tier_is_sufficient(current_tier, required_tier): return _transport_tier_precondition(required_tier, current_tier) try: from services.wormhole_settings import read_wormhole_settings data = read_wormhole_settings() if ( path.startswith("/api/mesh") and str(data.get("privacy_profile", "default")).lower() == "high" and not bool(data.get("enabled")) ): return JSONResponse( status_code=428, content={ "ok": False, "detail": "High privacy requires Wormhole to be enabled.", }, ) except Exception: pass state = _anonymous_mode_state() if state["enabled"] and ( _is_anonymous_mesh_write_path(path, request.method) or _is_anonymous_dm_action_path(path, request.method) or _is_anonymous_wormhole_gate_admin_path(path, request.method) ): if not state["wormhole_enabled"]: return JSONResponse( status_code=428, content={ "ok": False, "detail": "Anonymous mode requires Wormhole to be enabled.", }, ) if not state["ready"]: return JSONResponse( status_code=428, content={ "ok": False, "detail": ( "Anonymous mode requires a hidden Wormhole transport " "(Tor/I2P/Mixnet) to be ready before public posting, " "gate persona changes, or private DM activity." ), }, ) return await call_next(request) @app.middleware("http") async def apply_no_store_to_sensitive_paths(request: Request, call_next): response = await call_next(request) if _is_sensitive_no_store_path(request.url.path): for key, value in _NO_STORE_HEADERS.items(): response.headers[key] = value return response from services.data_fetcher import update_all_data _refresh_lock = threading.Lock() @app.get("/api/refresh", response_model=RefreshResponse, dependencies=[Depends(require_admin)]) @limiter.limit("2/minute") async def force_refresh(request: Request): if not _refresh_lock.acquire(blocking=False): return {"status": "refresh already in progress"} def _do_refresh(): try: update_all_data() finally: _refresh_lock.release() t = threading.Thread(target=_do_refresh) t.start() return {"status": "refreshing in background"} @app.post("/api/ais/feed") @limiter.limit("60/minute") async def ais_feed(request: Request): """Accept AIS-catcher HTTP JSON feed (POST decoded AIS messages).""" from services.ais_stream import ingest_ais_catcher try: body = await request.json() except Exception: return JSONResponse(status_code=422, content={"ok": False, "detail": "invalid JSON body"}) msgs = body.get("msgs", []) if not msgs: return {"status": "ok", "ingested": 0} count = ingest_ais_catcher(msgs) return {"status": "ok", "ingested": count} from pydantic import BaseModel class ViewportUpdate(BaseModel): s: float w: float n: float e: float _LAST_VIEWPORT_UPDATE: tuple[float, float, float, float] | None = None _LAST_VIEWPORT_UPDATE_TS = 0.0 _VIEWPORT_UPDATE_LOCK = threading.Lock() _VIEWPORT_DEDUPE_EPSILON = 1.0 _VIEWPORT_MIN_UPDATE_S = 10.0 def _normalize_longitude(value: float) -> float: normalized = ((value + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 if normalized == -180.0 and value > 0: return 180.0 return normalized def _normalize_viewport_bounds(s: float, w: float, n: float, e: float) -> tuple[float, float, float, float]: south = max(-90.0, min(90.0, s)) north = max(-90.0, min(90.0, n)) raw_width = abs(e - w) if not math.isfinite(raw_width) or raw_width >= 360.0: return south, -180.0, north, 180.0 west = _normalize_longitude(w) east = _normalize_longitude(e) if east < west: return south, -180.0, north, 180.0 return south, west, north, east def _viewport_changed_enough(bounds: tuple[float, float, float, float]) -> bool: global _LAST_VIEWPORT_UPDATE, _LAST_VIEWPORT_UPDATE_TS now = time.monotonic() with _VIEWPORT_UPDATE_LOCK: if _LAST_VIEWPORT_UPDATE is None: _LAST_VIEWPORT_UPDATE = bounds _LAST_VIEWPORT_UPDATE_TS = now return True changed = any( abs(current - previous) > _VIEWPORT_DEDUPE_EPSILON for current, previous in zip(bounds, _LAST_VIEWPORT_UPDATE) ) if not changed and (now - _LAST_VIEWPORT_UPDATE_TS) < _VIEWPORT_MIN_UPDATE_S: return False if (now - _LAST_VIEWPORT_UPDATE_TS) < _VIEWPORT_MIN_UPDATE_S: return False _LAST_VIEWPORT_UPDATE = bounds _LAST_VIEWPORT_UPDATE_TS = now return True def _queue_viirs_change_refresh() -> None: from services.fetchers.earth_observation import fetch_viirs_change_nodes threading.Thread(target=fetch_viirs_change_nodes, daemon=True).start() @app.post("/api/viewport") @limiter.limit("60/minute") async def update_viewport(vp: ViewportUpdate, request: Request): """Receive frontend map bounds to dynamically choke the AIS stream.""" from services.ais_stream import update_ais_bbox south, west, north, east = _normalize_viewport_bounds(vp.s, vp.w, vp.n, vp.e) normalized_bounds = (south, west, north, east) if not _viewport_changed_enough(normalized_bounds): return {"status": "ok", "deduped": True} # Add a gentle 10% padding so ships don't pop-in right at the edge pad_lat = (north - south) * 0.1 # handle antimeridian bounding box padding later if needed, simple for now: pad_lng = (east - west) * 0.1 if east > west else 0 update_ais_bbox( south=max(-90, south - pad_lat), west=max(-180, west - pad_lng) if pad_lng else west, north=min(90, north + pad_lat), east=min(180, east + pad_lng) if pad_lng else east, ) return {"status": "ok"} class LayerUpdate(BaseModel): layers: dict[str, bool] @app.post("/api/layers") @limiter.limit("30/minute") async def update_layers(update: LayerUpdate, request: Request): """Receive frontend layer toggle state. Starts/stops streams accordingly.""" from services.fetchers._store import active_layers, bump_active_layers_version, is_any_active # Snapshot old stream states before applying changes old_ships = is_any_active( "ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts" ) old_mesh = is_any_active("sigint_meshtastic") old_aprs = is_any_active("sigint_aprs") old_viirs = is_any_active("viirs_nightlights") # Update only known keys changed = False for key, value in update.layers.items(): if key in active_layers: if active_layers[key] != value: changed = True active_layers[key] = value if changed: bump_active_layers_version() new_ships = is_any_active( "ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts" ) new_mesh = is_any_active("sigint_meshtastic") new_aprs = is_any_active("sigint_aprs") new_viirs = is_any_active("viirs_nightlights") # Start/stop AIS stream on transition if old_ships and not new_ships: from services.ais_stream import stop_ais_stream stop_ais_stream() logger.info("AIS stream stopped (all ship layers disabled)") elif not old_ships and new_ships: from services.ais_stream import start_ais_stream start_ais_stream() logger.info("AIS stream started (ship layer enabled)") # Start/stop SIGINT bridges on transition from services.sigint_bridge import sigint_grid if old_mesh and not new_mesh: sigint_grid.mesh.stop() logger.info("Meshtastic MQTT bridge stopped (layer disabled)") elif not old_mesh and new_mesh: sigint_grid.mesh.start() logger.info("Meshtastic MQTT bridge started (layer enabled)") if old_aprs and not new_aprs: sigint_grid.aprs.stop() logger.info("APRS bridge stopped (layer disabled)") elif not old_aprs and new_aprs: sigint_grid.aprs.start() logger.info("APRS bridge started (layer enabled)") if not old_viirs and new_viirs: _queue_viirs_change_refresh() logger.info("VIIRS change refresh queued (layer enabled)") return {"status": "ok"} @app.get("/api/live-data") @limiter.limit("120/minute") async def live_data(request: Request): return get_latest_data() def _etag_response(request: Request, payload: dict, prefix: str = "", default=None): """Serialize once, use data version for ETag, return 304 or full response. Uses a monotonic version counter instead of MD5-hashing the full payload. The 304 fast path avoids serialization entirely. """ etag = _current_etag(prefix) if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) content = json_mod.dumps(_json_safe(payload), default=default, allow_nan=False) return Response( content=content, media_type="application/json", headers={"ETag": etag, "Cache-Control": "no-cache"}, ) def _current_etag(prefix: str = "") -> str: from services.fetchers._store import get_active_layers_version, get_data_version return f"{prefix}v{get_data_version()}-l{get_active_layers_version()}" def _json_safe(value): """Recursively replace non-finite floats with None so responses stay valid JSON.""" if isinstance(value, float): return value if math.isfinite(value) else None if isinstance(value, dict): # Snapshot mutable mappings first so background fetcher updates do not # invalidate iteration while we serialize a response. return {k: _json_safe(v) for k, v in list(value.items())} if isinstance(value, list): return [_json_safe(v) for v in list(value)] if isinstance(value, tuple): return [_json_safe(v) for v in list(value)] return value def _sanitize_payload(value): """Thread-safe snapshot with NaN→None. Cheaper than _json_safe: only deep- copies dicts (for thread safety) and replaces non-finite floats. Lists are shallow-copied — orjson handles the leaf serialisation natively.""" if isinstance(value, float): return value if math.isfinite(value) else None if isinstance(value, dict): return {k: _sanitize_payload(v) for k, v in list(value.items())} if isinstance(value, (list, tuple)): return list(value) return value def _bbox_filter( items: list, s: float, w: float, n: float, e: float, lat_key: str = "lat", lng_key: str = "lng" ) -> list: """Filter a list of dicts to those within the bounding box (with 20% padding). Handles antimeridian crossing (e.g. w=170, e=-170).""" pad_lat = (n - s) * 0.2 pad_lng = (e - w) * 0.2 if e > w else ((e + 360 - w) * 0.2) s2, n2 = s - pad_lat, n + pad_lat w2, e2 = w - pad_lng, e + pad_lng crosses_antimeridian = w2 > e2 out = [] for item in items: lat = item.get(lat_key) lng = item.get(lng_key) if lat is None or lng is None: out.append(item) # Keep items without coords (don't filter them out) continue if not (s2 <= lat <= n2): continue if crosses_antimeridian: if lng >= w2 or lng <= e2: out.append(item) else: if w2 <= lng <= e2: out.append(item) return out def _bbox_filter_geojson_points(items: list, s: float, w: float, n: float, e: float) -> list: """Filter GeoJSON Point features to a padded bounding box.""" pad_lat = (n - s) * 0.2 pad_lng = (e - w) * 0.2 if e > w else ((e + 360 - w) * 0.2) s2, n2 = s - pad_lat, n + pad_lat w2, e2 = w - pad_lng, e + pad_lng crosses_antimeridian = w2 > e2 out = [] for item in items: geometry = item.get("geometry") if isinstance(item, dict) else None coords = geometry.get("coordinates") if isinstance(geometry, dict) else None if not isinstance(coords, (list, tuple)) or len(coords) < 2: out.append(item) continue lng, lat = coords[0], coords[1] if lat is None or lng is None: out.append(item) continue if not (s2 <= lat <= n2): continue if crosses_antimeridian: if lng >= w2 or lng <= e2: out.append(item) else: if w2 <= lng <= e2: out.append(item) return out def _bbox_spans(s: float | None, w: float | None, n: float | None, e: float | None) -> tuple[float, float]: if None in (s, w, n, e): return 180.0, 360.0 lat_span = max(0.0, float(n) - float(s)) lng_span = float(e) - float(w) if lng_span < 0: lng_span += 360.0 if lng_span == 0 and w == -180 and e == 180: lng_span = 360.0 return lat_span, max(0.0, lng_span) def _downsample_points(items: list, max_items: int) -> list: if max_items <= 0 or len(items) <= max_items: return items step = len(items) / float(max_items) return [items[min(len(items) - 1, int(i * step))] for i in range(max_items)] def _world_and_continental_scale( has_bbox: bool, s: float | None, w: float | None, n: float | None, e: float | None ) -> tuple[bool, bool]: lat_span, lng_span = _bbox_spans(s, w, n, e) world_scale = (not has_bbox) or lng_span >= 300 or lat_span >= 120 continental_scale = has_bbox and not world_scale and (lng_span >= 120 or lat_span >= 55) return world_scale, continental_scale def _filter_sigint_by_layers(items: list, active_layers: dict[str, bool]) -> list: allow_aprs = bool(active_layers.get("sigint_aprs", True)) allow_mesh = bool(active_layers.get("sigint_meshtastic", True)) if allow_aprs and allow_mesh: return items allowed_sources: set[str] = {"js8call"} if allow_aprs: allowed_sources.add("aprs") if allow_mesh: allowed_sources.update({"meshtastic", "meshtastic-map"}) return [item for item in items if str(item.get("source") or "").lower() in allowed_sources] def _sigint_totals_for_items(items: list) -> dict[str, int]: totals = { "total": len(items), "meshtastic": 0, "meshtastic_live": 0, "meshtastic_map": 0, "aprs": 0, "js8call": 0, } for item in items: source = str(item.get("source") or "").lower() if source == "meshtastic": totals["meshtastic"] += 1 if bool(item.get("from_api")): totals["meshtastic_map"] += 1 else: totals["meshtastic_live"] += 1 elif source == "aprs": totals["aprs"] += 1 elif source == "js8call": totals["js8call"] += 1 return totals @app.get("/api/live-data/fast") @limiter.limit("120/minute") async def live_data_fast( request: Request, # bbox params accepted for backward compat but no longer used for filtering — # all cached data is returned and the frontend culls off-screen entities via MapLibre. s: float = Query(None, description="South bound (ignored)", ge=-90, le=90), w: float = Query(None, description="West bound (ignored)", ge=-180, le=180), n: float = Query(None, description="North bound (ignored)", ge=-90, le=90), e: float = Query(None, description="East bound (ignored)", ge=-180, le=180), ): etag = _current_etag(prefix="fast|full|") if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) from services.fetchers._store import ( active_layers, get_latest_data_subset, get_source_timestamps_snapshot, ) d = get_latest_data_subset( "last_updated", "commercial_flights", "military_flights", "private_flights", "private_jets", "tracked_flights", "ships", "cctv", "uavs", "liveuamap", "gps_jamming", "satellites", "satellite_source", "sigint", "sigint_totals", "trains", ) freshness = get_source_timestamps_snapshot() ships_enabled = any( active_layers.get(key, True) for key in ( "ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts", ) ) cctv_total = len(d.get("cctv") or []) sigint_items = _filter_sigint_by_layers(d.get("sigint") or [], active_layers) sigint_totals = _sigint_totals_for_items(sigint_items) payload = { "commercial_flights": (d.get("commercial_flights") or []) if active_layers.get("flights", True) else [], "military_flights": (d.get("military_flights") or []) if active_layers.get("military", True) else [], "private_flights": (d.get("private_flights") or []) if active_layers.get("private", True) else [], "private_jets": (d.get("private_jets") or []) if active_layers.get("jets", True) else [], "tracked_flights": (d.get("tracked_flights") or []) if active_layers.get("tracked", True) else [], "ships": (d.get("ships") or []) if ships_enabled else [], "cctv": (d.get("cctv") or []) if active_layers.get("cctv", True) else [], "uavs": (d.get("uavs") or []) if active_layers.get("military", True) else [], "liveuamap": (d.get("liveuamap") or []) if active_layers.get("global_incidents", True) else [], "gps_jamming": (d.get("gps_jamming") or []) if active_layers.get("gps_jamming", True) else [], "satellites": (d.get("satellites") or []) if active_layers.get("satellites", True) else [], "satellite_source": d.get("satellite_source", "none"), "sigint": sigint_items if (active_layers.get("sigint_meshtastic", True) or active_layers.get("sigint_aprs", True)) else [], "sigint_totals": sigint_totals, "cctv_total": cctv_total, "trains": (d.get("trains") or []) if active_layers.get("trains", True) else [], "freshness": freshness, } return Response( content=orjson.dumps(_sanitize_payload(payload)), media_type="application/json", headers={"ETag": etag, "Cache-Control": "no-cache"}, ) @app.get("/api/live-data/slow") @limiter.limit("60/minute") async def live_data_slow( request: Request, # bbox params accepted for backward compat but no longer used for filtering. s: float = Query(None, description="South bound (ignored)", ge=-90, le=90), w: float = Query(None, description="West bound (ignored)", ge=-180, le=180), n: float = Query(None, description="North bound (ignored)", ge=-90, le=90), e: float = Query(None, description="East bound (ignored)", ge=-180, le=180), ): etag = _current_etag(prefix="slow|full|") if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) from services.fetchers._store import ( active_layers, get_latest_data_subset, get_source_timestamps_snapshot, ) d = get_latest_data_subset( "last_updated", "news", "stocks", "financial_source", "oil", "weather", "traffic", "earthquakes", "frontlines", "gdelt", "airports", "kiwisdr", "satnogs_stations", "satnogs_observations", "tinygs_satellites", "space_weather", "internet_outages", "firms_fires", "datacenters", "military_bases", "power_plants", "viirs_change_nodes", "scanners", "weather_alerts", "ukraine_alerts", "air_quality", "volcanoes", "fishing_activity", "psk_reporter", "correlations", "threat_level", "trending_markets", ) freshness = get_source_timestamps_snapshot() payload = { "last_updated": d.get("last_updated"), "threat_level": d.get("threat_level"), "trending_markets": d.get("trending_markets", []), "news": d.get("news", []), "stocks": d.get("stocks", {}), "financial_source": d.get("financial_source", ""), "oil": d.get("oil", {}), "weather": d.get("weather"), "traffic": d.get("traffic", []), "earthquakes": (d.get("earthquakes") or []) if active_layers.get("earthquakes", True) else [], "frontlines": d.get("frontlines") if active_layers.get("ukraine_frontline", True) else None, "gdelt": (d.get("gdelt") or []) if active_layers.get("global_incidents", True) else [], "airports": d.get("airports") or [], "kiwisdr": (d.get("kiwisdr") or []) if active_layers.get("kiwisdr", True) else [], "satnogs_stations": (d.get("satnogs_stations") or []) if active_layers.get("satnogs", True) else [], "satnogs_total": len(d.get("satnogs_stations") or []), "satnogs_observations": (d.get("satnogs_observations") or []) if active_layers.get("satnogs", True) else [], "tinygs_satellites": (d.get("tinygs_satellites") or []) if active_layers.get("tinygs", True) else [], "tinygs_total": len(d.get("tinygs_satellites") or []), "psk_reporter": (d.get("psk_reporter") or []) if active_layers.get("psk_reporter", True) else [], "space_weather": d.get("space_weather"), "internet_outages": (d.get("internet_outages") or []) if active_layers.get("internet_outages", True) else [], "firms_fires": (d.get("firms_fires") or []) if active_layers.get("firms", True) else [], "datacenters": (d.get("datacenters") or []) if active_layers.get("datacenters", True) else [], "military_bases": (d.get("military_bases") or []) if active_layers.get("military_bases", True) else [], "power_plants": (d.get("power_plants") or []) if active_layers.get("power_plants", True) else [], "viirs_change_nodes": (d.get("viirs_change_nodes") or []) if active_layers.get("viirs_nightlights", True) else [], "scanners": (d.get("scanners") or []) if active_layers.get("scanners", True) else [], "weather_alerts": d.get("weather_alerts", []) if active_layers.get("weather_alerts", True) else [], "ukraine_alerts": d.get("ukraine_alerts", []) if active_layers.get("ukraine_alerts", True) else [], "air_quality": (d.get("air_quality") or []) if active_layers.get("air_quality", True) else [], "volcanoes": (d.get("volcanoes") or []) if active_layers.get("volcanoes", True) else [], "fishing_activity": (d.get("fishing_activity") or []) if active_layers.get("fishing_activity", True) else [], "correlations": (d.get("correlations") or []) if active_layers.get("correlations", True) else [], "freshness": freshness, } return Response( content=orjson.dumps( _sanitize_payload(payload), default=str, option=orjson.OPT_NON_STR_KEYS, ), media_type="application/json", headers={"ETag": etag, "Cache-Control": "no-cache"}, ) @app.get("/api/oracle/region-intel") @limiter.limit("30/minute") async def oracle_region_intel( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), ): """Get oracle intelligence summary for a geographic region.""" from services.oracle_service import get_region_oracle_intel news_items = get_latest_data().get("news", []) return get_region_oracle_intel(lat, lng, news_items) @app.get("/api/thermal/verify") @limiter.limit("10/minute") async def thermal_verify( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), radius_km: float = Query(10, ge=1, le=100), ): """On-demand thermal anomaly verification using Sentinel-2 SWIR bands.""" from services.thermal_sentinel import search_thermal_anomaly result = search_thermal_anomaly(lat, lng, radius_km) return result @app.post("/api/sigint/transmit") @limiter.limit("5/minute") async def sigint_transmit(request: Request): """Send an APRS-IS message to a specific callsign. Requires ham radio credentials.""" from services.wormhole_supervisor import get_transport_tier tier = get_transport_tier() if str(tier or "").startswith("private_"): return {"ok": False, "detail": "APRS transmit blocked in private transport mode"} body = await request.json() callsign = body.get("callsign", "") passcode = body.get("passcode", "") target = body.get("target", "") message = body.get("message", "") if not all([callsign, passcode, target, message]): return { "ok": False, "detail": "Missing required fields: callsign, passcode, target, message", } from services.sigint_bridge import send_aprs_message return send_aprs_message(callsign, passcode, target, message) @app.get("/api/sigint/nearest-sdr") @limiter.limit("30/minute") async def nearest_sdr( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), ): """Find the nearest KiwiSDR receivers to a given coordinate.""" from services.sigint_bridge import find_nearest_kiwisdr kiwisdr_data = get_latest_data().get("kiwisdr", []) return find_nearest_kiwisdr(lat, lng, kiwisdr_data) # ─── Per-Identity Throttle State ────────────────────────────────────────── # In-memory: {node_id: {"last_send": timestamp, "daily_count": int, "daily_reset": timestamp}} # Bounded to 10000 entries with 24hr TTL to prevent unbounded memory growth _node_throttle: TTLCache = TTLCache(maxsize=10000, ttl=86400) _gate_post_cooldown: TTLCache = TTLCache(maxsize=20000, ttl=86400) # Byte limits per payload type _BYTE_LIMITS = {"text": 200, "pin": 300, "emergency": 200, "command": 200} def _check_throttle( node_id: str, priority_str: str, transport_lock: str = "" ) -> tuple[bool, str]: """Per-identity rate limiting based on node age and reputation. Tiers: New (rep < 3, age < 24h): 1 msg / 5 min, 10/day Established (rep >= 3 OR > 24h): 1 msg / 2 min, 50/day Trusted (rep >= 10): 1 msg / 30 sec, 200/day Emergency: no throttle Meshtastic public mesh is intentionally looser in testnet mode: Any public mesh sender: 2 msgs / min, tier caps unchanged """ if priority_str == "emergency": return True, "" now = time.time() state = _node_throttle.get(node_id) if not state: _node_throttle[node_id] = { "last_send": 0, "daily_count": 0, "daily_reset": now, "first_seen": now, } state = _node_throttle[node_id] # Reset daily counter at midnight if now - state["daily_reset"] > 86400: state["daily_count"] = 0 state["daily_reset"] = now # Determine tier (reputation integration will come with Feature 2) age_hours = (now - state.get("first_seen", now)) / 3600 rep_score = 0 try: from services.mesh.mesh_reputation import reputation_ledger rep_score = reputation_ledger.get_reputation(node_id).get("overall", 0) age_hours = max(age_hours, reputation_ledger.get_node_age_days(node_id) * 24) except Exception: rep_score = 0 if rep_score >= 20 or age_hours >= 168: interval, daily_cap, tier = 30, 200, "trusted" elif rep_score >= 5 or age_hours >= 48: interval, daily_cap, tier = 120, 75, "established" else: interval, daily_cap, tier = 300, 15, "new" if str(transport_lock or "").lower() == "meshtastic": interval = min(interval, 30) # Check daily cap if state["daily_count"] >= daily_cap: return ( False, f"Daily message limit reached ({daily_cap} messages for {tier} nodes). Resets in {int(86400 - (now - state['daily_reset']))}s.", ) # Check interval elapsed = now - state["last_send"] if elapsed < interval: remaining = int(interval - elapsed) return False, f"Rate limit: 1 message per {interval}s for {tier} nodes. Wait {remaining}s." # Allowed state["last_send"] = now state["daily_count"] += 1 return True, "" def _check_gate_post_cooldown(sender_id: str, gate_id: str) -> tuple[bool, str]: """Check cooldown — does NOT record it. Call _record_gate_post_cooldown() after success.""" gate_key = str(gate_id or "").strip().lower() sender_key = str(sender_id or "").strip() if not gate_key or not sender_key: return True, "" now = time.time() cooldown_key = f"{sender_key}:{gate_key}" last_post = float(_gate_post_cooldown.get(cooldown_key, 0) or 0) if last_post > 0: elapsed = now - last_post if elapsed < 30: remaining = max(1, math.ceil(30 - elapsed)) return False, f"Gate post cooldown: wait {remaining}s before posting again." return True, "" def _record_gate_post_cooldown(sender_id: str, gate_id: str) -> None: """Stamp the cooldown AFTER a successful gate post.""" gate_key = str(gate_id or "").strip().lower() sender_key = str(sender_id or "").strip() if gate_key and sender_key: _gate_post_cooldown[f"{sender_key}:{gate_key}"] = time.time() def _verify_signed_event( *, event_type: str, node_id: str, sequence: int, public_key: str, public_key_algo: str, signature: str, payload: dict, protocol_version: str, ) -> tuple[bool, str]: from services.mesh.mesh_metrics import increment as metrics_inc if not protocol_version: metrics_inc("signature_missing_protocol") return False, "Missing protocol_version" if protocol_version != PROTOCOL_VERSION: metrics_inc("signature_protocol_mismatch") return False, f"Unsupported protocol_version: {protocol_version}" if not signature or not public_key or not public_key_algo: metrics_inc("signature_missing_fields") return False, "Missing signature or public key" if sequence <= 0: metrics_inc("signature_invalid_sequence") return False, "Missing or invalid sequence" if not verify_node_binding(node_id, public_key): metrics_inc("signature_node_mismatch") return False, "node_id does not match public key" algo = parse_public_key_algo(public_key_algo) if not algo: metrics_inc("signature_bad_algo") return False, "Unsupported public_key_algo" normalized = normalize_payload(event_type, payload) sig_payload = build_signature_payload( event_type=event_type, node_id=node_id, sequence=sequence, payload=normalized, ) if not verify_signature( public_key_b64=public_key, public_key_algo=algo, signature_hex=signature, payload=sig_payload, ): if event_type == "dm_message": legacy_sig_payload = build_signature_payload( event_type=event_type, node_id=node_id, sequence=sequence, payload=normalize_dm_message_payload_legacy(payload), ) if verify_signature( public_key_b64=public_key, public_key_algo=algo, signature_hex=signature, payload=legacy_sig_payload, ): return True, "ok" metrics_inc("signature_invalid") return False, "Invalid signature" return True, "ok" def _preflight_signed_event_integrity( *, event_type: str, node_id: str, sequence: int, public_key: str, public_key_algo: str, signature: str, protocol_version: str, ) -> tuple[bool, str]: if not protocol_version or not signature or not public_key or not public_key_algo: return False, "Missing signature or public key" if sequence <= 0: return False, "Missing or invalid sequence" try: from services.mesh.mesh_hashchain import infonet except Exception as exc: logger.error("Signed event integrity preflight unavailable: %s", exc) return False, "Signed event integrity preflight unavailable" if infonet.check_replay(node_id, sequence): last = infonet.node_sequences.get(node_id, 0) return False, f"Replay detected: sequence {sequence} <= last {last}" existing = infonet.public_key_bindings.get(public_key) if existing and existing != node_id: return False, f"public key already bound to {existing}" revoked, _info = infonet._revocation_status(public_key) if revoked and event_type != "key_revoke": return False, "public key is revoked" return True, "ok" @app.post("/api/mesh/send") @limiter.limit("10/minute") async def mesh_send(request: Request): """Unified mesh message endpoint — auto-routes via optimal transport. Body: { destination, message, priority?, channel?, node_id?, credentials? } The router picks APRS, Meshtastic, or Internet based on gate logic. Enforces byte limits and per-identity rate limiting. """ body = await request.json() destination = body.get("destination", "") message = body.get("message", "") if not destination or not message: return {"ok": False, "detail": "Missing required fields: destination, message"} # ─── Byte limit enforcement ─────────────────────────────────── payload_bytes = len(message.encode("utf-8")) payload_type = body.get("payload_type", "text") max_bytes = _BYTE_LIMITS.get(payload_type, 200) if payload_bytes > max_bytes: return { "ok": False, "detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.", } # ─── Signature verification & node registration ────────────── node_id = body.get("node_id", body.get("sender_id", "anonymous")) public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") signed_payload = { "message": message, "destination": destination, "channel": body.get("channel", "LongFast"), "priority": body.get("priority", "normal").lower(), "ephemeral": bool(body.get("ephemeral", False)), } if body.get("transport_lock"): signed_payload["transport_lock"] = str(body.get("transport_lock")) sig_ok, sig_reason = _verify_signed_event( event_type="message", node_id=node_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=signed_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="message", node_id=node_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} # Register node in reputation ledger (auto-creates if new) if node_id != "anonymous": try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(node_id, public_key, public_key_algo) except Exception: pass # Non-critical — don't block sends if reputation module fails # ─── Per-identity throttle ──────────────────────────────────── priority_str = signed_payload["priority"] transport_lock = str(body.get("transport_lock", "") or "").lower() throttle_ok, throttle_reason = _check_throttle(node_id, priority_str, transport_lock) if not throttle_ok: return {"ok": False, "detail": throttle_reason} from services.mesh.mesh_router import ( MeshEnvelope, MeshtasticTransport, Priority, TransportResult, mesh_router, ) priority_map = { "emergency": Priority.EMERGENCY, "high": Priority.HIGH, "normal": Priority.NORMAL, "low": Priority.LOW, } priority = priority_map.get(priority_str, Priority.NORMAL) # ─── C-1 fix: compute trust_tier from Wormhole state ─────── from services.wormhole_supervisor import get_transport_tier computed_tier = get_transport_tier() envelope = MeshEnvelope( sender_id=node_id, destination=destination, channel=body.get("channel", "LongFast"), priority=priority, payload=message, ephemeral=body.get("ephemeral", False), trust_tier=computed_tier, ) credentials = body.get("credentials", {}) # ─── C-2 fix: enforce tier before transport_lock dispatch ── private_tier = str(envelope.trust_tier or "").startswith("private_") if transport_lock == "meshtastic": if private_tier: results = [TransportResult( False, "meshtastic", "Private-tier content cannot be sent over Meshtastic" )] elif not mesh_router.meshtastic.can_reach(envelope): results = [TransportResult(False, "meshtastic", "Message exceeds Meshtastic payload limit")] else: cb_ok, cb_reason = mesh_router.breakers["meshtastic"].check_and_record(envelope.priority) if not cb_ok: results = [TransportResult(False, "meshtastic", cb_reason)] else: envelope.route_reason = ( "Transport locked to Meshtastic public path" if MeshtasticTransport._parse_node_id(destination) is None else "Transport locked to Meshtastic public node-targeted path" ) result = mesh_router.meshtastic.send(envelope, credentials) if result.ok: envelope.routed_via = mesh_router.meshtastic.NAME results = [result] elif transport_lock == "aprs": if private_tier: results = [TransportResult( False, "aprs", "Private-tier content cannot be sent over APRS" )] else: results = mesh_router.route(envelope, credentials) else: results = mesh_router.route(envelope, credentials) any_ok = any(r.ok for r in results) # ─── Mirror to Meshtastic bridge feed ──────────────────────── # The MQTT broker won't echo our own publishes back to our subscriber, # so inject successfully-sent messages into the bridge's deque directly. if any_ok and envelope.routed_via == "meshtastic": try: from services.sigint_bridge import sigint_grid bridge = sigint_grid.mesh if bridge: from datetime import datetime bridge.messages.appendleft( { "from": MeshtasticTransport.mesh_address_for_sender(node_id), "to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", "text": message, "region": credentials.get("mesh_region", "US"), "channel": body.get("channel", "LongFast"), "timestamp": datetime.utcnow().isoformat() + "Z", } ) except Exception: pass # Non-critical return { "ok": any_ok, "message_id": envelope.message_id, "event_id": "", "routed_via": envelope.routed_via, "route_reason": envelope.route_reason, "results": [r.to_dict() for r in results], } @app.get("/api/mesh/log") @limiter.limit("30/minute") async def mesh_log(request: Request): """Get recent mesh message routing log (audit trail).""" from services.mesh.mesh_router import mesh_router mesh_router.prune_message_log() entries = list(mesh_router.message_log) ok, _detail = _check_scoped_auth(request, "mesh.audit") if ok: return {"log": entries} public_entries = [entry for entry in (_public_mesh_log_entry(item) for item in entries) if entry] return {"log": public_entries} @app.get("/api/mesh/status") @limiter.limit("30/minute") async def mesh_status(request: Request): """Get mesh system status including circuit breaker state.""" from services.env_check import get_security_posture_warnings from services.mesh.mesh_router import mesh_router from services.sigint_bridge import sigint_grid mesh_router.prune_message_log() entries = list(mesh_router.message_log) sigs = sigint_grid.get_all_signals() aprs = sum(1 for s in sigs if s.get("source") == "aprs") mesh = sum(1 for s in sigs if s.get("source") == "meshtastic") js8 = sum(1 for s in sigs if s.get("source") == "js8call") ok, _detail = _check_scoped_auth(request, "mesh.audit") authenticated = _scoped_view_authenticated(request, "mesh.audit") response = { "circuit_breakers": { name: breaker.get_status() for name, breaker in mesh_router.breakers.items() }, "message_log_size": len(entries) if ok else _public_mesh_log_size(entries), "signal_counts": { "aprs": aprs, "meshtastic": mesh, "js8call": js8, "total": aprs + mesh + js8, }, } if ok: response["public_message_log_size"] = _public_mesh_log_size(entries) response["private_log_retention_seconds"] = int( getattr(get_settings(), "MESH_PRIVATE_LOG_TTL_S", 900) or 0 ) response["security_warnings"] = get_security_posture_warnings(get_settings()) return _redact_public_mesh_status(response, authenticated=authenticated) @app.get("/api/mesh/signals") @limiter.limit("30/minute") async def mesh_signals( request: Request, source: str = "", region: str = "", root: str = "", limit: int = 50, ): """Get SIGINT signals with optional source/region/root filters.""" from services.fetchers.sigint import build_sigint_snapshot sigs, _channel_stats, totals = build_sigint_snapshot() if source: sigs = [s for s in sigs if s.get("source") == source.lower()] if region: region_filter = region.upper() sigs = [ s for s in sigs if s.get("region", "").upper() == region_filter or s.get("root", "").upper() == region_filter ] if root: root_filter = root.upper() sigs = [s for s in sigs if s.get("root", "").upper() == root_filter] return { "signals": sigs[: min(limit, 500)], "total": len(sigs), "source_totals": totals, } @app.get("/api/mesh/messages") @limiter.limit("30/minute") async def mesh_messages( request: Request, region: str = "", root: str = "", channel: str = "", limit: int = 30, ): """Get recent Meshtastic text messages from the MQTT bridge.""" from services.sigint_bridge import sigint_grid bridge = sigint_grid.mesh if not bridge: return [] msgs = list(bridge.messages) if region: region_filter = region.upper() msgs = [ m for m in msgs if m.get("region", "").upper() == region_filter or m.get("root", "").upper() == region_filter ] if root: root_filter = root.upper() msgs = [m for m in msgs if m.get("root", "").upper() == root_filter] if channel: msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()] return msgs[: min(limit, 100)] @app.get("/api/mesh/channels") @limiter.limit("30/minute") async def mesh_channels(request: Request): """Get Meshtastic channel population stats — nodes per region/channel.""" stats = get_latest_data().get("mesh_channel_stats", {}) return stats # ─── Reputation Endpoints ───────────────────────────────────────────────── # Cached root node_id — avoids 5 encrypted disk reads per vote. _root_node_id_cache: dict[str, object] = {"value": None, "ts": 0.0} _ROOT_NODE_ID_TTL = 30.0 # seconds def _cached_root_node_id() -> str: import time as _time now = _time.time() if _root_node_id_cache["value"] is not None and (now - float(_root_node_id_cache["ts"])) < _ROOT_NODE_ID_TTL: return str(_root_node_id_cache["value"]) try: from services.mesh.mesh_wormhole_persona import read_wormhole_persona_state ps = read_wormhole_persona_state() nid = str(ps.get("root_identity", {}).get("node_id", "") or "").strip() _root_node_id_cache["value"] = nid _root_node_id_cache["ts"] = now return nid except Exception: return "" @app.post("/api/mesh/vote") @limiter.limit("30/minute") async def mesh_vote(request: Request): """Cast a reputation vote on a node. Body: {voter_id, voter_pubkey?, voter_sig?, target_id, vote: 1|-1, gate?: string} """ from services.mesh.mesh_reputation import reputation_ledger body = await request.json() voter_id = body.get("voter_id", "") target_id = body.get("target_id", "") vote = body.get("vote", 0) gate = body.get("gate", "") public_key = body.get("voter_pubkey", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("voter_sig", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not voter_id or not target_id: return {"ok": False, "detail": "Missing voter_id or target_id"} if vote not in (1, -1): return {"ok": False, "detail": "Vote must be 1 or -1"} gate_ok, gate_detail = _validate_gate_vote_context(voter_id, gate) if not gate_ok: return {"ok": False, "detail": gate_detail} gate = gate_detail or "" vote_payload = {"target_id": target_id, "vote": vote, "gate": gate} sig_ok, sig_reason = _verify_signed_event( event_type="vote", node_id=voter_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=vote_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="vote", node_id=voter_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} # Resolve stable local operator ID for duplicate-vote prevention. # Personas generate unique keypairs, so voter_id alone is insufficient — # use the root identity's node_id as a stable anchor so switching personas # doesn't let the same operator vote multiple times on the same post. stable_voter_id = voter_id try: root_nid = _cached_root_node_id() if root_nid: stable_voter_id = root_nid except Exception: pass # Register node if not known reputation_ledger.register_node(voter_id, public_key, public_key_algo) ok, reason, vote_weight = reputation_ledger.cast_vote(stable_voter_id, target_id, vote, gate) # Record on Infonet if ok: try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("vote", vote_payload) infonet.append( event_type="vote", node_id=voter_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) except Exception: pass return {"ok": ok, "detail": reason, "weight": round(vote_weight, 2)} @app.post("/api/mesh/report") @limiter.limit("10/minute") async def mesh_report(request: Request): """Report abusive or fraudulent behavior (signed, public, non-anonymous).""" body = await request.json() reporter_id = body.get("reporter_id", "") target_id = body.get("target_id", "") reason = body.get("reason", "") gate = body.get("gate", "") evidence = body.get("evidence", "") public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not reporter_id or not target_id or not reason: return {"ok": False, "detail": "Missing reporter_id, target_id, or reason"} report_payload = {"target_id": target_id, "reason": reason, "gate": gate, "evidence": evidence} sig_ok, sig_reason = _verify_signed_event( event_type="abuse_report", node_id=reporter_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=report_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="abuse_report", node_id=reporter_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(reporter_id, public_key, public_key_algo) except Exception: pass try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("abuse_report", report_payload) infonet.append( event_type="abuse_report", node_id=reporter_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) except Exception: logger.exception("failed to record abuse report on infonet") return {"ok": False, "detail": "report_record_failed"} return {"ok": True, "detail": "Report recorded"} @app.get("/api/mesh/reputation") @limiter.limit("60/minute") async def mesh_reputation(request: Request, node_id: str = ""): """Get reputation for a single node. Public callers receive a summary-only view; authenticated audit callers may access the richer breakdown. """ from services.mesh.mesh_reputation import reputation_ledger if not node_id: return {"ok": False, "detail": "Provide ?node_id=xxx"} return reputation_ledger.get_reputation_log( node_id, detailed=_scoped_view_authenticated(request, "mesh.audit"), ) @app.get("/api/mesh/reputation/batch") @limiter.limit("60/minute") async def mesh_reputation_batch(request: Request, node_id: list[str] = Query(default=[])): """Get overall public reputation for multiple public node IDs.""" from services.mesh.mesh_reputation import reputation_ledger normalized: list[str] = [] seen: set[str] = set() for raw in list(node_id or []): candidate = str(raw or "").strip() if not candidate or candidate in seen: continue seen.add(candidate) normalized.append(candidate) if len(normalized) >= 100: break if not normalized: return {"ok": False, "detail": "Provide at least one node_id", "reputations": {}} return { "ok": True, "reputations": { candidate: reputation_ledger.get_reputation(candidate).get("overall", 0) or 0 for candidate in normalized }, } @app.get("/api/mesh/reputation/all", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def mesh_reputation_all(request: Request): """Get all known node reputations.""" from services.mesh.mesh_reputation import reputation_ledger return {"reputations": reputation_ledger.get_all_reputations()} @app.post("/api/mesh/identity/rotate") @limiter.limit("5/minute") async def mesh_identity_rotate(request: Request): """Link a new node_id to an old one via dual-signature rotation.""" body = await request.json() old_node_id = body.get("old_node_id", "").strip() old_public_key = body.get("old_public_key", "").strip() old_public_key_algo = body.get("old_public_key_algo", "").strip() old_signature = body.get("old_signature", "").strip() new_node_id = body.get("new_node_id", "").strip() new_public_key = body.get("new_public_key", "").strip() new_public_key_algo = body.get("new_public_key_algo", "").strip() new_signature = body.get("new_signature", "").strip() timestamp = _safe_int(body.get("timestamp", 0) or 0) sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not ( old_node_id and old_public_key and old_public_key_algo and old_signature and new_node_id and new_public_key and new_public_key_algo and new_signature and timestamp ): return {"ok": False, "detail": "Missing rotation fields"} if old_node_id == new_node_id: return {"ok": False, "detail": "old_node_id must differ from new_node_id"} if abs(timestamp - int(time.time())) > 7 * 86400: return {"ok": False, "detail": "Rotation timestamp is too far from current time"} rotation_payload = { "old_node_id": old_node_id, "old_public_key": old_public_key, "old_public_key_algo": old_public_key_algo, "new_public_key": new_public_key, "new_public_key_algo": new_public_key_algo, "timestamp": timestamp, "old_signature": old_signature, } sig_ok, sig_reason = _verify_signed_event( event_type="key_rotate", node_id=new_node_id, sequence=sequence, public_key=new_public_key, public_key_algo=new_public_key_algo, signature=new_signature, payload=rotation_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="key_rotate", node_id=new_node_id, sequence=sequence, public_key=new_public_key, public_key_algo=new_public_key_algo, signature=new_signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} from services.mesh.mesh_crypto import ( build_signature_payload, parse_public_key_algo, verify_signature, verify_node_binding, ) if not verify_node_binding(old_node_id, old_public_key): return {"ok": False, "detail": "old_node_id does not match old public key"} old_algo = parse_public_key_algo(old_public_key_algo) if not old_algo: return {"ok": False, "detail": "Unsupported old_public_key_algo"} claim_payload = { "old_node_id": old_node_id, "old_public_key": old_public_key, "old_public_key_algo": old_public_key_algo, "new_public_key": new_public_key, "new_public_key_algo": new_public_key_algo, "timestamp": timestamp, } old_sig_payload = build_signature_payload( event_type="key_rotate", node_id=old_node_id, sequence=0, payload=claim_payload, ) if not verify_signature( public_key_b64=old_public_key, public_key_algo=old_algo, signature_hex=old_signature, payload=old_sig_payload, ): return {"ok": False, "detail": "Invalid old_signature"} from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(new_node_id, new_public_key, new_public_key_algo) ok, reason = reputation_ledger.link_identities(old_node_id, new_node_id) if not ok: return {"ok": False, "detail": reason} # Record on Infonet try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("key_rotate", rotation_payload) infonet.append( event_type="key_rotate", node_id=new_node_id, payload=normalized_payload, signature=new_signature, sequence=sequence, public_key=new_public_key, public_key_algo=new_public_key_algo, protocol_version=protocol_version, ) except Exception: pass return {"ok": True, "detail": "Identity linked"} @app.post("/api/mesh/identity/revoke") @limiter.limit("5/minute") async def mesh_identity_revoke(request: Request): """Revoke a node's key with a grace window.""" body = await request.json() node_id = body.get("node_id", "").strip() public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() revoked_at = _safe_int(body.get("revoked_at", 0) or 0) grace_until = _safe_int(body.get("grace_until", 0) or 0) reason = body.get("reason", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not (node_id and public_key and public_key_algo and signature and revoked_at and grace_until): return {"ok": False, "detail": "Missing revocation fields"} now = int(time.time()) max_grace = 7 * 86400 if grace_until < revoked_at: return {"ok": False, "detail": "grace_until must be >= revoked_at"} if grace_until - revoked_at > max_grace: return {"ok": False, "detail": "Grace window too large (max 7 days)"} if abs(revoked_at - now) > max_grace: return {"ok": False, "detail": "revoked_at is too far from current time"} payload = { "revoked_public_key": public_key, "revoked_public_key_algo": public_key_algo, "revoked_at": revoked_at, "grace_until": grace_until, "reason": reason, } sig_ok, sig_reason = _verify_signed_event( event_type="key_revoke", node_id=node_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} if payload["revoked_public_key"] != public_key: return {"ok": False, "detail": "revoked_public_key must match public_key"} if payload["revoked_public_key_algo"] != public_key_algo: return {"ok": False, "detail": "revoked_public_key_algo must match public_key_algo"} try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("key_revoke", payload) infonet.append( event_type="key_revoke", node_id=node_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) except Exception: logger.exception("failed to record key revocation on infonet") return {"ok": False, "detail": "revocation_record_failed"} return {"ok": True, "detail": "Identity revoked"} # ─── Gate Endpoints ─────────────────────────────────────────────────────── @app.post("/api/mesh/gate/create") @limiter.limit("5/hour") async def gate_create(request: Request): """Create a new reputation-gated community. Body: {creator_id, creator_pubkey?, creator_sig?, gate_id, display_name, rules?: {min_overall_rep, min_gate_rep}} """ from services.mesh.mesh_reputation import ( ALLOW_DYNAMIC_GATES, reputation_ledger, gate_manager, ) if not ALLOW_DYNAMIC_GATES: return {"ok": False, "detail": "Gate creation is disabled for the fixed private launch catalog"} body = await request.json() creator_id = body.get("creator_id", "") gate_id = body.get("gate_id", "") display_name = body.get("display_name", gate_id) rules = body.get("rules", {}) public_key = body.get("creator_pubkey", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("creator_sig", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not creator_id or not gate_id: return {"ok": False, "detail": "Missing creator_id or gate_id"} gate_payload = {"gate_id": gate_id, "display_name": display_name, "rules": rules} sig_ok, sig_reason = _verify_signed_event( event_type="gate_create", node_id=creator_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=gate_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="gate_create", node_id=creator_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} reputation_ledger.register_node(creator_id, public_key, public_key_algo) ok, reason = gate_manager.create_gate( creator_id, gate_id, display_name, min_overall_rep=rules.get("min_overall_rep", 0), min_gate_rep=rules.get("min_gate_rep"), ) # Record on Infonet if ok: try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("gate_create", gate_payload) infonet.append( event_type="gate_create", node_id=creator_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) except Exception: pass return {"ok": ok, "detail": reason} @app.get("/api/mesh/gate/list") @limiter.limit("30/minute") async def gate_list(request: Request): """List all known gates. Includes per-gate content keys so members can encrypt/decrypt gate_envelope payloads across nodes.""" from services.mesh.mesh_reputation import gate_manager return {"gates": gate_manager.list_gates(include_secrets=True)} @app.get("/api/mesh/gate/{gate_id}") @limiter.limit("30/minute") async def gate_detail(request: Request, gate_id: str): """Get gate details including ratification status.""" from services.mesh.mesh_reputation import gate_manager gate = gate_manager.get_gate(gate_id) if not gate: return {"ok": False, "detail": f"Gate '{gate_id}' not found"} gate["ratification"] = gate_manager.get_ratification_status(gate_id) return gate @app.post("/api/mesh/gate/{gate_id}/message") @limiter.limit("10/minute") async def gate_message(request: Request, gate_id: str): """Post a message to a gate. Checks entry rules against sender's reputation. Body: {sender_id, ciphertext, nonce, sender_ref, signature?} """ body = await request.json() return _submit_gate_message_envelope(request, gate_id, body) def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str, Any]) -> dict[str, Any]: """Validate and record an encrypted gate envelope on the private plane.""" from services.mesh.mesh_reputation import reputation_ledger, gate_manager sender_id = body.get("sender_id", "") epoch = _safe_int(body.get("epoch", 0) or 0) ciphertext = str(body.get("ciphertext", "")) nonce = str(body.get("nonce", body.get("iv", ""))) sender_ref = str(body.get("sender_ref", "")) payload_format = str(body.get("format", "mls1") or "mls1") public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not sender_id: return {"ok": False, "detail": "Missing sender_id"} if "message" in body and str(body.get("message", "")).strip(): return { "ok": False, "detail": "Plaintext gate messages are no longer accepted. Submit an encrypted gate envelope.", } gate_envelope = str(body.get("gate_envelope", "") or "").strip() reply_to = str(body.get("reply_to", "") or "").strip() gate_payload_input = { "gate": gate_id, "ciphertext": ciphertext, "nonce": nonce, "sender_ref": sender_ref, "format": payload_format, } if epoch > 0: gate_payload_input["epoch"] = epoch gate_payload = normalize_payload("gate_message", gate_payload_input) # Validate BEFORE adding gate_envelope (which is not a normalized field). payload_ok, payload_reason = validate_event_payload("gate_message", gate_payload) if not payload_ok: return {"ok": False, "detail": payload_reason} # gate_envelope and reply_to are NOT part of the signed payload — add after validation. if gate_envelope: gate_payload["gate_envelope"] = gate_envelope if reply_to: gate_payload["reply_to"] = reply_to # Signature verification payload must exclude epoch, gate_envelope, and reply_to # because compose_encrypted_gate_message signs without them. signature_gate_payload = normalize_payload( "gate_message", { "gate": gate_id, "ciphertext": ciphertext, "nonce": nonce, "sender_ref": sender_ref, "format": payload_format, }, ) sig_ok, sig_reason = _verify_signed_event( event_type="gate_message", node_id=sender_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=signature_gate_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="gate_message", node_id=sender_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} reputation_ledger.register_node(sender_id, public_key, public_key_algo) # Check gate access can_enter, reason = gate_manager.can_enter(sender_id, gate_id) if not can_enter: return {"ok": False, "detail": f"Gate access denied: {reason}"} cooldown_ok, cooldown_reason = _check_gate_post_cooldown(sender_id, gate_id) if not cooldown_ok: return {"ok": False, "detail": cooldown_reason} # Record on hashchain (encrypted — only gate members can decrypt). # NOTE: infonet.append() validates and advances the sequence counter # internally, so we must NOT call validate_and_set_sequence() beforehand # — doing so would pre-advance the counter and cause append() to reject # the event as a replay, silently dropping the message. # # Strip `epoch` — the message was signed without it so including it # would cause a signature mismatch. `gate_envelope` and `reply_to` # are kept in the payload for cross-node decryption; signature # verification in build_signature_payload() strips them automatically. chain_payload = {k: v for k, v in gate_payload.items() if k != "epoch"} chain_event_id = "" try: from services.mesh.mesh_hashchain import infonet, gate_store chain_result = infonet.append( event_type="gate_message", node_id=sender_id, payload=chain_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version or PROTOCOL_VERSION, ) chain_event_id = str(chain_result.get("event_id", "") or "") except ValueError as exc: # Sequence replay, signature failure, payload validation, etc. return {"ok": False, "detail": str(exc)} except Exception: logger.exception("Failed to record gate message on chain") return {"ok": False, "detail": "Failed to record gate message"} gate_manager.record_message(gate_id) _record_gate_post_cooldown(sender_id, gate_id) logger.info("Encrypted gate message accepted on obfuscated gate plane") # Store in gate_store for fast local read/decrypt (separate try so a # gate_store hiccup doesn't discard the already-committed chain event). try: from services.mesh.mesh_hashchain import gate_store import copy gate_event = copy.deepcopy(chain_result) gate_event["event_type"] = "gate_message" # Restore gate_envelope / reply_to that normalize_payload stripped # from the chain copy — these are needed for local decryption. # CRITICAL: we deep-copied so we don't mutate the chain's event dict # — adding gate_envelope to the chain payload would corrupt the hash. store_payload = gate_event.get("payload") if isinstance(store_payload, dict): if gate_envelope: store_payload["gate_envelope"] = gate_envelope if reply_to: store_payload["reply_to"] = reply_to stored_event = gate_store.append(gate_id, gate_event) _broadcast_gate_events(gate_id, [gate_event]) chain_event_id = chain_event_id or str(stored_event.get("event_id", "")) try: from services.mesh.mesh_rns import rns_bridge rns_bridge.publish_gate_event(gate_id, gate_event) except Exception: pass except Exception: logger.exception("Failed to store gate message in gate_store") return { "ok": True, "detail": f"Message posted to gate '{gate_id}'", "gate_id": gate_id, "event_id": chain_event_id, } # ─── Infonet Endpoints ─────────────────────────────────────────────────── @app.get("/api/mesh/infonet/status") @limiter.limit("30/minute") async def infonet_status(request: Request, verify_signatures: bool = False): """Get Infonet metadata — event counts, head hash, chain size.""" from services.mesh.mesh_hashchain import infonet from services.wormhole_supervisor import get_wormhole_state info = infonet.get_info() valid, reason = infonet.validate_chain(verify_signatures=verify_signatures) try: wormhole = get_wormhole_state() except Exception: wormhole = {"configured": False, "ready": False, "rns_ready": False} info["valid"] = valid info["validation"] = reason info["verify_signatures"] = verify_signatures info["private_lane_tier"] = _current_private_lane_tier(wormhole) info["private_lane_policy"] = _private_infonet_policy_snapshot() info.update(_node_runtime_snapshot()) return _redact_private_lane_control_fields( info, authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) @app.get("/api/mesh/infonet/merkle") @limiter.limit("30/minute") async def infonet_merkle(request: Request): """Merkle root for sync comparison.""" from services.mesh.mesh_hashchain import infonet return { "merkle_root": infonet.get_merkle_root(), "head_hash": infonet.head_hash, "count": len(infonet.events), "network_id": infonet.get_info().get("network_id"), } @app.get("/api/mesh/infonet/locator") @limiter.limit("30/minute") async def infonet_locator(request: Request, limit: int = Query(32, ge=4, le=128)): """Block locator for fork-aware sync.""" from services.mesh.mesh_hashchain import infonet locator = infonet.get_locator(max_entries=limit) return { "locator": locator, "head_hash": infonet.head_hash, "count": len(infonet.events), "network_id": infonet.get_info().get("network_id"), } @app.post("/api/mesh/infonet/sync") @limiter.limit("30/minute") async def infonet_sync_post( request: Request, limit: int = Query(100, ge=1, le=500), ): """Fork-aware sync using a block locator.""" from services.mesh.mesh_hashchain import infonet, GENESIS_HASH body = await request.json() req_proto = str(body.get("protocol_version", "") or "") if req_proto and req_proto != PROTOCOL_VERSION: return Response( content=json_mod.dumps( { "ok": False, "detail": "Unsupported protocol_version", "protocol_version": PROTOCOL_VERSION, } ), status_code=426, media_type="application/json", ) locator = body.get("locator", []) if not isinstance(locator, list): return {"ok": False, "detail": "locator must be a list"} expected_head = str(body.get("expected_head", "") or "") if expected_head and expected_head != infonet.head_hash: return Response( content=json_mod.dumps( { "ok": False, "detail": "head_hash mismatch", "head_hash": infonet.head_hash, "expected_head": expected_head, } ), status_code=409, media_type="application/json", ) if "limit" in body: try: limit = max(1, min(500, _safe_int(body["limit"], 0))) except Exception: pass matched_hash, start_index, events = infonet.get_events_after_locator(locator, limit=limit) forked = False if not matched_hash: forked = True elif matched_hash == GENESIS_HASH and len(locator) > 1: forked = True # Gate messages pass through as encrypted blobs — no redaction needed for ciphertext. # Non-gate events get standard public redaction. events = [e if e.get("event_type") == "gate_message" else _redact_public_event(e) for e in events] response = { "events": events, "matched_hash": matched_hash, "forked": forked, "head_hash": infonet.head_hash, "count": len(events), "protocol_version": PROTOCOL_VERSION, } if body.get("include_proofs"): proofs = infonet.get_merkle_proofs(start_index, len(events)) if start_index >= 0 else {} response.update( { "merkle_root": proofs.get("root", infonet.get_merkle_root()), "merkle_total": proofs.get("total", len(infonet.events)), "merkle_start": proofs.get("start", 0), "merkle_proofs": proofs.get("proofs", []), } ) return response @app.get("/api/mesh/metrics") @limiter.limit("30/minute") async def mesh_metrics(request: Request): """Mesh protocol health counters.""" from services.mesh.mesh_metrics import snapshot ok, detail = _check_scoped_auth(request, "mesh.audit") if not ok: if detail == "insufficient scope": raise HTTPException(status_code=403, detail="Forbidden — insufficient scope") raise HTTPException(status_code=403, detail=detail) return snapshot() @app.get("/api/mesh/rns/status") @limiter.limit("30/minute") async def mesh_rns_status(request: Request): from services.wormhole_supervisor import get_wormhole_state try: from services.mesh.mesh_rns import rns_bridge status = await asyncio.to_thread(rns_bridge.status) except Exception: status = {"enabled": False, "ready": False, "configured_peers": 0, "active_peers": 0} try: wormhole = get_wormhole_state() except Exception: wormhole = {"configured": False, "ready": False, "rns_ready": False} status["private_lane_tier"] = _current_private_lane_tier(wormhole) status["private_lane_policy"] = _private_infonet_policy_snapshot() return _redact_public_rns_status( status, authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) @app.get("/api/mesh/infonet/sync") @limiter.limit("30/minute") async def infonet_sync( request: Request, after_hash: str = "", limit: int = Query(100, ge=1, le=500), expected_head: str = "", protocol_version: str = "", ): """Return events after a given hash (delta sync).""" from services.mesh.mesh_hashchain import infonet, GENESIS_HASH if protocol_version and protocol_version != PROTOCOL_VERSION: return Response( content=json_mod.dumps( { "ok": False, "detail": "Unsupported protocol_version", "protocol_version": PROTOCOL_VERSION, } ), status_code=426, media_type="application/json", ) if expected_head and expected_head != infonet.head_hash: return Response( content=json_mod.dumps( { "ok": False, "detail": "head_hash mismatch", "head_hash": infonet.head_hash, "expected_head": expected_head, } ), status_code=409, media_type="application/json", ) base = after_hash or GENESIS_HASH events = infonet.get_events_after(base, limit=limit) events = [e if e.get("event_type") == "gate_message" else _redact_public_event(e) for e in events] return { "events": events, "after_hash": base, "count": len(events), "protocol_version": PROTOCOL_VERSION, } @app.post("/api/mesh/infonet/ingest", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def infonet_ingest(request: Request): """Ingest externally sourced Infonet events (strict verification).""" from services.mesh.mesh_hashchain import infonet body = await request.json() events = body.get("events", []) expected_head = str(body.get("expected_head", "") or "") if expected_head and expected_head != infonet.head_hash: return Response( content=json_mod.dumps( { "ok": False, "detail": "head_hash mismatch", "head_hash": infonet.head_hash, "expected_head": expected_head, } ), status_code=409, media_type="application/json", ) if not isinstance(events, list): return {"ok": False, "detail": "events must be a list"} if len(events) > 200: return {"ok": False, "detail": "Too many events in one ingest batch"} result = infonet.ingest_events(events) _hydrate_gate_store_from_chain(events) return {"ok": True, **result} @app.post("/api/mesh/infonet/peer-push") @limiter.limit("30/minute") async def infonet_peer_push(request: Request): """Accept pushed Infonet events from relay peers (HMAC-authenticated).""" content_length = request.headers.get("content-length") if content_length: try: if int(content_length) > 524_288: return Response( content='{"ok":false,"detail":"Request body too large (max 512KB)"}', status_code=413, media_type="application/json", ) except (ValueError, TypeError): pass from services.mesh.mesh_hashchain import infonet body_bytes = await request.body() if not _verify_peer_push_hmac(request, body_bytes): return Response( content='{"ok":false,"detail":"Invalid or missing peer HMAC"}', status_code=403, media_type="application/json", ) body = json_mod.loads(body_bytes or b"{}") events = body.get("events", []) if not isinstance(events, list): return {"ok": False, "detail": "events must be a list"} if len(events) > 50: return {"ok": False, "detail": "Too many events in one push (max 50)"} if not events: return {"ok": True, "accepted": 0, "duplicates": 0, "rejected": []} result = infonet.ingest_events(events) _hydrate_gate_store_from_chain(events) return {"ok": True, **result} @app.post("/api/mesh/gate/peer-push") @limiter.limit("30/minute") async def gate_peer_push(request: Request): """Accept pushed gate events from relay peers (private plane).""" content_length = request.headers.get("content-length") if content_length: try: if int(content_length) > 524_288: return Response( content='{"ok":false,"detail":"Request body too large"}', status_code=413, media_type="application/json", ) except (ValueError, TypeError): pass from services.mesh.mesh_hashchain import gate_store body_bytes = await request.body() if not _verify_peer_push_hmac(request, body_bytes): return Response( content='{"ok":false,"detail":"Invalid or missing peer HMAC"}', status_code=403, media_type="application/json", ) body = json_mod.loads(body_bytes or b"{}") events = body.get("events", []) if not isinstance(events, list): return {"ok": False, "detail": "events must be a list"} if len(events) > 50: return {"ok": False, "detail": "Too many events (max 50)"} if not events: return {"ok": True, "accepted": 0, "duplicates": 0} from services.mesh.mesh_hashchain import resolve_gate_wire_ref grouped_events: dict[str, list[dict[str, Any]]] = {} for evt in events: evt_dict = evt if isinstance(evt, dict) else {} payload = evt_dict.get("payload") if not isinstance(payload, dict): payload = {} clean_event = { "event_id": str(evt_dict.get("event_id", "") or ""), "event_type": "gate_message", "timestamp": evt_dict.get("timestamp", 0), "node_id": str(evt_dict.get("node_id", "") or evt_dict.get("sender_id", "") or ""), "sequence": evt_dict.get("sequence", 0), "signature": str(evt_dict.get("signature", "") or ""), "public_key": str(evt_dict.get("public_key", "") or ""), "public_key_algo": str(evt_dict.get("public_key_algo", "") or ""), "protocol_version": str(evt_dict.get("protocol_version", "") or ""), "payload": { "ciphertext": str(payload.get("ciphertext", "") or ""), "format": str(payload.get("format", "") or ""), "nonce": str(payload.get("nonce", "") or ""), "sender_ref": str(payload.get("sender_ref", "") or ""), }, } epoch = _safe_int(payload.get("epoch", 0) or 0) if epoch > 0: clean_event["payload"]["epoch"] = epoch # Preserve gate_envelope and reply_to — these are required for # cross-node decryption and threading. gate_envelope_val = str(payload.get("gate_envelope", "") or "").strip() reply_to_val = str(payload.get("reply_to", "") or "").strip() if gate_envelope_val: clean_event["payload"]["gate_envelope"] = gate_envelope_val if reply_to_val: clean_event["payload"]["reply_to"] = reply_to_val event_gate_id = str(payload.get("gate", "") or evt_dict.get("gate", "") or "").strip().lower() if not event_gate_id: event_gate_id = resolve_gate_wire_ref( str(payload.get("gate_ref", "") or evt_dict.get("gate_ref", "") or ""), clean_event, ) if not event_gate_id: return {"ok": False, "detail": "gate resolution failed"} final_payload: dict[str, Any] = { "gate": event_gate_id, "ciphertext": clean_event["payload"]["ciphertext"], "format": clean_event["payload"]["format"], "nonce": clean_event["payload"]["nonce"], "sender_ref": clean_event["payload"]["sender_ref"], } if epoch > 0: final_payload["epoch"] = epoch if clean_event["payload"].get("gate_envelope"): final_payload["gate_envelope"] = clean_event["payload"]["gate_envelope"] if clean_event["payload"].get("reply_to"): final_payload["reply_to"] = clean_event["payload"]["reply_to"] grouped_events.setdefault(event_gate_id, []).append( { "event_id": clean_event["event_id"], "event_type": "gate_message", "timestamp": clean_event["timestamp"], "node_id": clean_event["node_id"], "sequence": clean_event["sequence"], "signature": clean_event["signature"], "public_key": clean_event["public_key"], "public_key_algo": clean_event["public_key_algo"], "protocol_version": clean_event["protocol_version"], "payload": final_payload, } ) accepted = 0 duplicates = 0 rejected = 0 for event_gate_id, items in grouped_events.items(): result = gate_store.ingest_peer_events(event_gate_id, items) a = int(result.get("accepted", 0) or 0) accepted += a duplicates += int(result.get("duplicates", 0) or 0) rejected += int(result.get("rejected", 0) or 0) if a > 0: _broadcast_gate_events(event_gate_id, items[:a]) return {"ok": True, "accepted": accepted, "duplicates": duplicates, "rejected": rejected} @app.post("/api/mesh/gate/peer-pull") @limiter.limit("30/minute") async def gate_peer_pull(request: Request): """Return gate events a peer is missing (HMAC-authenticated pull sync). Body: {"gate_id": "...", "after_count": N} Returns up to 50 events after the caller's known count for that gate. """ content_length = request.headers.get("content-length") if content_length: try: if int(content_length) > 65_536: return Response( content='{"ok":false,"detail":"Request body too large"}', status_code=413, media_type="application/json", ) except (ValueError, TypeError): pass from services.mesh.mesh_hashchain import gate_store body_bytes = await request.body() if not _verify_peer_push_hmac(request, body_bytes): return Response( content='{"ok":false,"detail":"Invalid or missing peer HMAC"}', status_code=403, media_type="application/json", ) body = json_mod.loads(body_bytes or b"{}") gate_id = str(body.get("gate_id", "") or "").strip().lower() after_count = _safe_int(body.get("after_count", 0) or 0) if not gate_id: # If no gate_id, return all known gate IDs with their event counts # so the puller knows which gates to sync. gate_ids = gate_store.known_gate_ids() gate_counts: dict[str, int] = {} for gid in gate_ids: with gate_store._lock: gate_counts[gid] = len(gate_store._gates.get(gid, [])) return {"ok": True, "gates": gate_counts} with gate_store._lock: all_events = list(gate_store._gates.get(gate_id, [])) total = len(all_events) if after_count >= total: return {"ok": True, "events": [], "total": total, "gate_id": gate_id} batch = all_events[after_count : after_count + _PEER_PUSH_BATCH_SIZE] return {"ok": True, "events": batch, "total": total, "gate_id": gate_id} # --------------------------------------------------------------------------- # SSE Gate Event Stream — real-time push of gate activity to frontends. # Delivers ALL gate events (encrypted blobs) to every connected client. # The client filters locally by gate_id — the server never learns which # gates a client cares about (privacy-preserving broadcast). # --------------------------------------------------------------------------- @app.get("/api/mesh/gate/stream") async def gate_event_stream(request: Request): """SSE stream of all gate events for real-time delivery.""" client_queue: asyncio.Queue = asyncio.Queue(maxsize=256) with _gate_sse_lock: _gate_sse_clients.add(client_queue) async def event_generator(): try: yield ": connected\n\n" while True: if await request.is_disconnected(): break try: payload = await asyncio.wait_for(client_queue.get(), timeout=15.0) yield f"data: {payload}\n\n" except asyncio.TimeoutError: yield ": keepalive\n\n" finally: with _gate_sse_lock: _gate_sse_clients.discard(client_queue) return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) # --------------------------------------------------------------------------- # Peer Management API — operator endpoints for adding / removing / listing # peers without editing peer_store.json by hand. # --------------------------------------------------------------------------- @app.get("/api/mesh/peers", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def list_peers(request: Request, bucket: str = Query(None)): """List all peers (or filter by bucket: sync, push, bootstrap).""" from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore store = PeerStore(DEFAULT_PEER_STORE_PATH) try: store.load() except Exception as exc: return {"ok": False, "detail": f"Failed to load peer store: {exc}"} if bucket: records = store.records_for_bucket(bucket) else: records = store.records() return { "ok": True, "count": len(records), "peers": [r.to_dict() for r in records], } @app.post("/api/mesh/peers", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def add_peer(request: Request): """Add a peer to the store. Body: {peer_url, transport?, label?, role?, buckets?[]}.""" from services.mesh.mesh_crypto import normalize_peer_url from services.mesh.mesh_peer_store import ( DEFAULT_PEER_STORE_PATH, PeerStore, PeerStoreError, make_push_peer_record, make_sync_peer_record, ) from services.mesh.mesh_router import peer_transport_kind body = await request.json() peer_url_raw = str(body.get("peer_url", "") or "").strip() if not peer_url_raw: return {"ok": False, "detail": "peer_url is required"} peer_url = normalize_peer_url(peer_url_raw) if not peer_url: return {"ok": False, "detail": "Invalid peer_url"} transport = str(body.get("transport", "") or "").strip().lower() if not transport: transport = peer_transport_kind(peer_url) if not transport: return {"ok": False, "detail": "Cannot determine transport for peer_url — provide transport explicitly"} label = str(body.get("label", "") or "").strip() role = str(body.get("role", "") or "").strip().lower() or "relay" buckets = body.get("buckets", ["sync", "push"]) if isinstance(buckets, str): buckets = [buckets] if not isinstance(buckets, list): buckets = ["sync", "push"] store = PeerStore(DEFAULT_PEER_STORE_PATH) try: store.load() except Exception: store = PeerStore(DEFAULT_PEER_STORE_PATH) added: list[str] = [] try: for b in buckets: b = str(b).strip().lower() if b == "sync": store.upsert(make_sync_peer_record(peer_url=peer_url, transport=transport, role=role, label=label)) added.append("sync") elif b == "push": store.upsert(make_push_peer_record(peer_url=peer_url, transport=transport, role=role, label=label)) added.append("push") store.save() except PeerStoreError as exc: return {"ok": False, "detail": str(exc)} return {"ok": True, "peer_url": peer_url, "buckets": added} @app.delete("/api/mesh/peers", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def remove_peer(request: Request): """Remove a peer. Body: {peer_url, bucket?}. If bucket omitted, removes from all buckets.""" from services.mesh.mesh_crypto import normalize_peer_url from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore body = await request.json() peer_url_raw = str(body.get("peer_url", "") or "").strip() if not peer_url_raw: return {"ok": False, "detail": "peer_url is required"} peer_url = normalize_peer_url(peer_url_raw) if not peer_url: return {"ok": False, "detail": "Invalid peer_url"} bucket_filter = str(body.get("bucket", "") or "").strip().lower() store = PeerStore(DEFAULT_PEER_STORE_PATH) try: store.load() except Exception: return {"ok": False, "detail": "Failed to load peer store"} removed: list[str] = [] for b in ["bootstrap", "sync", "push"]: if bucket_filter and b != bucket_filter: continue key = f"{b}:{peer_url}" if key in store._records: del store._records[key] removed.append(b) if not removed: return {"ok": False, "detail": "Peer not found in any bucket"} store.save() return {"ok": True, "peer_url": peer_url, "removed_from": removed} @app.patch("/api/mesh/peers", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def toggle_peer(request: Request): """Enable or disable a peer. Body: {peer_url, bucket, enabled: bool}.""" from services.mesh.mesh_crypto import normalize_peer_url from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerRecord, PeerStore body = await request.json() peer_url_raw = str(body.get("peer_url", "") or "").strip() bucket = str(body.get("bucket", "") or "").strip().lower() enabled = body.get("enabled") if not peer_url_raw: return {"ok": False, "detail": "peer_url is required"} if not bucket: return {"ok": False, "detail": "bucket is required"} if enabled is None: return {"ok": False, "detail": "enabled (true/false) is required"} peer_url = normalize_peer_url(peer_url_raw) if not peer_url: return {"ok": False, "detail": "Invalid peer_url"} store = PeerStore(DEFAULT_PEER_STORE_PATH) try: store.load() except Exception: return {"ok": False, "detail": "Failed to load peer store"} key = f"{bucket}:{peer_url}" record = store._records.get(key) if not record: return {"ok": False, "detail": f"Peer not found in {bucket} bucket"} updated = PeerRecord(**{**record.to_dict(), "enabled": bool(enabled), "updated_at": int(time.time())}) store._records[key] = updated store.save() return {"ok": True, "peer_url": peer_url, "bucket": bucket, "enabled": bool(enabled)} @app.get("/api/mesh/gate/{gate_id}/messages") @limiter.limit("60/minute") async def gate_messages( request: Request, gate_id: str, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ): """Get encrypted gate messages from private store (newest first). Requires gate membership.""" if not _verify_gate_access(request, gate_id): return Response( content='{"ok":false,"detail":"Gate membership required"}', status_code=403, media_type="application/json", ) from services.mesh.mesh_hashchain import gate_store from services.mesh.mesh_reputation import gate_manager safe_messages = [_strip_gate_identity(m) for m in gate_store.get_messages(gate_id, limit=limit, offset=offset)] if gate_id and not safe_messages: gate_meta = gate_manager.get_gate(gate_id) if gate_meta: welcome_text = str(gate_meta.get("welcome") or gate_meta.get("description") or "").strip() if welcome_text: safe_messages = [ { "event_id": f"seed_{gate_id}_welcome", "event_type": "gate_notice", "node_id": "!sb_gate", "message": welcome_text, "gate": gate_id, "timestamp": int(gate_meta.get("created_at") or time.time()), "sequence": 0, "ephemeral": False, "system_seed": True, "fixed_gate": bool(gate_meta.get("fixed", False)), } ] return {"messages": safe_messages, "count": len(safe_messages), "gate": gate_id} @app.get("/api/mesh/infonet/messages") @limiter.limit("60/minute") async def infonet_messages( request: Request, gate: str = "", limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ): """Browse messages on the Infonet (newest first). Optional gate filter.""" from services.mesh.mesh_hashchain import gate_store, infonet from services.mesh.mesh_reputation import gate_manager if gate: if not _verify_gate_access(request, gate): return Response( content='{"ok":false,"detail":"Gate membership required"}', status_code=403, media_type="application/json", ) messages = [_strip_gate_identity(m) for m in gate_store.get_messages(gate, limit=limit, offset=offset)] else: messages = infonet.get_messages(gate_id="", limit=limit, offset=offset) messages = [m for m in messages if m.get("event_type") != "gate_message"] messages = [_redact_public_event(m) for m in messages] if gate and not messages: gate_meta = gate_manager.get_gate(gate) if gate_meta: welcome_text = str(gate_meta.get("welcome") or gate_meta.get("description") or "").strip() if welcome_text: messages = [ { "event_id": f"seed_{gate}_welcome", "event_type": "gate_notice", "node_id": "!sb_gate", "message": welcome_text, "gate": gate, "timestamp": int(gate_meta.get("created_at") or time.time()), "sequence": 0, "ephemeral": False, "system_seed": True, "fixed_gate": bool(gate_meta.get("fixed", False)), } ] return {"messages": messages, "count": len(messages), "gate": gate or "all"} @app.get("/api/mesh/infonet/event/{event_id}") @limiter.limit("60/minute") async def infonet_event(request: Request, event_id: str): """Look up a single Infonet event by ID.""" from services.mesh.mesh_hashchain import gate_store, infonet evt = infonet.get_event(event_id) if not evt: evt = gate_store.get_event(event_id) if evt: gate_id = str(evt.get("payload", {}).get("gate", "") or evt.get("gate", "") or "").strip() if not gate_id or not _verify_gate_access(request, gate_id): return Response( content='{"ok":false,"detail":"Gate membership required"}', status_code=403, media_type="application/json", ) return _strip_gate_identity(evt) return {"ok": False, "detail": "Event not found"} if evt.get("event_type") == "gate_message": gate_id = str(evt.get("payload", {}).get("gate", "") or evt.get("gate", "") or "").strip() if not gate_id or not _verify_gate_access(request, gate_id): return Response( content='{"ok":false,"detail":"Gate membership required"}', status_code=403, media_type="application/json", ) return _strip_gate_identity(evt) return _redact_public_event(infonet.decorate_event(evt)) @app.get("/api/mesh/infonet/node/{node_id}") @limiter.limit("30/minute") async def infonet_node_events( request: Request, node_id: str, limit: int = Query(20, ge=1, le=100), ): """Get recent Infonet events by a specific node.""" from services.mesh.mesh_hashchain import infonet events = infonet.get_events_by_node(node_id, limit=limit) events = [e for e in events if e.get("event_type") != "gate_message"] events = [_redact_public_event(e) for e in infonet.decorate_events(events)] events = _redact_public_node_history( events, authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) return {"events": events, "count": len(events), "node_id": node_id} @app.get("/api/mesh/infonet/events") @limiter.limit("30/minute") async def infonet_events_by_type( request: Request, event_type: str = "", limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ): """Get recent Infonet events, optionally filtered by type.""" from services.mesh.mesh_hashchain import infonet if event_type: events = infonet.get_events_by_type(event_type, limit=limit, offset=offset) else: events = list(reversed(infonet.events)) events = events[offset : offset + limit] events = [e for e in events if e.get("event_type") != "gate_message"] events = [_redact_public_event(e) for e in infonet.decorate_events(events)] return { "events": events, "count": len(events), "event_type": event_type or "all", } # ─── Oracle Endpoints ───────────────────────────────────────────────────── @app.post("/api/mesh/oracle/predict") @limiter.limit("10/minute") async def oracle_predict(request: Request): """Place a prediction on a market outcome. FINAL decision. Body: {node_id, market_title, side, stake_amount?: number} - stake_amount = 0 or omitted → FREE PICK (earn rep if correct) - stake_amount > 0 → STAKE REP (risk rep, split loser pool if correct) - side can be "yes"/"no" or an outcome name for multi-outcome markets """ from services.mesh.mesh_oracle import oracle_ledger body = await request.json() node_id = body.get("node_id", "") market_title = body.get("market_title", "") side = body.get("side", "") stake_amount = _safe_float(body.get("stake_amount", 0)) public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not node_id or not market_title or not side: return {"ok": False, "detail": "Missing node_id, market_title, or side"} prediction_payload = { "market_title": market_title, "side": side, "stake_amount": stake_amount, } sig_ok, sig_reason = _verify_signed_event( event_type="prediction", node_id=node_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=prediction_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="prediction", node_id=node_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(node_id, public_key, public_key_algo) except Exception: pass # Get current market probability from live data data = get_latest_data() markets = data.get("prediction_markets", []) matched = None for m in markets: if m.get("title", "").lower() == market_title.lower(): matched = m break # Fuzzy fallback — partial match if not matched: for m in markets: if market_title.lower() in m.get("title", "").lower(): matched = m break if not matched: return {"ok": False, "detail": f"Market '{market_title}' not found in active markets."} # Determine probability for the chosen side # For binary yes/no, use consensus_pct. For multi-outcome, find the outcome's pct. probability = 50.0 side_lower = side.lower() outcomes = matched.get("outcomes", []) if outcomes: # Multi-outcome: find the specific outcome's probability for o in outcomes: if o.get("name", "").lower() == side_lower: probability = float(o.get("pct", 50)) break else: # Binary market consensus = matched.get("consensus_pct") if consensus is None: consensus = matched.get("polymarket_pct") or matched.get("kalshi_pct") or 50 probability = float(consensus) if side_lower == "no": probability = 100.0 - probability if stake_amount > 0: # STAKED prediction — risk rep for bigger reward ok, detail = oracle_ledger.place_market_stake( node_id, matched["title"], side, stake_amount, probability ) mode = "staked" else: # FREE prediction — no rep risked ok, detail = oracle_ledger.place_prediction(node_id, matched["title"], side, probability) mode = "free" # Record on Infonet if ok: try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("prediction", prediction_payload) infonet.append( event_type="prediction", node_id=node_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) except Exception: pass return {"ok": ok, "detail": detail, "probability": probability, "mode": mode} @app.get("/api/mesh/oracle/markets") @limiter.limit("30/minute") async def oracle_markets(request: Request): """List active prediction markets, categorized with top 10 per category. Includes network consensus data (picks + staked rep per side).""" from collections import defaultdict from services.mesh.mesh_oracle import oracle_ledger data = get_latest_data() markets = data.get("prediction_markets", []) # Get consensus for all active markets (bulk) all_consensus = oracle_ledger.get_all_market_consensus() by_category = defaultdict(list) for m in markets: by_category[m.get("category", "NEWS")].append(m) _fields = ( "title", "consensus_pct", "polymarket_pct", "kalshi_pct", "volume", "volume_24h", "end_date", "description", "category", "sources", "slug", "kalshi_ticker", "outcomes", ) categories = {} cat_totals = {} for cat in ["POLITICS", "CONFLICT", "NEWS", "FINANCE", "CRYPTO"]: all_cat = sorted( by_category.get(cat, []), key=lambda x: x.get("volume", 0) or 0, reverse=True, ) cat_totals[cat] = len(all_cat) cat_list = [] for m in all_cat[:10]: entry = {k: m.get(k) for k in _fields} entry["consensus"] = all_consensus.get(m.get("title", ""), {}) cat_list.append(entry) categories[cat] = cat_list return {"categories": categories, "total_count": len(markets), "cat_totals": cat_totals} @app.get("/api/mesh/oracle/search") @limiter.limit("20/minute") async def oracle_search(request: Request, q: str = "", limit: int = 20): """Search prediction markets — queries Polymarket API directly + cached data.""" if not q or len(q) < 2: return {"results": [], "query": q, "count": 0} from services.fetchers.prediction_markets import search_polymarket_direct # 1. Search Polymarket API directly (finds ALL markets, not just cached) poly_results = search_polymarket_direct(q, limit=limit) # 2. Also search cached data (catches Kalshi matches + merged data) data = get_latest_data() markets = data.get("prediction_markets", []) q_lower = q.lower() cached_matches = [m for m in markets if q_lower in m.get("title", "").lower()] # Deduplicate: prefer cached (has both sources) over poly-only seen_titles = set() combined = [] for m in cached_matches: seen_titles.add(m["title"].lower()) combined.append(m) for m in poly_results: if m["title"].lower() not in seen_titles: seen_titles.add(m["title"].lower()) combined.append(m) # Sort by volume descending combined.sort(key=lambda x: x.get("volume", 0) or 0, reverse=True) _fields = ( "title", "consensus_pct", "polymarket_pct", "kalshi_pct", "volume", "volume_24h", "end_date", "description", "category", "sources", "slug", "kalshi_ticker", "outcomes", ) results = [{k: m.get(k) for k in _fields} for m in combined[:limit]] return {"results": results, "query": q, "count": len(results)} @app.get("/api/mesh/oracle/markets/more") @limiter.limit("30/minute") async def oracle_markets_more( request: Request, category: str = "NEWS", offset: int = 0, limit: int = 10 ): """Load more markets for a specific category (paginated).""" data = get_latest_data() markets = data.get("prediction_markets", []) cat_markets = sorted( [m for m in markets if m.get("category") == category], key=lambda x: x.get("volume", 0) or 0, reverse=True, ) page = cat_markets[offset : offset + limit] _fields = ( "title", "consensus_pct", "polymarket_pct", "kalshi_pct", "volume", "volume_24h", "end_date", "description", "category", "sources", "slug", "kalshi_ticker", "outcomes", ) results = [{k: m.get(k) for k in _fields} for m in page] return { "markets": results, "category": category, "offset": offset, "has_more": offset + limit < len(cat_markets), "total": len(cat_markets), } @app.post("/api/mesh/oracle/resolve") @limiter.limit("5/minute") async def oracle_resolve(request: Request): """Resolve a prediction market (admin/agent action). Body: {market_title, outcome: "yes"|"no" or any outcome name} """ from services.mesh.mesh_oracle import oracle_ledger body = await request.json() market_title = body.get("market_title", "") outcome = body.get("outcome", "") if not market_title or not outcome: return {"ok": False, "detail": "Need market_title and outcome"} # Resolve free predictions winners, losers = oracle_ledger.resolve_market(market_title, outcome) # Resolve market stakes stake_result = oracle_ledger.resolve_market_stakes(market_title, outcome) return { "ok": True, "detail": f"Resolved: {winners} free winners, {losers} free losers, " f"{stake_result.get('winners', 0)} stake winners, {stake_result.get('losers', 0)} stake losers", "free": {"winners": winners, "losers": losers}, "stakes": stake_result, } @app.get("/api/mesh/oracle/consensus") @limiter.limit("30/minute") async def oracle_consensus(request: Request, market_title: str = ""): """Get network consensus for a market — picks + staked rep per side.""" from services.mesh.mesh_oracle import oracle_ledger if not market_title: return {"error": "market_title required"} return oracle_ledger.get_market_consensus(market_title) @app.post("/api/mesh/oracle/stake") @limiter.limit("10/minute") async def oracle_stake(request: Request): """Stake oracle rep on a post's truthfulness. Body: {staker_id, message_id, poster_id, side: "truth"|"false", amount, duration_days: 1-7} """ from services.mesh.mesh_oracle import oracle_ledger body = await request.json() staker_id = body.get("staker_id", "") message_id = body.get("message_id", "") poster_id = body.get("poster_id", "") side = body.get("side", "").lower() amount = _safe_float(body.get("amount", 0)) duration_days = _safe_int(body.get("duration_days", 1), 1) public_key = body.get("public_key", "") public_key_algo = body.get("public_key_algo", "") signature = body.get("signature", "") sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "") if not staker_id or not message_id or not side: return {"ok": False, "detail": "Missing staker_id, message_id, or side"} stake_payload = { "message_id": message_id, "poster_id": poster_id, "side": side, "amount": amount, "duration_days": duration_days, } sig_ok, sig_reason = _verify_signed_event( event_type="stake", node_id=staker_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=stake_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="stake", node_id=staker_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(staker_id, public_key, public_key_algo) except Exception: pass ok, detail = oracle_ledger.place_stake( staker_id, message_id, poster_id, side, amount, duration_days ) # Record on Infonet if ok: try: from services.mesh.mesh_hashchain import infonet normalized_payload = normalize_payload("stake", stake_payload) infonet.append( event_type="stake", node_id=staker_id, payload=normalized_payload, signature=signature, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) except Exception: pass return {"ok": ok, "detail": detail} @app.get("/api/mesh/oracle/stakes/{message_id}") @limiter.limit("30/minute") async def oracle_stakes_for_message(request: Request, message_id: str): """Get all oracle stakes on a message.""" from services.mesh.mesh_oracle import oracle_ledger return _redact_public_oracle_stakes( oracle_ledger.get_stakes_for_message(message_id), authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) @app.get("/api/mesh/oracle/profile") @limiter.limit("30/minute") async def oracle_profile(request: Request, node_id: str = ""): """Get full oracle profile — rep, prediction history, win rate, farming score.""" from services.mesh.mesh_oracle import oracle_ledger if not node_id: return {"ok": False, "detail": "Provide ?node_id=xxx"} profile = oracle_ledger.get_oracle_profile(node_id) return _redact_public_oracle_profile( profile, authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) @app.get("/api/mesh/oracle/predictions") @limiter.limit("30/minute") async def oracle_predictions(request: Request, node_id: str = ""): """Get a node's active (unresolved) predictions.""" from services.mesh.mesh_oracle import oracle_ledger if not node_id: return {"ok": False, "detail": "Provide ?node_id=xxx"} active_predictions = oracle_ledger.get_active_predictions(node_id) return _redact_public_oracle_predictions( active_predictions, authenticated=_scoped_view_authenticated(request, "mesh.audit"), ) @app.post("/api/mesh/oracle/resolve-stakes") @limiter.limit("5/minute") async def oracle_resolve_stakes(request: Request): """Resolve all expired stake contests. Can be called periodically or manually.""" from services.mesh.mesh_oracle import oracle_ledger resolutions = oracle_ledger.resolve_expired_stakes() return {"ok": True, "resolutions": resolutions, "count": len(resolutions)} # ─── Encrypted DM Relay (Dead Drop) ─────────────────────────────────────── def _secure_dm_enabled() -> bool: return bool(get_settings().MESH_DM_SECURE_MODE) def _legacy_dm_get_allowed() -> bool: return bool(get_settings().MESH_DM_ALLOW_LEGACY_GET) def _rns_private_dm_ready() -> bool: try: from services.mesh.mesh_rns import rns_bridge return bool(rns_bridge.enabled()) and bool(rns_bridge.status().get("private_dm_direct_ready")) except Exception: return False def _anonymous_dm_hidden_transport_enforced() -> bool: state = _anonymous_mode_state() return bool(state.get("enabled")) def _high_privacy_profile_enabled() -> bool: try: from services.wormhole_settings import read_wormhole_settings settings = read_wormhole_settings() return str(settings.get("privacy_profile", "default") or "default").lower() == "high" except Exception: return False async def _maybe_apply_dm_relay_jitter() -> None: if not _high_privacy_profile_enabled(): return await asyncio.sleep((50 + secrets.randbelow(451)) / 1000.0) def _dm_request_fresh(timestamp: int) -> bool: now_ts = int(time.time()) max_age = max(30, int(get_settings().MESH_DM_REQUEST_MAX_AGE_S)) return abs(timestamp - now_ts) <= max_age def _normalize_mailbox_claims(mailbox_claims: list[dict]) -> list[dict]: normalized: list[dict] = [] for claim in mailbox_claims[:32]: if not isinstance(claim, dict): continue normalized.append( { "type": str(claim.get("type", "")).lower(), "token": str(claim.get("token", "")), } ) return normalized def _verify_dm_mailbox_request( *, event_type: str, agent_id: str, mailbox_claims: list[dict], timestamp: int, nonce: str, public_key: str, public_key_algo: str, signature: str, sequence: int, protocol_version: str, ): payload = { "mailbox_claims": _normalize_mailbox_claims(mailbox_claims), "timestamp": timestamp, "nonce": nonce, } valid, reason = validate_event_payload(event_type, payload) if not valid: return False, reason, payload sig_ok, sig_reason = _verify_signed_event( event_type=event_type, node_id=agent_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=payload, protocol_version=protocol_version, ) if not sig_ok: return False, sig_reason, payload if not _dm_request_fresh(timestamp): return False, "Mailbox request timestamp is stale", payload return True, "ok", payload @app.post("/api/mesh/dm/register") @limiter.limit("10/minute") async def dm_register_key(request: Request): """Register a DH public key for encrypted DM key exchange.""" body = await request.json() agent_id = body.get("agent_id", "").strip() dh_pub_key = body.get("dh_pub_key", "").strip() dh_algo = body.get("dh_algo", "").strip() timestamp = _safe_int(body.get("timestamp", 0) or 0) public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not agent_id or not dh_pub_key or not dh_algo or not timestamp: return {"ok": False, "detail": "Missing agent_id, dh_pub_key, dh_algo, or timestamp"} if dh_algo.upper() not in ("X25519", "ECDH_P256", "ECDH"): return {"ok": False, "detail": "Unsupported dh_algo"} now_ts = int(time.time()) if abs(timestamp - now_ts) > 7 * 86400: return {"ok": False, "detail": "DH key timestamp is too far from current time"} from services.mesh.mesh_dm_relay import dm_relay key_payload = {"dh_pub_key": dh_pub_key, "dh_algo": dh_algo, "timestamp": timestamp} sig_ok, sig_reason = _verify_signed_event( event_type="dm_key", node_id=agent_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=key_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(agent_id, public_key, public_key_algo) except Exception: pass accepted, detail, metadata = dm_relay.register_dh_key( agent_id, dh_pub_key, dh_algo, timestamp, signature, public_key, public_key_algo, protocol_version, sequence, ) if not accepted: return {"ok": False, "detail": detail} return {"ok": True, **(metadata or {})} @app.get("/api/mesh/dm/pubkey") @limiter.limit("30/minute") async def dm_get_pubkey(request: Request, agent_id: str = ""): """Fetch an agent's DH public key for key exchange.""" if not agent_id: return {"ok": False, "detail": "Missing agent_id"} from services.mesh.mesh_dm_relay import dm_relay key_bundle = dm_relay.get_dh_key(agent_id) if key_bundle is None: return {"ok": False, "detail": "Agent not found or has no DH key"} return {"ok": True, "agent_id": agent_id, **key_bundle} @app.get("/api/mesh/dm/prekey-bundle") @limiter.limit("30/minute") async def dm_get_prekey_bundle(request: Request, agent_id: str = ""): if not agent_id: return {"ok": False, "detail": "Missing agent_id"} return fetch_dm_prekey_bundle(agent_id) @app.post("/api/mesh/dm/send") @limiter.limit("20/minute") async def dm_send(request: Request): """Deposit an encrypted DM in recipient's mailbox.""" from services.wormhole_supervisor import get_transport_tier tier = get_transport_tier() if tier == "public_degraded" and not _is_debug_test_request(request): return JSONResponse( status_code=428, content={"ok": False, "detail": "DM send requires private transport"}, ) body = await request.json() sender_id = body.get("sender_id", "").strip() sender_token = str(body.get("sender_token", "")).strip() sender_token_hash = "" recipient_id = body.get("recipient_id", "").strip() delivery_class = str(body.get("delivery_class", "")).strip().lower() recipient_token = str(body.get("recipient_token", "")).strip() ciphertext = body.get("ciphertext", "").strip() payload_format = str(body.get("format", "mls1") or "mls1").strip().lower() or "mls1" if str(tier or "").startswith("private_") and payload_format == "dm1": return JSONResponse( {"ok": False, "detail": "MLS session required in private transport mode — dm1 blocked on raw send path"}, status_code=403, ) session_welcome = str(body.get("session_welcome", "") or "").strip() sender_seal = str(body.get("sender_seal", "")).strip() relay_salt_hex = str(body.get("relay_salt", "") or "").strip().lower() msg_id = body.get("msg_id", "").strip() timestamp = _safe_int(body.get("timestamp", 0) or 0) nonce = str(body.get("nonce", "")).strip() public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if sender_token: token_result = consume_wormhole_dm_sender_token( sender_token=sender_token, recipient_id=recipient_id, delivery_class=delivery_class, recipient_token=recipient_token, ) if not token_result.get("ok"): return token_result if not recipient_id: recipient_id = str(token_result.get("recipient_id", "") or "") sender_id = str(token_result.get("sender_id", "") or sender_id) sender_token_hash = str(token_result.get("sender_token_hash", "") or "") public_key = str(token_result.get("public_key", "") or public_key) public_key_algo = str(token_result.get("public_key_algo", "") or public_key_algo) protocol_version = str(token_result.get("protocol_version", "") or protocol_version) from services.mesh.mesh_crypto import verify_node_binding derived_sender_id = sender_id if public_key and not verify_node_binding(sender_id or derived_sender_id, public_key): derived_sender_id = derive_node_id(public_key) if sender_seal: if not derived_sender_id: return {"ok": False, "detail": "sender_seal requires a valid public key"} if sender_id and sender_id != derived_sender_id: return {"ok": False, "detail": "sender_id does not match sender_seal public key"} sender_id = derived_sender_id if not sender_id or not recipient_id or not ciphertext or not msg_id or not timestamp: return {"ok": False, "detail": "Missing sender_id, recipient_id, ciphertext, msg_id, or timestamp"} now_ts = int(time.time()) if abs(timestamp - now_ts) > 7 * 86400: return {"ok": False, "detail": "DM timestamp is too far from current time"} if delivery_class not in ("request", "shared"): return {"ok": False, "detail": "delivery_class must be request or shared"} if ( str(tier or "").startswith("private_") and delivery_class == "shared" and bool(get_settings().MESH_DM_REQUIRE_SENDER_SEAL_SHARED) and not sender_seal ): return {"ok": False, "detail": "sealed sender required for shared private DMs"} if delivery_class == "shared" and not recipient_token: return {"ok": False, "detail": "recipient_token required for shared delivery"} if delivery_class == "shared" and not sender_token_hash: return {"ok": False, "detail": "sender_token required for shared delivery"} from services.mesh.mesh_dm_relay import dm_relay dm_payload = { "recipient_id": recipient_id, "delivery_class": delivery_class, "recipient_token": recipient_token, "ciphertext": ciphertext, "format": payload_format, "msg_id": msg_id, "timestamp": timestamp, } if session_welcome: dm_payload["session_welcome"] = session_welcome if sender_seal: dm_payload["sender_seal"] = sender_seal if relay_salt_hex: dm_payload["relay_salt"] = relay_salt_hex sig_ok, sig_reason = _verify_signed_event( event_type="dm_message", node_id=sender_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=dm_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} send_nonce = nonce or msg_id nonce_ok, nonce_reason = dm_relay.consume_nonce(sender_id, send_nonce, timestamp) if not nonce_ok: return {"ok": False, "detail": nonce_reason} try: from services.mesh.mesh_hashchain import infonet ok_seq, seq_reason = infonet.validate_and_set_sequence(sender_id, sequence) if not ok_seq: return {"ok": False, "detail": seq_reason} except Exception as exc: logger.warning("DM send sequence validation unavailable: %s", type(exc).__name__) def _append_dm_event() -> str | None: # Private DMs are intentionally off-ledger. The relay / Reticulum mailboxes # already carry the encrypted payload, and mirroring them into the public # chain creates exactly the metadata surface we are trying to avoid. # # Keep the hook shape here so later phases can add private local audit # storage without reworking the send path again. return None relay_sender_id = sender_id if sender_seal: if relay_salt_hex: if len(relay_salt_hex) != 32 or any(ch not in "0123456789abcdef" for ch in relay_salt_hex): return {"ok": False, "detail": "relay_salt must be a 32-character hex string"} else: import os as _os relay_salt_hex = _os.urandom(16).hex() relay_sender_id = "sealed:" + hmac.new( bytes.fromhex(relay_salt_hex), sender_id.encode("utf-8"), hashlib.sha256 ).hexdigest()[:16] transport = "relay" direct_result = None anonymous_dm_hidden_transport = _anonymous_dm_hidden_transport_enforced() if _secure_dm_enabled() and _rns_private_dm_ready() and not anonymous_dm_hidden_transport: try: from services.mesh.mesh_dm_relay import dm_relay from services.mesh.mesh_rns import rns_bridge if dm_relay.is_blocked(recipient_id, sender_id): return {"ok": False, "detail": "Recipient is not accepting your messages"} mailbox_key = dm_relay.mailbox_key_for_delivery( recipient_id=recipient_id, delivery_class=delivery_class, recipient_token=recipient_token if delivery_class == "shared" else None, ) direct_result = rns_bridge.send_private_dm( mailbox_key=mailbox_key, envelope={ "sender_id": relay_sender_id, "ciphertext": ciphertext, "format": payload_format, "session_welcome": session_welcome, "timestamp": timestamp, "msg_id": msg_id, "delivery_class": delivery_class, "sender_seal": sender_seal, }, ) if direct_result: transport = "reticulum" append_error = _append_dm_event() if append_error: return {"ok": False, "detail": append_error} return {"ok": True, "msg_id": msg_id, "transport": transport, "detail": "Delivered via Reticulum"} except Exception: direct_result = False await _maybe_apply_dm_relay_jitter() deposit_result = dm_relay.deposit( sender_id=relay_sender_id, raw_sender_id=sender_id, recipient_id=recipient_id, ciphertext=ciphertext, msg_id=msg_id, delivery_class=delivery_class, recipient_token=recipient_token if delivery_class == "shared" else None, sender_seal=sender_seal, sender_token_hash=sender_token_hash, payload_format=payload_format, session_welcome=session_welcome, ) if not deposit_result.get("ok"): return deposit_result append_error = _append_dm_event() if append_error: return {"ok": False, "detail": append_error} deposit_result["transport"] = transport if anonymous_dm_hidden_transport: deposit_result["detail"] = ( deposit_result.get("detail") or "Anonymous mode keeps private DMs off direct transport; delivered via hidden relay path" ) elif direct_result is False and _secure_dm_enabled(): deposit_result["detail"] = deposit_result.get("detail") or "Reticulum unavailable, relay fallback used" return deposit_result _REQUEST_V2_REDUCED_VERSION = "request-v2-reduced-v3" _REQUEST_V2_RECOVERY_STATES = {"pending", "verified", "failed"} def _is_canonical_reduced_request_message(message: dict[str, Any]) -> bool: item = dict(message or {}) return ( str(item.get("delivery_class", "") or "").strip().lower() == "request" and str(item.get("request_contract_version", "") or "").strip() == _REQUEST_V2_REDUCED_VERSION and item.get("sender_recovery_required") is True ) def _annotate_request_recovery_message(message: dict[str, Any]) -> dict[str, Any]: item = dict(message or {}) delivery_class = str(item.get("delivery_class", "") or "").strip().lower() sender_id = str(item.get("sender_id", "") or "").strip() sender_seal = str(item.get("sender_seal", "") or "").strip() if delivery_class != "request" or not sender_id.startswith("sealed:") or not sender_seal.startswith("v3:"): return item if not str(item.get("request_contract_version", "") or "").strip(): item["request_contract_version"] = _REQUEST_V2_REDUCED_VERSION item["sender_recovery_required"] = True state = str(item.get("sender_recovery_state", "") or "").strip().lower() if state not in _REQUEST_V2_RECOVERY_STATES: state = "pending" item["sender_recovery_state"] = state return item def _annotate_request_recovery_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: return [_annotate_request_recovery_message(message) for message in (messages or [])] def _request_duplicate_authority_rank(message: dict[str, Any]) -> int: item = dict(message or {}) if str(item.get("delivery_class", "") or "").strip().lower() != "request": return 0 if _is_canonical_reduced_request_message(item): return 3 sender_id = str(item.get("sender_id", "") or "").strip() if sender_id.startswith("sealed:"): return 1 if sender_id: return 2 return 0 def _request_duplicate_recovery_rank(message: dict[str, Any]) -> int: if not _is_canonical_reduced_request_message(message): return 0 state = str(dict(message or {}).get("sender_recovery_state", "") or "").strip().lower() if state == "verified": return 2 if state == "pending": return 1 return 0 def _poll_duplicate_source_rank(source: str) -> int: normalized = str(source or "").strip().lower() if normalized == "relay": return 2 if normalized == "reticulum": return 1 return 0 def _should_replace_dm_poll_duplicate( existing: dict[str, Any], existing_source: str, candidate: dict[str, Any], candidate_source: str, ) -> bool: candidate_authority = _request_duplicate_authority_rank(candidate) existing_authority = _request_duplicate_authority_rank(existing) if candidate_authority != existing_authority: return candidate_authority > existing_authority candidate_recovery = _request_duplicate_recovery_rank(candidate) existing_recovery = _request_duplicate_recovery_rank(existing) if candidate_recovery != existing_recovery: return candidate_recovery > existing_recovery candidate_source_rank = _poll_duplicate_source_rank(candidate_source) existing_source_rank = _poll_duplicate_source_rank(existing_source) if candidate_source_rank != existing_source_rank: return candidate_source_rank > existing_source_rank try: candidate_ts = float(candidate.get("timestamp", 0) or 0) except Exception: candidate_ts = 0.0 try: existing_ts = float(existing.get("timestamp", 0) or 0) except Exception: existing_ts = 0.0 return candidate_ts > existing_ts def _merge_dm_poll_messages( relay_messages: list[dict[str, Any]], direct_messages: list[dict[str, Any]], ) -> list[dict[str, Any]]: merged: list[dict[str, Any]] = [] index_by_msg_id: dict[str, tuple[int, str]] = {} def add_messages(items: list[dict[str, Any]], source: str) -> None: for original in items or []: item = dict(original or {}) msg_id = str(item.get("msg_id", "") or "").strip() if not msg_id: merged.append(item) continue existing = index_by_msg_id.get(msg_id) if existing is None: index_by_msg_id[msg_id] = (len(merged), source) merged.append(item) continue index, existing_source = existing if _should_replace_dm_poll_duplicate(merged[index], existing_source, item, source): merged[index] = item index_by_msg_id[msg_id] = (index, source) add_messages(relay_messages, "relay") add_messages(direct_messages, "reticulum") return sorted(merged, key=lambda item: float(item.get("timestamp", 0) or 0)) @app.post("/api/mesh/dm/poll") @limiter.limit("30/minute") async def dm_poll_secure(request: Request): """Pick up pending DMs via signed mailbox claims.""" body = await request.json() agent_id = body.get("agent_id", "").strip() mailbox_claims = body.get("mailbox_claims", []) timestamp = _safe_int(body.get("timestamp", 0) or 0) nonce = str(body.get("nonce", "")).strip() public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not agent_id: return {"ok": False, "detail": "Missing agent_id"} from services.mesh.mesh_dm_relay import dm_relay ok, reason, payload = _verify_dm_mailbox_request( event_type="dm_poll", agent_id=agent_id, mailbox_claims=mailbox_claims, timestamp=timestamp, nonce=nonce, public_key=public_key, public_key_algo=public_key_algo, signature=signature, sequence=sequence, protocol_version=protocol_version, ) if not ok: return {"ok": False, "detail": reason, "messages": [], "count": 0} nonce_ok, nonce_reason = dm_relay.consume_nonce(agent_id, nonce, timestamp) if not nonce_ok: return {"ok": False, "detail": nonce_reason, "messages": [], "count": 0} try: from services.mesh.mesh_hashchain import infonet ok_seq, seq_reason = infonet.validate_and_set_sequence(agent_id, sequence) if not ok_seq: return {"ok": False, "detail": seq_reason, "messages": [], "count": 0} except Exception: pass claims = payload.get("mailbox_claims", []) mailbox_keys = dm_relay.claim_mailbox_keys(agent_id, claims) msgs = _annotate_request_recovery_messages(dm_relay.collect_claims(agent_id, claims)) direct_msgs = [] if not _anonymous_dm_hidden_transport_enforced(): try: from services.mesh.mesh_rns import rns_bridge direct_msgs = _annotate_request_recovery_messages( rns_bridge.collect_private_dm(mailbox_keys) ) except Exception: direct_msgs = [] msgs = _merge_dm_poll_messages(msgs, direct_msgs) return {"ok": True, "messages": msgs, "count": len(msgs)} @app.get("/api/mesh/dm/poll") @limiter.limit("30/minute") async def dm_poll( request: Request, agent_id: str = "", agent_token: str = "", agent_token_prev: str = "", agent_tokens: str = "", ): """Pick up all pending DMs. Removes them from mailbox after retrieval.""" if _secure_dm_enabled() and not _legacy_dm_get_allowed(): return {"ok": False, "detail": "Legacy GET polling is disabled in secure mode", "messages": [], "count": 0} if not agent_id and not agent_token and not agent_token_prev and not agent_tokens: return {"ok": True, "messages": [], "count": 0} from services.mesh.mesh_dm_relay import dm_relay tokens: list[str] = [] if agent_tokens: for token in agent_tokens.split(","): token = token.strip() if token: tokens.append(token) if agent_token: tokens.append(agent_token) if agent_token_prev and agent_token_prev != agent_token: tokens.append(agent_token_prev) # Deduplicate while preserving order seen = set() unique_tokens: list[str] = [] for token in tokens: if token in seen: continue seen.add(token) unique_tokens.append(token) msgs: list[dict] = [] if unique_tokens: for token in unique_tokens[:32]: msgs.extend(dm_relay.collect_legacy(agent_token=token)) return {"ok": True, "messages": msgs, "count": len(msgs)} @app.post("/api/mesh/dm/count") @limiter.limit("60/minute") async def dm_count_secure(request: Request): """Unread DM count via signed mailbox claims.""" body = await request.json() agent_id = body.get("agent_id", "").strip() mailbox_claims = body.get("mailbox_claims", []) timestamp = _safe_int(body.get("timestamp", 0) or 0) nonce = str(body.get("nonce", "")).strip() public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not agent_id: return {"ok": False, "detail": "Missing agent_id", "count": 0} from services.mesh.mesh_dm_relay import dm_relay ok, reason, payload = _verify_dm_mailbox_request( event_type="dm_count", agent_id=agent_id, mailbox_claims=mailbox_claims, timestamp=timestamp, nonce=nonce, public_key=public_key, public_key_algo=public_key_algo, signature=signature, sequence=sequence, protocol_version=protocol_version, ) if not ok: return {"ok": False, "detail": reason, "count": 0} nonce_ok, nonce_reason = dm_relay.consume_nonce(agent_id, nonce, timestamp) if not nonce_ok: return {"ok": False, "detail": nonce_reason, "count": 0} try: from services.mesh.mesh_hashchain import infonet ok_seq, seq_reason = infonet.validate_and_set_sequence(agent_id, sequence) if not ok_seq: return {"ok": False, "detail": seq_reason, "count": 0} except Exception: pass claims = payload.get("mailbox_claims", []) mailbox_keys = dm_relay.claim_mailbox_keys(agent_id, claims) relay_ids = dm_relay.claim_message_ids(agent_id, claims) direct_ids = set() if not _anonymous_dm_hidden_transport_enforced(): try: from services.mesh.mesh_rns import rns_bridge direct_ids = rns_bridge.private_dm_ids(mailbox_keys) except Exception: direct_ids = set() return {"ok": True, "count": len(relay_ids | direct_ids)} @app.get("/api/mesh/dm/count") @limiter.limit("60/minute") async def dm_count( request: Request, agent_id: str = "", agent_token: str = "", agent_token_prev: str = "", agent_tokens: str = "", ): """Unread DM count (for notification badge). Lightweight poll.""" if _secure_dm_enabled() and not _legacy_dm_get_allowed(): return {"ok": False, "detail": "Legacy GET count is disabled in secure mode", "count": 0} if not agent_id and not agent_token and not agent_token_prev and not agent_tokens: return {"ok": True, "count": 0} from services.mesh.mesh_dm_relay import dm_relay tokens: list[str] = [] if agent_tokens: for token in agent_tokens.split(","): token = token.strip() if token: tokens.append(token) if agent_token: tokens.append(agent_token) if agent_token_prev and agent_token_prev != agent_token: tokens.append(agent_token_prev) # Deduplicate while preserving order seen = set() unique_tokens: list[str] = [] for token in tokens: if token in seen: continue seen.add(token) unique_tokens.append(token) if unique_tokens: total = 0 for token in unique_tokens[:32]: total += dm_relay.count_legacy(agent_token=token) return {"ok": True, "count": total} return {"ok": True, "count": 0} @app.post("/api/mesh/dm/block") @limiter.limit("10/minute") async def dm_block(request: Request): """Block or unblock a sender from DMing you.""" body = await request.json() agent_id = body.get("agent_id", "").strip() blocked_id = body.get("blocked_id", "").strip() action = body.get("action", "block").strip().lower() public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not agent_id or not blocked_id: return {"ok": False, "detail": "Missing agent_id or blocked_id"} from services.mesh.mesh_dm_relay import dm_relay block_payload = {"blocked_id": blocked_id, "action": action} sig_ok, sig_reason = _verify_signed_event( event_type="dm_block", node_id=agent_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=block_payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} try: from services.mesh.mesh_hashchain import infonet ok_seq, seq_reason = infonet.validate_and_set_sequence(agent_id, sequence) if not ok_seq: return {"ok": False, "detail": seq_reason} except Exception: pass if action == "unblock": dm_relay.unblock(agent_id, blocked_id) else: dm_relay.block(agent_id, blocked_id) return {"ok": True, "action": action, "blocked_id": blocked_id} @app.post("/api/mesh/dm/witness") @limiter.limit("20/minute") async def dm_key_witness(request: Request): """Record a lightweight witness for a DM key (dual-path spot-check).""" body = await request.json() witness_id = body.get("witness_id", "").strip() target_id = body.get("target_id", "").strip() dh_pub_key = body.get("dh_pub_key", "").strip() timestamp = _safe_int(body.get("timestamp", 0) or 0) public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not witness_id or not target_id or not dh_pub_key or not timestamp: return {"ok": False, "detail": "Missing witness_id, target_id, dh_pub_key, or timestamp"} now_ts = int(time.time()) if abs(timestamp - now_ts) > 7 * 86400: return {"ok": False, "detail": "Witness timestamp is too far from current time"} payload = {"target_id": target_id, "dh_pub_key": dh_pub_key, "timestamp": timestamp} sig_ok, sig_reason = _verify_signed_event( event_type="dm_key_witness", node_id=witness_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} integrity_ok, integrity_reason = _preflight_signed_event_integrity( event_type="dm_key_witness", node_id=witness_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, protocol_version=protocol_version, ) if not integrity_ok: return {"ok": False, "detail": integrity_reason} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(witness_id, public_key, public_key_algo) except Exception: pass from services.mesh.mesh_dm_relay import dm_relay ok, reason = dm_relay.record_witness(witness_id, target_id, dh_pub_key, timestamp) return {"ok": ok, "detail": reason} @app.get("/api/mesh/dm/witness") @limiter.limit("60/minute") async def dm_key_witness_get(request: Request, target_id: str = "", dh_pub_key: str = ""): """Get witness counts for a target's DH key.""" if not target_id: return {"ok": False, "detail": "Missing target_id"} from services.mesh.mesh_dm_relay import dm_relay witnesses = dm_relay.get_witnesses(target_id, dh_pub_key if dh_pub_key else None, limit=5) response = { "ok": True, "count": len(witnesses), } if _scoped_view_authenticated(request, "mesh.audit"): response["target_id"] = target_id response["dh_pub_key"] = dh_pub_key or "" response["witnesses"] = witnesses return response @app.post("/api/mesh/trust/vouch") @limiter.limit("20/minute") async def trust_vouch(request: Request): """Record a trust vouch for a node (web-of-trust signal).""" body = await request.json() voucher_id = body.get("voucher_id", "").strip() target_id = body.get("target_id", "").strip() note = body.get("note", "").strip() timestamp = _safe_int(body.get("timestamp", 0) or 0) public_key = body.get("public_key", "").strip() public_key_algo = body.get("public_key_algo", "").strip() signature = body.get("signature", "").strip() sequence = _safe_int(body.get("sequence", 0) or 0) protocol_version = body.get("protocol_version", "").strip() if not voucher_id or not target_id or not timestamp: return {"ok": False, "detail": "Missing voucher_id, target_id, or timestamp"} now_ts = int(time.time()) if abs(timestamp - now_ts) > 7 * 86400: return {"ok": False, "detail": "Vouch timestamp is too far from current time"} payload = {"target_id": target_id, "note": note, "timestamp": timestamp} sig_ok, sig_reason = _verify_signed_event( event_type="trust_vouch", node_id=voucher_id, sequence=sequence, public_key=public_key, public_key_algo=public_key_algo, signature=signature, payload=payload, protocol_version=protocol_version, ) if not sig_ok: return {"ok": False, "detail": sig_reason} try: from services.mesh.mesh_reputation import reputation_ledger reputation_ledger.register_node(voucher_id, public_key, public_key_algo) ok, reason = reputation_ledger.add_vouch(voucher_id, target_id, note, timestamp) return {"ok": ok, "detail": reason} except Exception: return {"ok": False, "detail": "Failed to record vouch"} @app.get("/api/mesh/trust/vouches", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def trust_vouches(request: Request, node_id: str = "", limit: int = 20): """Fetch latest vouches for a node.""" if not node_id: return {"ok": False, "detail": "Missing node_id"} try: from services.mesh.mesh_reputation import reputation_ledger vouches = reputation_ledger.get_vouches(node_id, limit=limit) return {"ok": True, "node_id": node_id, "vouches": vouches, "count": len(vouches)} except Exception: return {"ok": False, "detail": "Failed to fetch vouches"} @app.get("/api/debug-latest", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def debug_latest_data(request: Request): return list(get_latest_data().keys()) # ── CCTV media proxy (bypass CORS for cross-origin video/image streams) ─── _CCTV_PROXY_ALLOWED_HOSTS = { "s3-eu-west-1.amazonaws.com", # TfL JamCams "jamcams.tfl.gov.uk", "images.data.gov.sg", # Singapore LTA "cctv.austinmobility.io", "webcams.nyctmc.org", # State DOT camera feeds often resolve to separate media/CDN hosts from the # catalog/API hostname. Keep the proxy allowlist aligned with the actual # media hosts produced by trusted ingestors so cameras render reliably. "cwwp2.dot.ca.gov", # Caltrans "wzmedia.dot.ca.gov", # Caltrans static media "images.wsdot.wa.gov", # WSDOT "olypen.com", # WSDOT Aviation-linked public camera "flyykm.com", # WSDOT Aviation-linked public camera "cam.pangbornairport.com", # WSDOT Aviation-linked public camera "navigator-c2c.dot.ga.gov", # Georgia DOT "navigator-c2c.ga.gov", # Georgia DOT alternate host variant "navigator-csc.dot.ga.gov", # Georgia DOT alternate catalog/media host "vss1live.dot.ga.gov", # Georgia DOT stream hosts "vss2live.dot.ga.gov", "vss3live.dot.ga.gov", "vss4live.dot.ga.gov", "vss5live.dot.ga.gov", "511ga.org", # Georgia public camera images "gettingaroundillinois.com", # Illinois DOT "cctv.travelmidwest.com", # Illinois DOT camera media "mdotjboss.state.mi.us", # Michigan DOT "micamerasimages.net", # Michigan DOT image host "publicstreamer1.cotrip.org", # Colorado DOT / COtrip HLS hosts "publicstreamer2.cotrip.org", "publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org", "cocam.carsprogram.org", # Colorado DOT preview images "tripcheck.com", # Oregon DOT / TripCheck "www.tripcheck.com", "infocar.dgt.es", # Spain DGT "informo.madrid.es", # Madrid "www.windy.com", } @dataclass(frozen=True) class _CCTVProxyProfile: name: str timeout: tuple[float, float] = (5.0, 10.0) cache_seconds: int = 30 headers: dict[str, str] = field(default_factory=dict) def _cctv_host_allowed(hostname: str | None) -> bool: host = str(hostname or "").strip().lower() if not host: return False for allowed in _CCTV_PROXY_ALLOWED_HOSTS: normalized = str(allowed or "").strip().lower() if host == normalized or host.endswith(f".{normalized}"): return True return False def _proxied_cctv_url(target_url: str) -> str: from urllib.parse import quote return f"/api/cctv/media?url={quote(target_url, safe='')}" def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile: from urllib.parse import urlparse parsed = urlparse(target_url) host = str(parsed.hostname or "").strip().lower() path = str(parsed.path or "").strip().lower() if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}: return _CCTVProxyProfile( name="tfl-jamcam", timeout=(5.0, 20.0), cache_seconds=15, headers={ "Accept": "video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://tfl.gov.uk/", }, ) if host == "images.data.gov.sg": return _CCTVProxyProfile( name="lta-singapore", timeout=(5.0, 10.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, ) if host == "cctv.austinmobility.io": return _CCTVProxyProfile( name="austin-mobility", timeout=(5.0, 8.0), cache_seconds=15, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov", }, ) if host == "webcams.nyctmc.org": return _CCTVProxyProfile( name="nyc-dot", timeout=(5.0, 10.0), cache_seconds=15, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, ) if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}: return _CCTVProxyProfile( name="caltrans", timeout=(5.0, 15.0), cache_seconds=15, headers={ "Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8", "Referer": "https://cwwp2.dot.ca.gov/", }, ) if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}: return _CCTVProxyProfile( name="wsdot", timeout=(5.0, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, ) if host in {"navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov"}: read_timeout = 18.0 if "/snapshots/" in path else 12.0 return _CCTVProxyProfile( name="gdot-snapshot", timeout=(5.0, read_timeout), cache_seconds=15, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "http://navigator-c2c.dot.ga.gov/", }, ) if host == "511ga.org": return _CCTVProxyProfile( name="gdot-511ga-image", timeout=(5.0, 12.0), cache_seconds=15, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://511ga.org/cctv", }, ) if host.startswith("vss") and host.endswith("dot.ga.gov"): return _CCTVProxyProfile( name="gdot-hls", timeout=(5.0, 20.0), cache_seconds=10, headers={ "Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8", "Referer": "http://navigator-c2c.dot.ga.gov/", }, ) if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}: return _CCTVProxyProfile( name="illinois-dot", timeout=(5.0, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, ) if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}: return _CCTVProxyProfile( name="michigan-dot", timeout=(5.0, 12.0), cache_seconds=30, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://mdotjboss.state.mi.us/", }, ) if host in { "publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org", "publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org", }: return _CCTVProxyProfile( name="cotrip-hls", timeout=(5.0, 20.0), cache_seconds=10, headers={ "Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8", "Referer": "https://www.cotrip.org/", }, ) if host == "cocam.carsprogram.org": return _CCTVProxyProfile( name="cotrip-preview", timeout=(5.0, 12.0), cache_seconds=20, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://www.cotrip.org/", }, ) if host in {"tripcheck.com", "www.tripcheck.com"}: return _CCTVProxyProfile( name="odot-tripcheck", timeout=(5.0, 12.0), cache_seconds=30, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, ) if host == "infocar.dgt.es": return _CCTVProxyProfile( name="dgt-spain", timeout=(5.0, 8.0), cache_seconds=60, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://infocar.dgt.es/", }, ) if host == "informo.madrid.es": return _CCTVProxyProfile( name="madrid-city", timeout=(5.0, 12.0), cache_seconds=30, headers={ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://informo.madrid.es/", }, ) if host == "www.windy.com": return _CCTVProxyProfile( name="windy-webcams", timeout=(5.0, 12.0), cache_seconds=60, headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, ) return _CCTVProxyProfile( name="generic-cctv", timeout=(5.0, 10.0), cache_seconds=30, headers={"Accept": "*/*"}, ) def _cctv_upstream_headers(request: Request, profile: _CCTVProxyProfile) -> dict[str, str]: headers = { "User-Agent": "Mozilla/5.0 (compatible; ShadowBroker CCTV proxy)", **profile.headers, } range_header = request.headers.get("range") if range_header: headers["Range"] = range_header if_none_match = request.headers.get("if-none-match") if if_none_match: headers["If-None-Match"] = if_none_match if_modified_since = request.headers.get("if-modified-since") if if_modified_since: headers["If-Modified-Since"] = if_modified_since return headers def _cctv_response_headers(resp, cache_seconds: int, include_length: bool = True) -> dict[str, str]: headers = { "Cache-Control": f"public, max-age={cache_seconds}", "Access-Control-Allow-Origin": "*", } for key in ("Accept-Ranges", "Content-Range", "ETag", "Last-Modified"): value = resp.headers.get(key) if value: headers[key] = value if include_length: content_length = resp.headers.get("Content-Length") if content_length: headers["Content-Length"] = content_length return headers def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile): import requests as _req headers = _cctv_upstream_headers(request, profile) try: resp = _req.get( target_url, timeout=profile.timeout, stream=True, allow_redirects=True, headers=headers, ) except _req.exceptions.Timeout as exc: logger.warning("CCTV upstream timeout [%s] %s", profile.name, target_url) raise HTTPException(status_code=504, detail="Upstream timeout") from exc except _req.exceptions.RequestException as exc: logger.warning("CCTV upstream request failure [%s] %s: %s", profile.name, target_url, exc) raise HTTPException(status_code=502, detail="Upstream fetch failed") from exc if resp.status_code >= 400: logger.info("CCTV upstream HTTP %s [%s] %s", resp.status_code, profile.name, target_url) resp.close() raise HTTPException(status_code=int(resp.status_code), detail=f"Upstream returned {resp.status_code}") return resp def _proxy_cctv_media_response(request: Request, target_url: str): from urllib.parse import urlparse parsed = urlparse(target_url) profile = _cctv_proxy_profile_for_url(target_url) resp = _fetch_cctv_upstream_response(request, target_url, profile) content_type = resp.headers.get("Content-Type", "application/octet-stream") is_hls_playlist = ( ".m3u8" in str(parsed.path or "").lower() or "mpegurl" in content_type.lower() or "vnd.apple.mpegurl" in content_type.lower() ) if is_hls_playlist: body = resp.text if "#EXTM3U" in body: body = _rewrite_cctv_hls_playlist(target_url, body) resp.close() return Response( content=body, media_type=content_type, headers=_cctv_response_headers(resp, cache_seconds=profile.cache_seconds, include_length=False), ) return StreamingResponse( resp.iter_content(chunk_size=65536), status_code=resp.status_code, media_type=content_type, headers=_cctv_response_headers(resp, cache_seconds=profile.cache_seconds), background=BackgroundTask(resp.close), ) def _rewrite_cctv_hls_playlist(base_url: str, body: str) -> str: import re from urllib.parse import urljoin, urlparse def _rewrite_target(target: str) -> str: candidate = str(target or "").strip() if not candidate or candidate.startswith("data:"): return candidate absolute = urljoin(base_url, candidate) parsed_target = urlparse(absolute) if parsed_target.scheme not in ("http", "https"): return candidate if not _cctv_host_allowed(parsed_target.hostname): return candidate return _proxied_cctv_url(absolute) rewritten_lines: list[str] = [] for raw_line in body.splitlines(): stripped = raw_line.strip() if not stripped: rewritten_lines.append(raw_line) continue if stripped.startswith("#"): rewritten_lines.append( re.sub( r'URI="([^"]+)"', lambda match: f'URI="{_rewrite_target(match.group(1))}"', raw_line, ) ) continue rewritten_lines.append(_rewrite_target(stripped)) return "\n".join(rewritten_lines) + ("\n" if body.endswith("\n") else "") @app.get("/api/cctv/media") @limiter.limit("120/minute") async def cctv_media_proxy(request: Request, url: str = Query(...)): """Proxy CCTV media through the backend to bypass browser CORS restrictions.""" from urllib.parse import urlparse parsed = urlparse(url) if not _cctv_host_allowed(parsed.hostname): raise HTTPException(status_code=403, detail="Host not allowed") if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Invalid scheme") return _proxy_cctv_media_response(request, url) @app.get("/api/health", response_model=HealthResponse) @limiter.limit("30/minute") async def health_check(request: Request): import time from services.fetchers._store import get_source_timestamps_snapshot d = get_latest_data() last = d.get("last_updated") return { "status": "ok", "version": APP_VERSION, "last_updated": last, "sources": { "flights": len(d.get("commercial_flights", [])), "military": len(d.get("military_flights", [])), "ships": len(d.get("ships", [])), "satellites": len(d.get("satellites", [])), "earthquakes": len(d.get("earthquakes", [])), "cctv": len(d.get("cctv", [])), "news": len(d.get("news", [])), "uavs": len(d.get("uavs", [])), "firms_fires": len(d.get("firms_fires", [])), "liveuamap": len(d.get("liveuamap", [])), "gdelt": len(d.get("gdelt", [])), }, "freshness": get_source_timestamps_snapshot(), "uptime_seconds": round(time.time() - _start_time), } from services.radio_intercept import ( get_top_broadcastify_feeds, get_openmhz_systems, get_recent_openmhz_calls, find_nearest_openmhz_system, ) @app.get("/api/radio/top") @limiter.limit("30/minute") async def get_top_radios(request: Request): return get_top_broadcastify_feeds() @app.get("/api/radio/openmhz/systems") @limiter.limit("30/minute") async def api_get_openmhz_systems(request: Request): return get_openmhz_systems() @app.get("/api/radio/openmhz/calls/{sys_name}") @limiter.limit("60/minute") async def api_get_openmhz_calls(request: Request, sys_name: str): return get_recent_openmhz_calls(sys_name) @app.get("/api/radio/nearest") @limiter.limit("60/minute") async def api_get_nearest_radio( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), ): return find_nearest_openmhz_system(lat, lng) from services.radio_intercept import find_nearest_openmhz_systems_list @app.get("/api/radio/nearest-list") @limiter.limit("60/minute") async def api_get_nearest_radios_list( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), limit: int = Query(5, ge=1, le=20), ): return find_nearest_openmhz_systems_list(lat, lng, limit=limit) from services.network_utils import fetch_with_curl @app.get("/api/route/{callsign}") @limiter.limit("60/minute") async def get_flight_route(request: Request, callsign: str, lat: float = 0.0, lng: float = 0.0): r = fetch_with_curl( "https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign, "lat": lat, "lng": lng}]}, timeout=10, ) if r and r.status_code == 200: data = r.json() route_list = [] if isinstance(data, dict): route_list = data.get("value", []) elif isinstance(data, list): route_list = data if route_list and len(route_list) > 0: route = route_list[0] airports = route.get("_airports", []) if len(airports) >= 2: orig = airports[0] dest = airports[-1] return { "orig_loc": [orig.get("lon", 0), orig.get("lat", 0)], "dest_loc": [dest.get("lon", 0), dest.get("lat", 0)], "origin_name": f"{orig.get('iata', '') or orig.get('icao', '')}: {orig.get('name', 'Unknown')}", "dest_name": f"{dest.get('iata', '') or dest.get('icao', '')}: {dest.get('name', 'Unknown')}", } return {} from services.region_dossier import get_region_dossier @app.get("/api/region-dossier") @limiter.limit("30/minute") def api_region_dossier( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), ): """Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop.""" return get_region_dossier(lat, lng) # --------------------------------------------------------------------------- # Geocoding — proxy to Nominatim with caching and proper headers # --------------------------------------------------------------------------- from services.geocode import search_geocode, reverse_geocode @app.get("/api/geocode/search") @limiter.limit("30/minute") async def api_geocode_search( request: Request, q: str = "", limit: int = 5, local_only: bool = False, ): if not q or len(q.strip()) < 2: return {"results": [], "query": q, "count": 0} results = await asyncio.to_thread(search_geocode, q, limit, local_only) return {"results": results, "query": q, "count": len(results)} @app.get("/api/geocode/reverse") @limiter.limit("60/minute") async def api_geocode_reverse( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), local_only: bool = False, ): return await asyncio.to_thread(reverse_geocode, lat, lng, local_only) from services.sentinel_search import search_sentinel2_scene @app.get("/api/sentinel2/search") @limiter.limit("30/minute") def api_sentinel2_search( request: Request, lat: float = Query(..., ge=-90, le=90), lng: float = Query(..., ge=-180, le=180), ): """Search for latest Sentinel-2 imagery at a point. Sync for threadpool execution.""" return search_sentinel2_scene(lat, lng) @app.post("/api/sentinel/token") @limiter.limit("60/minute") async def api_sentinel_token(request: Request): """Proxy Copernicus CDSE OAuth2 token request (avoids browser CORS block). The user's client_id + client_secret are forwarded to the Copernicus identity provider and never stored on the server. """ import requests as req # Parse URL-encoded form body manually (avoids python-multipart dependency) body = await request.body() from urllib.parse import parse_qs params = parse_qs(body.decode("utf-8")) client_id = params.get("client_id", [""])[0] client_secret = params.get("client_secret", [""])[0] if not client_id or not client_secret: raise HTTPException(400, "client_id and client_secret required") token_url = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token" try: resp = await asyncio.to_thread( req.post, token_url, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, }, timeout=15, ) return Response( content=resp.content, status_code=resp.status_code, media_type="application/json", ) except Exception as exc: logger.exception("Token request failed") raise HTTPException(502, "Token request failed") # Server-side token cache for tile requests (avoids re-auth on every tile) _sh_token_cache: dict = {"token": None, "expiry": 0, "client_id": ""} @app.post("/api/sentinel/tile") @limiter.limit("300/minute") async def api_sentinel_tile(request: Request): """Proxy Sentinel Hub Process API tile request (avoids CORS block). Expects JSON body with: client_id, client_secret, preset, date, z, x, y. Returns the PNG tile directly. """ import requests as req import time as _time try: body = await request.json() except Exception: return JSONResponse(status_code=422, content={"ok": False, "detail": "invalid JSON body"}) client_id = body.get("client_id", "") client_secret = body.get("client_secret", "") preset = body.get("preset", "TRUE-COLOR") date_str = body.get("date", "") z = body.get("z", 0) x = body.get("x", 0) y = body.get("y", 0) if not client_id or not client_secret or not date_str: raise HTTPException(400, "client_id, client_secret, and date required") # Reuse cached token if same client_id and not expired now = _time.time() if ( _sh_token_cache["token"] and _sh_token_cache["client_id"] == client_id and now < _sh_token_cache["expiry"] - 30 ): token = _sh_token_cache["token"] else: # Fetch new token token_url = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token" try: tresp = await asyncio.to_thread( req.post, token_url, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, }, timeout=15, ) if tresp.status_code != 200: raise HTTPException(401, f"Token auth failed: {tresp.text[:200]}") tdata = tresp.json() token = tdata["access_token"] _sh_token_cache["token"] = token _sh_token_cache["expiry"] = now + tdata.get("expires_in", 300) _sh_token_cache["client_id"] = client_id except HTTPException: raise except Exception as exc: logger.exception("Token request failed") raise HTTPException(502, "Token request failed") # Compute bounding box from tile coordinates (EPSG:3857) import math half = 20037508.342789244 tile_size = (2 * half) / math.pow(2, z) min_x = -half + x * tile_size max_x = min_x + tile_size max_y = half - y * tile_size min_y = max_y - tile_size bbox = [min_x, min_y, max_x, max_y] # Evalscripts evalscripts = { "TRUE-COLOR": '//VERSION=3\nfunction setup(){return{input:["B04","B03","B02"],output:{bands:3}};}\nfunction evaluatePixel(s){return[2.5*s.B04,2.5*s.B03,2.5*s.B02];}', "FALSE-COLOR": '//VERSION=3\nfunction setup(){return{input:["B08","B04","B03"],output:{bands:3}};}\nfunction evaluatePixel(s){return[2.5*s.B08,2.5*s.B04,2.5*s.B03];}', "NDVI": '//VERSION=3\nfunction setup(){return{input:["B04","B08"],output:{bands:3}};}\nfunction evaluatePixel(s){var n=(s.B08-s.B04)/(s.B08+s.B04);if(n<-0.2)return[0.05,0.05,0.05];if(n<0)return[0.75,0.75,0.75];if(n<0.1)return[0.86,0.86,0.86];if(n<0.2)return[0.92,0.84,0.68];if(n<0.3)return[0.77,0.88,0.55];if(n<0.4)return[0.56,0.80,0.32];if(n<0.5)return[0.35,0.72,0.18];if(n<0.6)return[0.20,0.60,0.08];if(n<0.7)return[0.10,0.48,0.04];return[0.0,0.36,0.0];}', "MOISTURE-INDEX": '//VERSION=3\nfunction setup(){return{input:["B8A","B11"],output:{bands:3}};}\nfunction evaluatePixel(s){var m=(s.B8A-s.B11)/(s.B8A+s.B11);var r=Math.max(0,Math.min(1,1.5-3*m));var g=Math.max(0,Math.min(1,m<0?1.5+3*m:1.5-3*m));var b=Math.max(0,Math.min(1,1.5+3*(m-0.5)));return[r,g,b];}', } evalscript = evalscripts.get(preset, evalscripts["TRUE-COLOR"]) # Adaptive time range: wider window at lower zoom for better coverage. # Sentinel-2 has 5-day revisit — a single day often has gaps. # At low zoom we mosaic over more days to fill gaps. from datetime import datetime as _dt, timedelta as _td try: end_date = _dt.strptime(date_str, "%Y-%m-%d") except ValueError: end_date = _dt.utcnow() if z <= 6: lookback_days = 30 # continent-level: mosaic a full month elif z <= 9: lookback_days = 14 # region-level: 2 weeks elif z <= 11: lookback_days = 7 # country-level: 1 week else: lookback_days = 5 # close-up: 5 days (one revisit cycle) start_date = end_date - _td(days=lookback_days) process_body = { "input": { "bounds": { "bbox": bbox, "properties": {"crs": "http://www.opengis.net/def/crs/EPSG/0/3857"}, }, "data": [ { "type": "sentinel-2-l2a", "dataFilter": { "timeRange": { "from": start_date.strftime("%Y-%m-%dT00:00:00Z"), "to": end_date.strftime("%Y-%m-%dT23:59:59Z"), }, "maxCloudCoverage": 30, "mosaickingOrder": "leastCC", }, } ], }, "output": { "width": 256, "height": 256, "responses": [{"identifier": "default", "format": {"type": "image/png"}}], }, "evalscript": evalscript, } try: resp = await asyncio.to_thread( req.post, "https://sh.dataspace.copernicus.eu/api/v1/process", json=process_body, headers={ "Authorization": f"Bearer {token}", "Accept": "image/png", }, timeout=30, ) return Response( content=resp.content, status_code=resp.status_code, media_type=resp.headers.get("content-type", "image/png"), ) except Exception as exc: logger.exception("Process API failed") raise HTTPException(502, "Process API failed") # --------------------------------------------------------------------------- # API Settings — key registry & management # --------------------------------------------------------------------------- from services.api_settings import get_api_keys, update_api_key from services.shodan_connector import ( ShodanConnectorError, count_shodan, get_shodan_connector_status, lookup_shodan_host, search_shodan, ) from pydantic import BaseModel class ApiKeyUpdate(BaseModel): env_key: str value: str class ShodanSearchRequest(BaseModel): query: str page: int = 1 facets: list[str] = [] class ShodanCountRequest(BaseModel): query: str facets: list[str] = [] class ShodanHostRequest(BaseModel): ip: str history: bool = False @app.get("/api/settings/api-keys", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def api_get_keys(request: Request): return get_api_keys() @app.put("/api/settings/api-keys", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_update_key(request: Request, body: ApiKeyUpdate): ok = update_api_key(body.env_key, body.value) if ok: return {"status": "updated", "env_key": body.env_key} return {"status": "error", "message": "Failed to update .env file"} @app.get("/api/tools/shodan/status", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_shodan_status(request: Request): return get_shodan_connector_status() @app.post("/api/tools/shodan/search", dependencies=[Depends(require_local_operator)]) @limiter.limit("12/minute") async def api_shodan_search(request: Request, body: ShodanSearchRequest): try: return search_shodan(body.query, page=body.page, facets=body.facets) except ShodanConnectorError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc @app.post("/api/tools/shodan/count", dependencies=[Depends(require_local_operator)]) @limiter.limit("12/minute") async def api_shodan_count(request: Request, body: ShodanCountRequest): try: return count_shodan(body.query, facets=body.facets) except ShodanConnectorError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc @app.post("/api/tools/shodan/host", dependencies=[Depends(require_local_operator)]) @limiter.limit("12/minute") async def api_shodan_host(request: Request, body: ShodanHostRequest): try: return lookup_shodan_host(body.ip, history=body.history) except ShodanConnectorError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc # --------------------------------------------------------------------------- # Finnhub — free market intelligence (quotes, congress trades, insider txns) # --------------------------------------------------------------------------- from services.unusual_whales_connector import ( FinnhubConnectorError, get_uw_status, fetch_congress_trades, fetch_insider_transactions, fetch_defense_quotes, ) @app.get("/api/tools/uw/status", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_uw_status(request: Request): return get_uw_status() @app.post("/api/tools/uw/congress", dependencies=[Depends(require_local_operator)]) @limiter.limit("12/minute") async def api_uw_congress(request: Request): try: return fetch_congress_trades() except FinnhubConnectorError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc @app.post("/api/tools/uw/darkpool", dependencies=[Depends(require_local_operator)]) @limiter.limit("12/minute") async def api_uw_darkpool(request: Request): try: return fetch_insider_transactions() except FinnhubConnectorError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc @app.post("/api/tools/uw/flow", dependencies=[Depends(require_local_operator)]) @limiter.limit("12/minute") async def api_uw_flow(request: Request): try: return fetch_defense_quotes() except FinnhubConnectorError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc # --------------------------------------------------------------------------- # News Feed Configuration # --------------------------------------------------------------------------- from services.news_feed_config import get_feeds, save_feeds, reset_feeds @app.get("/api/settings/news-feeds") @limiter.limit("30/minute") async def api_get_news_feeds(request: Request): return get_feeds() @app.put("/api/settings/news-feeds", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_save_news_feeds(request: Request): body = await request.json() ok = save_feeds(body) if ok: return {"status": "updated", "count": len(body)} return Response( content=json_mod.dumps( { "status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)", } ), status_code=400, media_type="application/json", ) @app.post("/api/settings/news-feeds/reset", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_reset_news_feeds(request: Request): ok = reset_feeds() if ok: return {"status": "reset", "feeds": get_feeds()} return {"status": "error", "message": "Failed to reset feeds"} # --------------------------------------------------------------------------- # Wormhole Settings — local agent toggle # --------------------------------------------------------------------------- from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings from services.wormhole_status import read_wormhole_status from services.wormhole_supervisor import ( connect_wormhole, disconnect_wormhole, get_wormhole_state, restart_wormhole, ) from services.mesh.mesh_wormhole_identity import ( bootstrap_wormhole_identity, register_wormhole_dm_key, sign_wormhole_message, sign_wormhole_event, ) from services.mesh.mesh_wormhole_persona import ( activate_gate_persona, bootstrap_wormhole_persona_state, clear_active_gate_persona, create_gate_persona, enter_gate_anonymously, get_active_gate_identity, get_dm_identity, get_transport_identity, leave_gate, list_gate_personas, retire_gate_persona, sign_gate_wormhole_event, sign_public_wormhole_event, ) from services.mesh.mesh_wormhole_prekey import ( bootstrap_decrypt_from_sender, bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle, register_wormhole_prekey_bundle, ) from services.mesh.mesh_wormhole_sender_token import ( consume_wormhole_dm_sender_token, issue_wormhole_dm_sender_token, issue_wormhole_dm_sender_tokens, ) from services.mesh.mesh_wormhole_seal import build_sender_seal, open_sender_seal from services.mesh.mesh_wormhole_dead_drop import ( derive_dead_drop_token_pair, derive_sas_phrase, derive_dead_drop_tokens_for_contacts, issue_pairwise_dm_alias, rotate_pairwise_dm_alias, ) from services.mesh.mesh_gate_mls import ( compose_encrypted_gate_message, decrypt_gate_message_for_local_identity, ensure_gate_member_access, get_local_gate_key_status, is_gate_locked_to_mls as is_gate_mls_locked, mark_gate_rekey_recommended, rotate_gate_epoch, ) from services.mesh.mesh_dm_mls import ( decrypt_dm as decrypt_mls_dm, encrypt_dm as encrypt_mls_dm, ensure_dm_session as ensure_mls_dm_session, has_dm_session as has_mls_dm_session, initiate_dm_session as initiate_mls_dm_session, is_dm_locked_to_mls, ) from services.mesh.mesh_wormhole_ratchet import ( decrypt_wormhole_dm, encrypt_wormhole_dm, reset_wormhole_dm_ratchet, ) class WormholeUpdate(BaseModel): enabled: bool transport: str | None = None socks_proxy: str | None = None socks_dns: bool | None = None anonymous_mode: bool | None = None class NodeSettingsUpdate(BaseModel): enabled: bool @app.get("/api/settings/node") @limiter.limit("30/minute") async def api_get_node_settings(request: Request): from services.node_settings import read_node_settings data = await asyncio.to_thread(read_node_settings) return { **data, "node_mode": _current_node_mode(), "node_enabled": _participant_node_enabled(), } @app.put("/api/settings/node", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_set_node_settings(request: Request, body: NodeSettingsUpdate): _refresh_node_peer_store() return _set_participant_node_enabled(bool(body.enabled)) @app.get("/api/settings/wormhole") @limiter.limit("30/minute") async def api_get_wormhole_settings(request: Request): settings = await asyncio.to_thread(read_wormhole_settings) return _redact_wormhole_settings(settings, authenticated=_scoped_view_authenticated(request, "wormhole")) @app.put("/api/settings/wormhole", dependencies=[Depends(require_admin)]) @limiter.limit("5/minute") async def api_set_wormhole_settings(request: Request, body: WormholeUpdate): existing = read_wormhole_settings() updated = write_wormhole_settings( enabled=bool(body.enabled), transport=body.transport, socks_proxy=body.socks_proxy, socks_dns=body.socks_dns, anonymous_mode=body.anonymous_mode, ) transport_changed = ( str(existing.get("transport", "direct")) != str(updated.get("transport", "direct")) or str(existing.get("socks_proxy", "")) != str(updated.get("socks_proxy", "")) or bool(existing.get("socks_dns", True)) != bool(updated.get("socks_dns", True)) ) if bool(updated.get("enabled")): state = restart_wormhole(reason="settings_update") if transport_changed else connect_wormhole(reason="settings_enable") else: state = disconnect_wormhole(reason="settings_disable") return {**updated, "requires_restart": False, "runtime": state} class PrivacyProfileUpdate(BaseModel): profile: str class WormholeSignRequest(BaseModel): event_type: str payload: dict sequence: int | None = None gate_id: str | None = None class WormholeSignRawRequest(BaseModel): message: str class WormholeDmEncryptRequest(BaseModel): peer_id: str peer_dh_pub: str = "" plaintext: str local_alias: str | None = None remote_alias: str | None = None remote_prekey_bundle: dict[str, Any] | None = None class WormholeDmComposeRequest(BaseModel): peer_id: str peer_dh_pub: str = "" plaintext: str local_alias: str | None = None remote_alias: str | None = None remote_prekey_bundle: dict[str, Any] | None = None class WormholeDmDecryptRequest(BaseModel): peer_id: str ciphertext: str format: str = "dm1" nonce: str = "" local_alias: str | None = None remote_alias: str | None = None session_welcome: str | None = None class WormholeDmResetRequest(BaseModel): peer_id: str | None = None class WormholeDmBootstrapEncryptRequest(BaseModel): peer_id: str plaintext: str class WormholeDmBootstrapDecryptRequest(BaseModel): sender_id: str = "" ciphertext: str class WormholeDmSenderTokenRequest(BaseModel): recipient_id: str delivery_class: str recipient_token: str = "" count: int = 1 class WormholeOpenSealRequest(BaseModel): sender_seal: str candidate_dh_pub: str recipient_id: str expected_msg_id: str class WormholeBuildSealRequest(BaseModel): recipient_id: str recipient_dh_pub: str msg_id: str timestamp: int class WormholeDeadDropTokenRequest(BaseModel): peer_id: str peer_dh_pub: str class WormholePairwiseAliasRequest(BaseModel): peer_id: str peer_dh_pub: str = "" class WormholePairwiseAliasRotateRequest(BaseModel): peer_id: str peer_dh_pub: str = "" grace_ms: int = 45_000 class WormholeDeadDropContactsRequest(BaseModel): contacts: list[dict[str, Any]] limit: int = 24 class WormholeSasRequest(BaseModel): peer_id: str peer_dh_pub: str words: int = 8 class WormholeGateRequest(BaseModel): gate_id: str rotate: bool = False class WormholeGatePersonaCreateRequest(BaseModel): gate_id: str label: str = "" class WormholeGatePersonaActivateRequest(BaseModel): gate_id: str persona_id: str class WormholeGateKeyGrantRequest(BaseModel): gate_id: str recipient_node_id: str recipient_dh_pub: str recipient_scope: str = "member" class WormholeGateComposeRequest(BaseModel): gate_id: str plaintext: str reply_to: str = "" class WormholeGateDecryptRequest(BaseModel): gate_id: str epoch: int = 0 ciphertext: str nonce: str = "" sender_ref: str = "" format: str = "mls1" gate_envelope: str = "" class WormholeGateDecryptBatchRequest(BaseModel): messages: list[WormholeGateDecryptRequest] class WormholeGateRotateRequest(BaseModel): gate_id: str reason: str = "manual_rotate" def _default_dm_local_alias(peer_id: str = "") -> str: """Generate a per-peer pseudonymous alias for DM conversations.""" import hashlib import hmac as _hmac identity = get_dm_identity() node_id = str(identity.get("node_id", "") or "").strip() if not node_id: return "dm-local" if not peer_id: return node_id[:12] derived = _hmac.new( node_id.encode("utf-8"), peer_id.encode("utf-8"), hashlib.sha256, ).hexdigest()[:12] return f"dm-{derived}" def _resolve_dm_aliases( *, peer_id: str, local_alias: str | None, remote_alias: str | None, ) -> tuple[str, str]: resolved_local = str(local_alias or "").strip() or _default_dm_local_alias(peer_id=peer_id) resolved_remote = str(remote_alias or "").strip() or str(peer_id or "").strip() return resolved_local, resolved_remote def compose_wormhole_dm( *, peer_id: str, peer_dh_pub: str, plaintext: str, local_alias: str | None = None, remote_alias: str | None = None, remote_prekey_bundle: dict[str, Any] | None = None, ) -> dict[str, Any]: resolved_local, resolved_remote = _resolve_dm_aliases( peer_id=peer_id, local_alias=local_alias, remote_alias=remote_alias, ) has_session = has_mls_dm_session(resolved_local, resolved_remote) if not has_session.get("ok"): return has_session if has_session.get("exists"): encrypted = encrypt_mls_dm(resolved_local, resolved_remote, plaintext) if encrypted.get("ok"): return { "ok": True, "peer_id": str(peer_id or "").strip(), "local_alias": resolved_local, "remote_alias": resolved_remote, "ciphertext": str(encrypted.get("ciphertext", "") or ""), "nonce": str(encrypted.get("nonce", "") or ""), "format": "mls1", "session_welcome": "", } if str(encrypted.get("detail", "") or "") != "session_expired": return encrypted bundle = dict(remote_prekey_bundle or {}) if not bundle and str(peer_id or "").strip(): fetched_bundle = fetch_dm_prekey_bundle(str(peer_id or "").strip()) if fetched_bundle.get("ok"): bundle = fetched_bundle if bundle and str(peer_id or "").strip(): try: from services.mesh.mesh_wormhole_contacts import observe_remote_prekey_identity from services.mesh.mesh_wormhole_prekey import trust_fingerprint_for_bundle_record trust_fingerprint = str(bundle.get("trust_fingerprint", "") or "").strip().lower() if not trust_fingerprint: trust_fingerprint = trust_fingerprint_for_bundle_record( { "agent_id": str(peer_id or "").strip(), "bundle": bundle, "public_key": str(bundle.get("public_key", "") or ""), "public_key_algo": str(bundle.get("public_key_algo", "") or ""), "protocol_version": str(bundle.get("protocol_version", "") or ""), } ) trust_state = observe_remote_prekey_identity( str(peer_id or "").strip(), fingerprint=trust_fingerprint, sequence=_safe_int(bundle.get("sequence", 0) or 0), signed_at=_safe_int(bundle.get("signed_at", 0) or 0), ) if trust_state.get("trust_changed"): return { "ok": False, "peer_id": str(peer_id or "").strip(), "detail": "remote prekey identity changed; verification required", "trust_changed": True, } except Exception as exc: logger.warning("remote prekey trust pin unavailable: %s", type(exc).__name__) if str(bundle.get("mls_key_package", "") or "").strip(): initiated = initiate_mls_dm_session( resolved_local, resolved_remote, bundle, str( peer_dh_pub or bundle.get("welcome_dh_pub") or bundle.get("identity_dh_pub_key") or "" ).strip(), ) if not initiated.get("ok"): return initiated encrypted = encrypt_mls_dm(resolved_local, resolved_remote, plaintext) if not encrypted.get("ok"): return encrypted return { "ok": True, "peer_id": str(peer_id or "").strip(), "local_alias": resolved_local, "remote_alias": resolved_remote, "ciphertext": str(encrypted.get("ciphertext", "") or ""), "nonce": str(encrypted.get("nonce", "") or ""), "format": "mls1", "session_welcome": str(initiated.get("welcome", "") or ""), } from services.wormhole_supervisor import get_transport_tier current_tier = get_transport_tier() if str(current_tier or "").startswith("private_"): return { "ok": False, "detail": "MLS session required in private transport mode — legacy DM fallback blocked", } if not str(peer_dh_pub or "").strip(): return {"ok": False, "detail": "peer_dh_pub required for legacy DM fallback"} logger.warning("legacy dm compose path used") legacy = encrypt_wormhole_dm(peer_id=str(peer_id or ""), peer_dh_pub=str(peer_dh_pub or ""), plaintext=plaintext) if not legacy.get("ok"): return legacy return { "ok": True, "peer_id": str(peer_id or "").strip(), "local_alias": resolved_local, "remote_alias": resolved_remote, "ciphertext": str(legacy.get("result", "") or ""), "nonce": "", "format": "dm1", "session_welcome": "", } def decrypt_wormhole_dm_envelope( *, peer_id: str, ciphertext: str, payload_format: str = "dm1", nonce: str = "", local_alias: str | None = None, remote_alias: str | None = None, session_welcome: str | None = None, ) -> dict[str, Any]: resolved_local, resolved_remote = _resolve_dm_aliases( peer_id=peer_id, local_alias=local_alias, remote_alias=remote_alias, ) normalized_format = str(payload_format or "dm1").strip().lower() or "dm1" if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote): return { "ok": False, "detail": "DM session is locked to MLS format", "required_format": "mls1", "current_format": normalized_format, } if normalized_format == "mls1": has_session = has_mls_dm_session(resolved_local, resolved_remote) if not has_session.get("ok"): return has_session if not has_session.get("exists"): ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or "")) if not ensured.get("ok"): return ensured decrypted = decrypt_mls_dm( resolved_local, resolved_remote, str(ciphertext or ""), str(nonce or ""), ) if not decrypted.get("ok"): return decrypted return { "ok": True, "peer_id": str(peer_id or "").strip(), "local_alias": resolved_local, "remote_alias": resolved_remote, "plaintext": str(decrypted.get("plaintext", "") or ""), "format": "mls1", } from services.wormhole_supervisor import get_transport_tier current_tier = get_transport_tier() if str(current_tier or "").startswith("private_"): return { "ok": False, "detail": "MLS format required in private transport mode — legacy DM decrypt blocked", } logger.warning("legacy dm decrypt path used") legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or "")) if not legacy.get("ok"): return legacy return { "ok": True, "peer_id": str(peer_id or "").strip(), "local_alias": resolved_local, "remote_alias": resolved_remote, "plaintext": str(legacy.get("result", "") or ""), "format": "dm1", } @app.get("/api/settings/privacy-profile") @limiter.limit("30/minute") async def api_get_privacy_profile(request: Request): data = await asyncio.to_thread(read_wormhole_settings) return _redact_privacy_profile_settings( data, authenticated=_scoped_view_authenticated(request, "wormhole"), ) @app.get("/api/settings/wormhole-status") @limiter.limit("30/minute") async def api_get_wormhole_status(request: Request): state = await asyncio.to_thread(get_wormhole_state) transport_tier = _current_private_lane_tier(state) if ( transport_tier == "public_degraded" and bool(state.get("arti_ready")) and _is_debug_test_request(request) ): transport_tier = "private_strong" full_state = { **state, "transport_tier": transport_tier, } return _redact_wormhole_status( full_state, authenticated=_scoped_view_authenticated(request, "wormhole"), ) @app.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_join(request: Request): existing = read_wormhole_settings() updated = write_wormhole_settings( enabled=True, transport="direct", socks_proxy="", socks_dns=True, anonymous_mode=False, ) transport_changed = ( str(existing.get("transport", "direct")) != "direct" or str(existing.get("socks_proxy", "")) != "" or bool(existing.get("socks_dns", True)) is not True or bool(existing.get("anonymous_mode", False)) is not False or bool(existing.get("enabled", False)) is not True ) bootstrap_wormhole_identity() bootstrap_wormhole_persona_state() state = ( restart_wormhole(reason="join_wormhole") if transport_changed else connect_wormhole(reason="join_wormhole") ) # Enable node participation so the sync/push workers connect to peers. # This is the voluntary opt-in — the node only joins the network when # the user explicitly opens the Wormhole. from services.node_settings import write_node_settings write_node_settings(enabled=True) _refresh_node_peer_store() return { "ok": True, "identity": get_transport_identity(), "runtime": state, "settings": updated, } @app.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_leave(request: Request): updated = write_wormhole_settings(enabled=False) state = disconnect_wormhole(reason="leave_wormhole") # Disable node participation when the user leaves the Wormhole. from services.node_settings import write_node_settings write_node_settings(enabled=False) return { "ok": True, "runtime": state, "settings": updated, } @app.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_identity(request: Request): try: bootstrap_wormhole_persona_state() return get_transport_identity() except Exception as exc: logger.exception("wormhole transport identity fetch failed") raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc @app.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_identity_bootstrap(request: Request): bootstrap_wormhole_identity() bootstrap_wormhole_persona_state() return get_transport_identity() @app.get("/api/wormhole/dm/identity", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_dm_identity(request: Request): try: bootstrap_wormhole_persona_state() return get_dm_identity() except Exception as exc: logger.exception("wormhole dm identity fetch failed") raise HTTPException(status_code=500, detail="wormhole_dm_identity_failed") from exc @app.post("/api/wormhole/sign", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_sign(request: Request, body: WormholeSignRequest): event_type = str(body.event_type or "") payload = dict(body.payload or {}) if event_type.startswith("dm_"): return sign_wormhole_event( event_type=event_type, payload=payload, sequence=body.sequence, ) gate_id = str(body.gate_id or "").strip().lower() if gate_id: signed = sign_gate_wormhole_event( gate_id=gate_id, event_type=event_type, payload=payload, sequence=body.sequence, ) if not signed.get("signature"): raise HTTPException(status_code=400, detail=str(signed.get("detail") or "wormhole_gate_sign_failed")) return signed return sign_public_wormhole_event( event_type=event_type, payload=payload, sequence=body.sequence, ) @app.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): return enter_gate_anonymously(str(body.gate_id or ""), rotate=bool(body.rotate)) @app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): return leave_gate(str(body.gate_id or "")) @app.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_identity(request: Request, gate_id: str): return get_active_gate_identity(gate_id) @app.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_personas(request: Request, gate_id: str): return list_gate_personas(gate_id) @app.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_key_status(request: Request, gate_id: str): return get_local_gate_key_status(gate_id) @app.post("/api/wormhole/gate/key/rotate", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotateRequest): return rotate_gate_epoch( gate_id=str(body.gate_id or ""), reason=str(body.reason or "manual_rotate"), ) @app.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_create( request: Request, body: WormholeGatePersonaCreateRequest ): return create_gate_persona(str(body.gate_id or ""), label=str(body.label or "")) @app.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_activate( request: Request, body: WormholeGatePersonaActivateRequest ): return activate_gate_persona(str(body.gate_id or ""), str(body.persona_id or "")) @app.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): return clear_active_gate_persona(str(body.gate_id or "")) @app.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_retire( request: Request, body: WormholeGatePersonaActivateRequest ): result = retire_gate_persona(str(body.gate_id or ""), str(body.persona_id or "")) if result.get("ok"): result["gate_key_status"] = mark_gate_rekey_recommended( str(body.gate_id or ""), reason="persona_retired", ) return result @app.post("/api/wormhole/gate/key/grant", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_key_grant(request: Request, body: WormholeGateKeyGrantRequest): return ensure_gate_member_access( gate_id=str(body.gate_id or ""), recipient_node_id=str(body.recipient_node_id or ""), recipient_dh_pub=str(body.recipient_dh_pub or ""), recipient_scope=str(body.recipient_scope or "member"), ) @app.post("/api/wormhole/gate/message/compose", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_message_compose(request: Request, body: WormholeGateComposeRequest): composed = compose_encrypted_gate_message( gate_id=str(body.gate_id or ""), plaintext=str(body.plaintext or ""), ) if composed.get("ok") and _is_debug_test_request(request): return {**dict(composed), "epoch": composed.get("epoch", 0)} if composed.get("ok"): return _redact_composed_gate_message(composed) return composed @app.post("/api/wormhole/gate/message/post", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_message_post(request: Request, body: WormholeGateComposeRequest): composed = compose_encrypted_gate_message( gate_id=str(body.gate_id or ""), plaintext=str(body.plaintext or ""), ) if not composed.get("ok"): return composed reply_to = str(body.reply_to or "").strip() return _submit_gate_message_envelope( request, str(body.gate_id or ""), { "sender_id": composed.get("sender_id", ""), "public_key": composed.get("public_key", ""), "public_key_algo": composed.get("public_key_algo", ""), "signature": composed.get("signature", ""), "sequence": composed.get("sequence", 0), "protocol_version": composed.get("protocol_version", ""), "epoch": composed.get("epoch", 0), "ciphertext": composed.get("ciphertext", ""), "nonce": composed.get("nonce", ""), "sender_ref": composed.get("sender_ref", ""), "format": composed.get("format", "mls1"), "gate_envelope": composed.get("gate_envelope", ""), "reply_to": reply_to, }, ) @app.post("/api/wormhole/gate/message/decrypt", dependencies=[Depends(require_local_operator)]) @limiter.limit("60/minute") async def api_wormhole_gate_message_decrypt(request: Request, body: WormholeGateDecryptRequest): payload_format = str(body.format or "mls1").strip().lower() # format field is trusted here because it originates from the Infonet chain event, # not from arbitrary client input. gate_id = str(body.gate_id or "") if payload_format != "mls1" and is_gate_mls_locked(gate_id): return { "ok": False, "detail": "gate is locked to MLS format", "gate_id": gate_id, "required_format": "mls1", "current_format": payload_format or "mls1", } return decrypt_gate_message_for_local_identity( gate_id=gate_id, epoch=_safe_int(body.epoch or 0), ciphertext=str(body.ciphertext or ""), nonce=str(body.nonce or ""), sender_ref=str(body.sender_ref or ""), gate_envelope=str(body.gate_envelope or ""), ) @app.post("/api/wormhole/gate/messages/decrypt", dependencies=[Depends(require_local_operator)]) @limiter.limit("60/minute") async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGateDecryptBatchRequest): items = list(body.messages or []) if not items: return {"ok": False, "detail": "messages required", "results": []} if len(items) > 100: return {"ok": False, "detail": "too many messages", "results": []} results: list[dict[str, Any]] = [] for item in items: payload_format = str(item.format or "mls1").strip().lower() gate_id = str(item.gate_id or "") if payload_format != "mls1" and is_gate_mls_locked(gate_id): results.append( { "ok": False, "detail": "gate is locked to MLS format", "gate_id": gate_id, "required_format": "mls1", "current_format": payload_format or "mls1", } ) continue results.append( decrypt_gate_message_for_local_identity( gate_id=gate_id, epoch=_safe_int(item.epoch or 0), ciphertext=str(item.ciphertext or ""), nonce=str(item.nonce or ""), sender_ref=str(item.sender_ref or ""), gate_envelope=str(item.gate_envelope or ""), ) ) return {"ok": True, "results": results} @app.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): proof = _sign_gate_access_proof(str(body.gate_id or "")) if not proof.get("ok"): raise HTTPException(status_code=403, detail=str(proof.get("detail") or "gate_access_proof_failed")) return proof @app.post("/api/wormhole/sign-raw", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_sign_raw(request: Request, body: WormholeSignRawRequest): return sign_wormhole_message(str(body.message or "")) @app.post("/api/wormhole/dm/register-key", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_wormhole_dm_register_key(request: Request): result = register_wormhole_dm_key() if not result.get("ok"): return result prekeys = register_wormhole_prekey_bundle() return {**result, "prekeys_ok": bool(prekeys.get("ok")), "prekey_detail": prekeys} @app.post("/api/wormhole/dm/prekey/register", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_wormhole_dm_prekey_register(request: Request): return register_wormhole_prekey_bundle() @app.post("/api/wormhole/dm/bootstrap-encrypt", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def api_wormhole_dm_bootstrap_encrypt(request: Request, body: WormholeDmBootstrapEncryptRequest): return bootstrap_encrypt_for_peer( peer_id=str(body.peer_id or ""), plaintext=str(body.plaintext or ""), ) @app.post("/api/wormhole/dm/bootstrap-decrypt", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBootstrapDecryptRequest): return bootstrap_decrypt_from_sender( sender_id=str(body.sender_id or ""), ciphertext=str(body.ciphertext or ""), ) @app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest): if _safe_int(body.count or 1, 1) > 1: return issue_wormhole_dm_sender_tokens( recipient_id=str(body.recipient_id or ""), delivery_class=str(body.delivery_class or ""), recipient_token=str(body.recipient_token or ""), count=_safe_int(body.count or 1, 1), ) return issue_wormhole_dm_sender_token( recipient_id=str(body.recipient_id or ""), delivery_class=str(body.delivery_class or ""), recipient_token=str(body.recipient_token or ""), ) @app.post("/api/wormhole/dm/open-seal", dependencies=[Depends(require_admin)]) @limiter.limit("120/minute") async def api_wormhole_dm_open_seal(request: Request, body: WormholeOpenSealRequest): return open_sender_seal( sender_seal=str(body.sender_seal or ""), candidate_dh_pub=str(body.candidate_dh_pub or ""), recipient_id=str(body.recipient_id or ""), expected_msg_id=str(body.expected_msg_id or ""), ) @app.post("/api/wormhole/dm/build-seal", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_build_seal(request: Request, body: WormholeBuildSealRequest): return build_sender_seal( recipient_id=str(body.recipient_id or ""), recipient_dh_pub=str(body.recipient_dh_pub or ""), msg_id=str(body.msg_id or ""), timestamp=_safe_int(body.timestamp or 0), ) @app.post("/api/wormhole/dm/dead-drop-token", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_dead_drop_token(request: Request, body: WormholeDeadDropTokenRequest): return derive_dead_drop_token_pair( peer_id=str(body.peer_id or ""), peer_dh_pub=str(body.peer_dh_pub or ""), ) @app.post("/api/wormhole/dm/pairwise-alias", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def api_wormhole_dm_pairwise_alias(request: Request, body: WormholePairwiseAliasRequest): return issue_pairwise_dm_alias( peer_id=str(body.peer_id or ""), peer_dh_pub=str(body.peer_dh_pub or ""), ) @app.post("/api/wormhole/dm/pairwise-alias/rotate", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def api_wormhole_dm_pairwise_alias_rotate( request: Request, body: WormholePairwiseAliasRotateRequest ): return rotate_pairwise_dm_alias( peer_id=str(body.peer_id or ""), peer_dh_pub=str(body.peer_dh_pub or ""), grace_ms=_safe_int(body.grace_ms or 45_000, 45_000), ) @app.post("/api/wormhole/dm/dead-drop-tokens", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def api_wormhole_dm_dead_drop_tokens(request: Request, body: WormholeDeadDropContactsRequest): return derive_dead_drop_tokens_for_contacts( contacts=list(body.contacts or []), limit=_safe_int(body.limit or 24, 24), ) @app.post("/api/wormhole/dm/sas", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_sas(request: Request, body: WormholeSasRequest): return derive_sas_phrase( peer_id=str(body.peer_id or ""), peer_dh_pub=str(body.peer_dh_pub or ""), words=_safe_int(body.words or 8, 8), ) @app.post("/api/wormhole/dm/encrypt", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_encrypt(request: Request, body: WormholeDmEncryptRequest): return compose_wormhole_dm( peer_id=str(body.peer_id or ""), peer_dh_pub=str(body.peer_dh_pub or ""), plaintext=str(body.plaintext or ""), local_alias=body.local_alias, remote_alias=body.remote_alias, remote_prekey_bundle=dict(body.remote_prekey_bundle or {}), ) @app.post("/api/wormhole/dm/compose", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_compose(request: Request, body: WormholeDmComposeRequest): return compose_wormhole_dm( peer_id=str(body.peer_id or ""), peer_dh_pub=str(body.peer_dh_pub or ""), plaintext=str(body.plaintext or ""), local_alias=body.local_alias, remote_alias=body.remote_alias, remote_prekey_bundle=dict(body.remote_prekey_bundle or {}), ) @app.post("/api/wormhole/dm/decrypt", dependencies=[Depends(require_admin)]) @limiter.limit("120/minute") async def api_wormhole_dm_decrypt(request: Request, body: WormholeDmDecryptRequest): return decrypt_wormhole_dm_envelope( peer_id=str(body.peer_id or ""), ciphertext=str(body.ciphertext or ""), payload_format=str(body.format or "dm1"), nonce=str(body.nonce or ""), local_alias=body.local_alias, remote_alias=body.remote_alias, session_welcome=body.session_welcome, ) @app.post("/api/wormhole/dm/reset", dependencies=[Depends(require_admin)]) @limiter.limit("30/minute") async def api_wormhole_dm_reset(request: Request, body: WormholeDmResetRequest): return reset_wormhole_dm_ratchet( peer_id=str(body.peer_id or "").strip() or None, ) @app.get("/api/wormhole/dm/contacts", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_contacts(request: Request): from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts try: return {"ok": True, "contacts": list_wormhole_dm_contacts()} except Exception as exc: logger.exception("wormhole dm contacts fetch failed") raise HTTPException(status_code=500, detail="wormhole_dm_contacts_failed") from exc @app.put("/api/wormhole/dm/contact", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_contact_put(request: Request): body = await request.json() peer_id = str(body.get("peer_id", "") or "").strip() updates = body.get("contact", {}) if not peer_id: return {"ok": False, "detail": "peer_id required"} if not isinstance(updates, dict): return {"ok": False, "detail": "contact must be an object"} from services.mesh.mesh_wormhole_contacts import upsert_wormhole_dm_contact try: contact = upsert_wormhole_dm_contact(peer_id, updates) except ValueError as exc: return {"ok": False, "detail": str(exc)} return {"ok": True, "peer_id": peer_id, "contact": contact} @app.delete("/api/wormhole/dm/contact/{peer_id}", dependencies=[Depends(require_admin)]) @limiter.limit("60/minute") async def api_wormhole_dm_contact_delete(request: Request, peer_id: str): from services.mesh.mesh_wormhole_contacts import delete_wormhole_dm_contact deleted = delete_wormhole_dm_contact(peer_id) return {"ok": True, "peer_id": peer_id, "deleted": deleted} _WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"} def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]: if authenticated: return state return {k: v for k, v in state.items() if k in _WORMHOLE_PUBLIC_FIELDS} @app.get("/api/wormhole/status") @limiter.limit("30/minute") async def api_wormhole_status(request: Request): state = await asyncio.to_thread(get_wormhole_state) transport_tier = _current_private_lane_tier(state) if ( transport_tier == "public_degraded" and bool(state.get("arti_ready")) and _is_debug_test_request(request) ): transport_tier = "private_strong" try: _fallback_policy = str(get_settings().MESH_PRIVATE_CLEARNET_FALLBACK or "block").strip().lower() except Exception: _fallback_policy = "block" full_state = { **state, "transport_tier": transport_tier, "clearnet_fallback_policy": _fallback_policy, } ok, _detail = _check_scoped_auth(request, "wormhole") if not ok: ok = _is_debug_test_request(request) return _redact_wormhole_status(full_state, authenticated=ok) @app.get("/api/wormhole/health") @limiter.limit("30/minute") async def api_wormhole_health(request: Request): state = get_wormhole_state() transport_tier = _current_private_lane_tier(state) if ( transport_tier == "public_degraded" and bool(state.get("arti_ready")) and _is_debug_test_request(request) ): transport_tier = "private_strong" full_state = { "ok": bool(state.get("ready")), "transport_tier": transport_tier, **state, } ok, _detail = _check_scoped_auth(request, "wormhole") if not ok: ok = _is_debug_test_request(request) return _redact_wormhole_status(full_state, authenticated=ok) @app.post("/api/wormhole/connect", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_wormhole_connect(request: Request): settings = read_wormhole_settings() if not bool(settings.get("enabled")): write_wormhole_settings(enabled=True) return connect_wormhole(reason="api_connect") @app.post("/api/wormhole/disconnect", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_wormhole_disconnect(request: Request): settings = read_wormhole_settings() if bool(settings.get("enabled")): write_wormhole_settings(enabled=False) return disconnect_wormhole(reason="api_disconnect") @app.post("/api/wormhole/restart", dependencies=[Depends(require_admin)]) @limiter.limit("10/minute") async def api_wormhole_restart(request: Request): settings = read_wormhole_settings() if not bool(settings.get("enabled")): write_wormhole_settings(enabled=True) return restart_wormhole(reason="api_restart") @app.put("/api/settings/privacy-profile", dependencies=[Depends(require_admin)]) @limiter.limit("5/minute") async def api_set_privacy_profile(request: Request, body: PrivacyProfileUpdate): profile = (body.profile or "default").lower() if profile not in ("default", "high"): return Response( content=json_mod.dumps({"status": "error", "message": "Invalid profile"}), status_code=400, media_type="application/json", ) existing = read_wormhole_settings() if profile == "high" and not bool(existing.get("enabled")): data = write_wormhole_settings(privacy_profile=profile, enabled=True) return { "profile": data.get("privacy_profile", profile), "wormhole_enabled": bool(data.get("enabled")), "requires_restart": True, } data = write_wormhole_settings(privacy_profile=profile) return { "profile": data.get("privacy_profile", profile), "wormhole_enabled": bool(data.get("enabled")), "requires_restart": False, } # --------------------------------------------------------------------------- # System — self-update # --------------------------------------------------------------------------- from pathlib import Path from services.updater import perform_update, schedule_restart @app.post("/api/system/update", dependencies=[Depends(require_admin)]) @limiter.limit("1/minute") async def system_update(request: Request): """Download latest release, backup current files, extract update, and restart.""" # In Docker, __file__ is /app/main.py so .parent.parent resolves to / # which causes PermissionError. Use cwd as fallback when parent.parent # doesn't contain frontend/ or backend/ (i.e. we're already at project root). candidate = Path(__file__).resolve().parent.parent if (candidate / "frontend").is_dir() or (candidate / "backend").is_dir(): project_root = str(candidate) else: project_root = os.getcwd() result = perform_update(project_root) if result.get("status") == "error": return Response( content=json_mod.dumps(result), status_code=500, media_type="application/json", ) # Docker: skip restart — user must pull new images manually if result.get("status") == "docker": return result # Schedule restart AFTER response flushes (2s delay) threading.Timer(2.0, schedule_restart, args=[project_root]).start() return result if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True, timeout_keep_alive=120)