Files
Shadowbroker/backend/routers/mesh_public.py
T
Shadowbroker e36d1fc79c [security] Close tg12 audit issues #201–#214 seamlessly (#261)
External security audit by @tg12 (May 17, 2026) filed issues #201–#214
in addition to the #189–#200 batch already closed by PRs #227/#232/#260.
This PR closes all eight that are real security bugs (the other six in
the 201–214 range are either design discussions or upstream-abuse/TOS
concerns we're keeping intentional, see issue triage notes on each).

The user-facing principle for this PR: fix the security gap WITHOUT
introducing a single hostile error or behavior change for legitimate
users. Every fix follows the same template — fail forward, not loud.
When the secure path is harder than the insecure one, build a
fallback chain that ends in graceful degradation, not in a scary
modal or 422 response.

  #205 — OpenMHZ audio redirect SSRF (services/radio_intercept.py)

  Replaced requests.get(..., allow_redirects=True) with a manual
  redirect loop that re-validates each hop's host against
  _OPENMHZ_AUDIO_HOSTS. Same-host redirects (CDN edge selection)
  still work, so legitimate audio playback is unaffected. Cross-host
  redirects to disallowed hosts return a generic 502 which the
  browser audio element handles gracefully. Cap at 5 hops.

  #207 — infonet/status verify_signatures DoS (routers/mesh_public.py)

  Silently downgrade verify_signatures=true to False for
  unauthenticated callers. No error surfaced — the response shape is
  identical, just without the O(n_events) signature verification.
  Authenticated callers (scoped mesh.audit) still get the full path.
  The frontend never passes this param so legitimate UI is unaffected.

  #211 — thermal/verify expensive analysis (routers/sigint.py)

  Added Depends(require_local_operator). Frontend has no direct
  callers (verified by grep); Tauri/AI agents use scoped tokens that
  pass the auth check. Anonymous abusers blocked silently — the
  legitimate UI keeps working through the Next.js admin-key proxy.

  #213, #214 — OpenMHZ calls/audio upstream abuse (routers/radio.py)

  Added Depends(require_local_operator) to both. Browser users hit
  these through the Next.js proxy at src/app/api/[...path]/route.ts
  which injects X-Admin-Key, so the auth check passes transparently.
  Direct attackers can no longer rotate sys_names to hammer
  api.openmhz.com or relay arbitrary audio streams through the
  backend's bandwidth.

  #202 — overflights unbounded hours (routers/data.py)

  Silently clamp `hours` to OVERFLIGHTS_MAX_HOURS (default 72,
  configurable). NO 422 — clients asking for an absurd window get a
  shorter window back with `requested_hours` and `effective_hours`
  hint fields. Postel's law: liberal in what we accept, conservative
  in what we compute.

  #203 — Meshtastic callsign UA leak (services/fetchers/meshtastic_map.py)

  Added MESHTASTIC_SEND_CALLSIGN_HEADER opt-out env var. Default is
  TRUE — preserves existing operator behavior (callsign sent so
  meshtastic.org can rate-limit per-install). Privacy-conscious
  operators set it to false to suppress.

  #206 — KiwiSDR upstream is HTTP-only (services/kiwisdr_fetcher.py)

  Upstream rx.linkfanel.net doesn't speak HTTPS (verified — Apache
  2.4.10 only on port 80). We can't fix the transport. Instead added
  three layers:
    1. Content validation on fetched data — reject responses with
       <50 receivers or >5% malformed entries (likely MITM injection).
    2. Existing disk cache fallback (already present).
    3. NEW: bundled static directory at backend/data/kiwisdr_directory.json
       shipping 798 known-good receivers. Used as last resort so the
       KiwiSDR map layer always renders something useful.

  #208 — Merkle proof DoS via /api/mesh/infonet/sync (services/mesh/mesh_hashchain.py)

  The endpoint is part of the cross-node federation protocol — peers
  legitimately call it without local-operator auth, so we can't add
  Depends(). Instead made the underlying operation O(1) per proof
  via a cached Merkle level structure on the Infonet instance:
    - _merkle_levels_cache + _merkle_levels_for_event_count on each
      Infonet instance
    - _invalidate_merkle_cache() called from every chain mutation
      point (append, ingest_events, apply_fork, cleanup_expired)
    - _get_merkle_levels() does the lazy recompute on first read
      after invalidation, then serves from cache thereafter
  Effect: anonymous attackers hammering the proofs endpoint hit a
  cached structure; the rebuild happens at most once per real chain
  advance. Federation untouched.

  #201 — Tor bundle SHA-256 bypass (services/tor_hidden_service.py)

  Docker users were already covered — backend/Dockerfile installs
  Tor via apt-get at build time (signed by Debian's package system).
  No runtime download needed for the 80%-of-users case.

  For Tauri desktop, replaced the single .sha256sum check with a
  multi-source verification chain implemented in _verify_tor_bundle():
    1. Try upstream .sha256sum (current behavior — fast path)
    2. Try baked-in digest list at backend/data/tor_bundle_digests.json
       (pinned per-version, maintainer-updated)
    3. If neither source is REACHABLE: HTTPS-only fallback with a loud
       warning (avoids breaking first-run onboarding while the
       maintainer hasn't yet pinned a new Tor release)
  A mismatch from a source that DID respond is always fatal — only
  the "no source reachable" case falls back to HTTPS-only. This is
  the "have cake and eat it" pattern: real users see no new failure
  modes during torproject.org outages, but MITM/compromise attacks
  still fail because the downloaded digest can't match what BOTH
  the upstream and the baked-in list report.

  Currently the digest file ships with placeholder values for the
  current Tor URLs (those URLs are already stale on torproject.org
  too). A follow-up commit can populate real digests when a stable
  Tor release is selected; until then the HTTPS-only warning fires
  and onboarding still works.

Tests (82 total, all passing):
  test_openmhz_redirect_ssrf.py        (5 tests)  — #205
  test_infonet_status_verify_gate.py   (2 tests)  — #207
  test_overflights_clamp.py            (5 tests)  — #202
  test_meshtastic_callsign_optout.py   (3 tests)  — #203
  test_kiwisdr_fallback.py             (6 tests)  — #206
  test_merkle_cache.py                 (6 tests)  — #208
  test_tor_bundle_verification.py      (6 tests)  — #201
  test_control_surface_auth.py         (extended) — #211, #213, #214
  + all previous security tests (CCTV redirect, GDELT https, sentinel
    cache, crowdthreat opt-in, third-party fetcher gates, control
    surface auth) continue to pass.

Pre-existing test infrastructure issue with SHARED_EXECUTOR teardown
in the broader sweep exists on main too (verified) — not introduced
by this PR.

Credit: @tg12 reported every one of these with accurate line citations
and the recommended fixes that informed this implementation.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:57:06 -06:00

2339 lines
85 KiB
Python

import asyncio
import base64
import hashlib as _hashlib_mod
import json as json_mod
import logging
import math
import secrets
import time
from typing import Any
from cachetools import TTLCache
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, StreamingResponse
from auth import (
_private_plane_access_denied_payload,
_private_plane_refusal_response,
_private_infonet_policy_snapshot,
require_admin,
require_local_operator,
)
from limiter import limiter
# ---------------------------------------------------------------------------
# Transition delegates: forward to main.py so test monkeypatches still work.
# These will move to a shared module once main.py routes are removed.
# ---------------------------------------------------------------------------
def _main_delegate(name):
def _wrapper(*a, **kw):
import main as _m
return getattr(_m, name)(*a, **kw)
_wrapper.__name__ = name
return _wrapper
_check_scoped_auth = _main_delegate("_check_scoped_auth")
_current_private_lane_tier = _main_delegate("_current_private_lane_tier")
_is_debug_test_request = _main_delegate("_is_debug_test_request")
_scoped_view_authenticated = _main_delegate("_scoped_view_authenticated")
_node_runtime_snapshot = _main_delegate("_node_runtime_snapshot")
_verify_gate_access_main = _main_delegate("_verify_gate_access")
from services.config import get_settings
from services.data_fetcher import get_latest_data
from services.mesh.mesh_crypto import (
derive_node_id,
normalize_peer_url,
parse_public_key_algo,
)
from services.mesh.mesh_protocol import (
PROTOCOL_VERSION,
normalize_payload,
)
from services.mesh.mesh_schema import validate_event_payload
from services.mesh.mesh_signed_events import (
MeshWriteExemption,
SignedWriteKind,
get_prepared_signed_write,
mesh_write_exempt,
requires_signed_write,
verify_key_rotation_claim_signature,
verify_node_bound_signature,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _signed_body(request: Request) -> dict[str, Any]:
prepared = get_prepared_signed_write(request)
if prepared is None:
return {}
return dict(prepared.body)
# --- Public mesh log helpers ---
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)
# --- Constants ---
_PRIVATE_LANE_CONTROL_FIELDS = {"private_lane_tier", "private_lane_policy"}
_PUBLIC_RNS_STATUS_FIELDS = {"enabled", "ready", "configured_peers", "active_peers"}
# --- Gate timestamp redaction ---
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)))
# --- Status/lane redaction helpers ---
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),
}
# --- Node history redaction ---
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
]
# --- Composed gate message redaction ---
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
# --- Gate validation and access helpers ---
_validate_gate_vote_context = _main_delegate("_validate_gate_vote_context")
_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 _trusted_gate_reply_to(event: dict) -> str:
if not isinstance(event, dict):
return ""
payload = event.get("payload")
if not isinstance(payload, dict):
return ""
reply_to = str(payload.get("reply_to", "") or "").strip()
if not reply_to:
return ""
gate_id = str(payload.get("gate", "") or "").strip()
node_id = str(event.get("node_id", "") or "").strip()
public_key = str(event.get("public_key", "") or "").strip()
public_key_algo = str(event.get("public_key_algo", "") or "").strip()
if node_id and not public_key and gate_id:
try:
binding = _lookup_gate_member_binding(gate_id, node_id)
if binding:
public_key, public_key_algo = binding
except Exception:
return ""
signature = str(event.get("signature", "") or "").strip()
protocol_version = str(event.get("protocol_version", "") or "").strip()
sequence = int(event.get("sequence", 0) or 0)
if not (gate_id and node_id and public_key and public_key_algo and signature and protocol_version and sequence > 0):
return ""
verify_payload = {
"gate": gate_id,
"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"),
}
epoch = _safe_int(payload.get("epoch", 0) or 0)
if epoch > 0:
verify_payload["epoch"] = epoch
envelope_hash = str(payload.get("envelope_hash", "") or "").strip()
if envelope_hash:
verify_payload["envelope_hash"] = envelope_hash
return _recover_verified_gate_reply_to(
node_id=node_id,
sequence=sequence,
public_key=public_key,
public_key_algo=public_key_algo,
signature=signature,
payload=verify_payload,
reply_to=reply_to,
protocol_version=protocol_version,
)
def _derive_anon_handle_router(node_id: str, gate_id: str) -> str:
"""HMAC(node_id, gate_id)[:4] — stable session handle, rotates with session."""
import hmac as _hmac, hashlib as _hashlib
node_key = str(node_id or "").strip()
gate_key = str(gate_id or "").strip().lower()
if not node_key:
return "anon_????"
tag = _hmac.new(
node_key.encode("utf-8"),
f"{gate_key}|sender-handle-v1".encode("utf-8"),
_hashlib.sha256,
).hexdigest()[:4]
return f"anon_{tag}"
def _strip_gate_identity_member(event: dict, *, envelope_policy: str = "envelope_disabled") -> dict:
"""Narrowed member view: strips signer identity fields.
Includes ``sender_handle`` (stable per-session anonymized display label)
and the ``gate_envelope`` / ``envelope_hash`` fields members need to
decrypt durable history via the AES-GCM envelope under gate_secret.
"""
if not isinstance(event, dict):
event = {}
payload = event.get("payload")
if not isinstance(payload, dict):
payload = {}
gate_id = str(payload.get("gate", "") or "")
sender_handle = _derive_anon_handle_router(str(event.get("node_id", "") or ""), gate_id)
result_payload: dict = {
"gate": gate_id,
"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 ""),
"sender_handle": sender_handle,
"transport_lock": str(payload.get("transport_lock", "") or ""),
"gate_envelope": str(payload.get("gate_envelope", "") or ""),
"envelope_hash": str(payload.get("envelope_hash", "") or ""),
"reply_to": _trusted_gate_reply_to(event),
}
return {
"event_id": str(event.get("event_id", "") or ""),
"event_type": "gate_message",
"timestamp": _redacted_gate_timestamp(event),
"protocol_version": str(event.get("protocol_version", "") or ""),
"sender_handle": sender_handle,
"payload": result_payload,
}
def _strip_gate_identity_privileged(event: dict) -> dict:
"""Privileged/audit view: preserves full signer identity surface."""
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 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 ""),
"transport_lock": str(payload.get("transport_lock", "") or ""),
"gate_envelope": str(payload.get("gate_envelope", "") or ""),
"envelope_hash": str(payload.get("envelope_hash", "") or ""),
"reply_to": _trusted_gate_reply_to(event),
},
}
def _strip_gate_identity(event: dict) -> dict:
"""Legacy alias — defaults to member (narrowed) view."""
return _strip_gate_identity_member(event)
def _resolve_envelope_policy(gate_id: str) -> str:
"""Look up envelope_policy for a gate. Per-gate policy is the source of
truth; the global recovery-envelope runtime gate is intentionally NOT
checked here — it silently downgrades working configurations to
envelope_disabled without surfacing any error."""
try:
from services.mesh.mesh_reputation import gate_manager
return str(gate_manager.get_envelope_policy(gate_id) or "envelope_disabled")
except Exception:
return "envelope_disabled"
def _strip_gate_for_access(event: dict, access: str) -> dict:
"""Select member or privileged strip based on access level."""
if access == "privileged":
return _strip_gate_identity_privileged(event)
payload = event.get("payload") if isinstance(event, dict) else None
gate_id = str((payload or {}).get("gate", "") or "")
envelope_policy = _resolve_envelope_policy(gate_id) if gate_id else "envelope_disabled"
return _strip_gate_identity_member(event, envelope_policy=envelope_policy)
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
_resolve_gate_proof_identity = _main_delegate("_resolve_gate_proof_identity")
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) -> str:
"""Delegate gate access policy to main.py so the runtime seam stays singular."""
return str(_verify_gate_access_main(request, gate_id) or "")
# --- Throttle state ---
# ─── 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}
# --- Throttle and signed event helpers ---
_check_throttle = _main_delegate("_check_throttle")
_check_gate_post_cooldown = _main_delegate("_check_gate_post_cooldown")
_record_gate_post_cooldown = _main_delegate("_record_gate_post_cooldown")
_recover_verified_gate_reply_to = _main_delegate("_recover_verified_gate_reply_to")
_verify_gate_message_signed_write = _main_delegate("_verify_gate_message_signed_write")
_verify_signed_write = _main_delegate("_verify_signed_write")
# --- Gate store hydration ---
def _hydrate_gate_store_from_chain(events: list[dict]) -> int:
"""Copy any gate_message chain events into the local gate_store for read/decrypt.
Only events that are resident in the local infonet (accepted or already
present) are hydrated. The canonical infonet-resident event is used —
never the raw batch event — so a forged batch entry carrying a valid
event_id but attacker-chosen payload cannot pollute gate_store.
"""
import copy
from services.mesh.mesh_hashchain import gate_store, infonet
count = 0
for evt in events:
if evt.get("event_type") != "gate_message":
continue
event_id = str(evt.get("event_id", "") or "").strip()
if not event_id or event_id not in infonet.event_index:
continue
canonical = infonet.events[infonet.event_index[event_id]]
payload = canonical.get("payload") or {}
gate_id = str(payload.get("gate", "") or "").strip()
if not gate_id:
continue
try:
gate_store.append(gate_id, copy.deepcopy(canonical))
count += 1
except Exception:
pass
return count
# --- Safe type helpers ---
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
# --- Route handlers ---
@router.post("/api/mesh/send")
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_SEND)
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 = _signed_body(request)
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"))
# 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 channel broadcasts into the bridge directly.
# Node-targeted packets must not appear in the public channel feed.
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
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": "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,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results],
}
@router.post("/api/mesh/meshtastic/send", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
async def meshtastic_public_send(request: Request):
"""Local public-MQTT send path for standalone Meshtastic-style identities."""
body = await request.json()
destination = str(body.get("destination", "") or "").strip() or "broadcast"
message = str(body.get("message", "") or "")
sender_id = str(body.get("sender_id", "") or "").strip().lower()
if not message:
return {"ok": False, "detail": "Missing required field: message"}
from services.mesh.mesh_router import (
MeshEnvelope,
MeshtasticTransport,
Priority,
TransportResult,
mesh_router,
)
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
if MeshtasticTransport._parse_node_id(sender_id) is None:
return {"ok": False, "detail": "Missing or invalid public Meshtastic address"}
if not mqtt_bridge_enabled():
return {"ok": False, "detail": "Meshtastic MQTT bridge is disabled"}
payload_bytes = len(message.encode("utf-8"))
payload_type = str(body.get("payload_type", "text") or "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.",
}
priority_str = str(body.get("priority", "normal") or "normal").lower()
throttle_ok, throttle_reason = _check_throttle(sender_id, priority_str, "meshtastic")
if not throttle_ok:
return {"ok": False, "detail": throttle_reason}
priority_map = {
"emergency": Priority.EMERGENCY,
"high": Priority.HIGH,
"normal": Priority.NORMAL,
"low": Priority.LOW,
}
priority = priority_map.get(priority_str, Priority.NORMAL)
envelope = MeshEnvelope(
sender_id=sender_id,
destination=destination,
channel=str(body.get("channel", "LongFast") or "LongFast"),
priority=priority,
payload=message,
ephemeral=bool(body.get("ephemeral", False)),
trust_tier="public_degraded",
)
if 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:
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
envelope.route_reason = (
"Local public Meshtastic MQTT path"
if not is_direct_destination
else "Local public Meshtastic direct node path"
)
credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")}
result = mesh_router.meshtastic.send(envelope, credentials)
if result.ok:
envelope.routed_via = mesh_router.meshtastic.NAME
results = [result]
any_ok = any(r.ok for r in results)
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
try:
from datetime import datetime
from services.sigint_bridge import sigint_grid
bridge = sigint_grid.mesh
if bridge:
record = {
"from": MeshtasticTransport.mesh_address_for_sender(sender_id),
"to": "broadcast",
"text": message,
"region": str(body.get("mesh_region", "US") or "US"),
"root": str(body.get("mesh_region", "US") or "US"),
"channel": str(body.get("channel", "LongFast") or "LongFast"),
"timestamp": datetime.utcnow().isoformat() + "Z",
}
append_text = getattr(bridge, "append_text_message", None)
if callable(append_text):
append_text(record)
else:
bridge.messages.appendleft(record)
except Exception:
pass
return {
"ok": any_ok,
"message_id": envelope.message_id,
"event_id": "",
"routed_via": envelope.routed_via,
"route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results],
}
@router.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}
@router.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)
@router.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,
}
@router.get("/api/mesh/messages")
@limiter.limit("30/minute")
async def mesh_messages(
request: Request,
region: str = "",
root: str = "",
channel: str = "",
limit: int = 30,
include_direct: bool = False,
):
"""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()]
if not include_direct:
msgs = [
m
for m in msgs
if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"}
]
return msgs[: min(limit, 100)]
@router.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 ""
@router.post("/api/mesh/vote")
@limiter.limit("30/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_VOTE)
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 = _signed_body(request)
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}
# 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)}
@router.post("/api/mesh/report")
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_REPORT)
async def mesh_report(request: Request):
"""Report abusive or fraudulent behavior (signed, public, non-anonymous)."""
body = _signed_body(request)
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}
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"}
@router.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"),
)
@router.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
},
}
@router.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()}
@router.post("/api/mesh/identity/rotate")
@limiter.limit("5/minute")
@requires_signed_write(kind=SignedWriteKind.IDENTITY_ROTATE)
async def mesh_identity_rotate(request: Request):
"""Link a new node_id to an old one via dual-signature rotation."""
body = _signed_body(request)
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,
}
old_sig_ok, old_sig_reason = verify_key_rotation_claim_signature(
old_node_id=old_node_id,
old_public_key=old_public_key,
old_public_key_algo=old_public_key_algo,
old_signature=old_signature,
new_public_key=new_public_key,
new_public_key_algo=new_public_key_algo,
timestamp=timestamp,
)
if not old_sig_ok:
return {"ok": False, "detail": old_sig_reason}
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"}
@router.post("/api/mesh/identity/revoke")
@limiter.limit("5/minute")
@requires_signed_write(kind=SignedWriteKind.IDENTITY_REVOKE)
async def mesh_identity_revoke(request: Request):
"""Revoke a node's key with a grace window."""
body = _signed_body(request)
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,
}
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 ───────────────────────────────────────────────────────
@router.post("/api/mesh/gate/create")
@limiter.limit("5/hour")
@requires_signed_write(kind=SignedWriteKind.GATE_CREATE)
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 = _signed_body(request)
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}
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}
@router.get("/api/mesh/gate/list")
@limiter.limit("30/minute")
async def gate_list(request: Request):
"""List all known gates (public catalog — secrets are never included)."""
from services.mesh.mesh_reputation import gate_manager
return {"gates": gate_manager.list_gates()}
@router.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
@router.post("/api/mesh/gate/{gate_id}/message")
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.GATE_MESSAGE)
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 = _signed_body(request)
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]:
import main as _m
return _m._submit_gate_message_envelope(request, gate_id, body)
# ─── Infonet Endpoints ───────────────────────────────────────────────────
@router.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.
The ``verify_signatures`` query parameter is honored ONLY when the
caller has authenticated via scoped auth or local-operator credentials.
Verifying every signature in a long chain is O(n_events) work — letting
anonymous callers trigger it is a DoS surface (issue #207). For
anonymous callers we silently fall back to the cheap path; the response
structure is identical so legitimate frontends see no behavior change.
"""
from services.mesh.mesh_hashchain import infonet
from services.wormhole_supervisor import get_wormhole_state
# Silently downgrade for unauthenticated callers — no error surfaced.
authenticated = _scoped_view_authenticated(request, "mesh.audit")
effective_verify_signatures = bool(verify_signatures) and authenticated
info = infonet.get_info()
valid, reason = infonet.validate_chain(verify_signatures=effective_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"] = effective_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=authenticated,
)
@router.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"),
}
@router.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"),
}
@router.post("/api/mesh/infonet/sync")
@limiter.limit("30/minute")
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
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
# Filter out legacy gate_message events — not part of the public sync surface.
events = [_redact_public_event(e) for e in events if e.get("event_type") != "gate_message"]
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
@router.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()
@router.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"),
)
@router.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)
# Filter out legacy gate_message events — not part of the public sync surface.
events = [_redact_public_event(e) for e in events if e.get("event_type") != "gate_message"]
return {
"events": events,
"after_hash": base,
"count": len(events),
"protocol_version": PROTOCOL_VERSION,
}
@router.post("/api/mesh/infonet/ingest", dependencies=[Depends(require_admin)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)
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}
# ---------------------------------------------------------------------------
# Peer Management API — operator endpoints for adding / removing / listing
# peers without editing peer_store.json by hand.
# ---------------------------------------------------------------------------
@router.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],
}
@router.post("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
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}
@router.delete("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
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}
@router.patch("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
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)}
@router.put("/api/mesh/gate/{gate_id}/envelope_policy")
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)
async def set_gate_envelope_policy(request: Request, gate_id: str):
"""Set the envelope_policy for a gate. Requires gate admin scope."""
ok, detail = _check_scoped_auth(request, "gate")
if not ok:
return Response(
content='{"ok":false,"detail":"Gate admin scope required"}',
status_code=403,
media_type="application/json",
)
try:
body = await request.json()
except Exception:
return {"ok": False, "detail": "Invalid JSON body"}
policy = str(body.get("envelope_policy", "") or "").strip()
acknowledge_recovery_risk = bool(body.get("acknowledge_recovery_risk", False))
from services.mesh.mesh_reputation import gate_manager, VALID_ENVELOPE_POLICIES
if policy not in VALID_ENVELOPE_POLICIES:
return {"ok": False, "detail": f"Invalid policy: must be one of {VALID_ENVELOPE_POLICIES}"}
success, msg = gate_manager.set_envelope_policy(
gate_id,
policy,
acknowledge_recovery_risk=acknowledge_recovery_risk,
)
return {"ok": success, "detail": msg}
@router.put("/api/mesh/gate/{gate_id}/legacy_envelope_fallback")
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)
async def set_gate_legacy_envelope_fallback(request: Request, gate_id: str):
"""Set legacy_envelope_fallback for a gate. Requires gate admin scope."""
ok, detail = _check_scoped_auth(request, "gate")
if not ok:
return Response(
content='{"ok":false,"detail":"Gate admin scope required"}',
status_code=403,
media_type="application/json",
)
try:
body = await request.json()
except Exception:
return {"ok": False, "detail": "Invalid JSON body"}
raw = body.get("legacy_envelope_fallback")
acknowledge_legacy_risk = body.get("acknowledge_legacy_risk", False)
if raw is None or not isinstance(raw, bool):
return {"ok": False, "detail": "legacy_envelope_fallback must be a boolean"}
if acknowledge_legacy_risk is not None and not isinstance(acknowledge_legacy_risk, bool):
return {"ok": False, "detail": "acknowledge_legacy_risk must be a boolean"}
from services.mesh.mesh_reputation import gate_manager
success, msg = gate_manager.set_legacy_envelope_fallback(
gate_id,
raw,
acknowledge_legacy_risk=bool(acknowledge_legacy_risk),
)
return {"ok": success, "detail": msg}
@router.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."""
access = _verify_gate_access(request, gate_id)
if not access:
return await _private_plane_refusal_response(
request,
status_code=403,
payload=_private_plane_access_denied_payload(),
)
return _build_gate_message_response(gate_id, access, limit=limit, offset=offset)
def _build_gate_message_response(
gate_id: str,
access: str,
*,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
from services.mesh.mesh_hashchain import gate_store
from services.mesh.mesh_reputation import gate_manager
raw_messages, cursor = gate_store.get_messages_with_cursor(gate_id, limit=limit, offset=offset)
safe_messages = [_strip_gate_for_access(m, access) for m in raw_messages]
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, "cursor": cursor}
def _gate_session_stream_enabled() -> bool:
try:
return bool(get_settings().MESH_GATE_SESSION_STREAM_ENABLED)
except Exception:
return False
def _gate_session_stream_heartbeat_s() -> int:
try:
return max(1, int(get_settings().MESH_GATE_SESSION_STREAM_HEARTBEAT_S or 20))
except Exception:
return 20
def _gate_session_stream_batch_ms() -> int:
try:
return max(250, int(get_settings().MESH_GATE_SESSION_STREAM_BATCH_MS or 1500))
except Exception:
return 1500
def _gate_session_stream_max_gates() -> int:
try:
return max(1, int(get_settings().MESH_GATE_SESSION_STREAM_MAX_GATES or 16))
except Exception:
return 16
def _normalize_gate_session_stream_gates(raw: str, limit: int) -> list[str]:
seen: set[str] = set()
normalized: list[str] = []
for gate_id in str(raw or "").split(","):
candidate = str(gate_id or "").strip().lower()
if not candidate or candidate in seen:
continue
seen.add(candidate)
normalized.append(candidate)
if len(normalized) >= limit:
break
return normalized
def _format_gate_session_stream_event(event: str, data: dict[str, Any]) -> str:
payload = json_mod.dumps(data, default=str)
return f"event: {event}\ndata: {payload}\n\n"
def _build_gate_session_stream_gate_access(gate_id: str) -> dict[str, Any] | None:
proof = _sign_gate_access_proof(gate_id)
if not proof.get("ok"):
return None
node_id = str(proof.get("node_id") or "").strip()
gate_proof = str(proof.get("proof") or "").strip()
gate_ts = str(proof.get("ts") or "").strip()
if not node_id or not gate_proof or not gate_ts:
return None
return {
"node_id": node_id,
"proof": gate_proof,
"ts": gate_ts,
}
def _build_gate_session_stream_gate_key_status(gate_id: str) -> dict[str, Any]:
from services.mesh.mesh_gate_mls import get_local_gate_key_status
status = get_local_gate_key_status(gate_id)
if not isinstance(status, dict):
return {"ok": False, "gate_id": gate_id, "detail": "gate_key_status_unavailable"}
return dict(status)
@router.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 infonet
if gate:
access = _verify_gate_access(request, gate)
if not access:
return await _private_plane_refusal_response(
request,
status_code=403,
payload=_private_plane_access_denied_payload(),
)
return _build_gate_message_response(gate, access, 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]
return {"messages": messages, "count": len(messages), "gate": gate or "all", "cursor": 0}
@router.get("/api/mesh/infonet/messages/wait")
@limiter.limit("60/minute")
async def infonet_messages_wait(
request: Request,
gate: str = "",
after: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
timeout_ms: int = Query(25_000, ge=1_000, le=90_000),
):
"""Wait for gate message changes, then return the latest gate view."""
gate_id = str(gate or "").strip().lower()
if not gate_id:
return Response(
content='{"ok":false,"detail":"gate required"}',
status_code=400,
media_type="application/json",
)
access = _verify_gate_access(request, gate_id)
if not access:
return await _private_plane_refusal_response(
request,
status_code=403,
payload=_private_plane_access_denied_payload(),
)
from services.mesh.mesh_hashchain import gate_store
changed, _cursor = await asyncio.to_thread(
gate_store.wait_for_gate_change,
gate_id,
after,
timeout_ms / 1000.0,
)
payload = _build_gate_message_response(gate_id, access, limit=limit, offset=0)
payload["changed"] = bool(changed)
return payload
@router.get("/api/mesh/infonet/session-stream", dependencies=[Depends(require_admin)])
@limiter.limit("30/minute")
async def infonet_session_stream(
request: Request,
gates: str = Query(""),
):
"""Feature-flagged session-level gate stream for multiplexed room updates.
Current behavior:
- admin-gated control-plane access
- immediate hello event with normalized subscriptions and gate bootstrap context
- gate_update events for subscribed rooms
- coarse heartbeats and reconnect-friendly session state
"""
if not _gate_session_stream_enabled():
return JSONResponse(
status_code=404,
content={"ok": False, "detail": "gate_session_stream_disabled"},
)
heartbeat_s = _gate_session_stream_heartbeat_s()
batch_ms = _gate_session_stream_batch_ms()
max_gates = _gate_session_stream_max_gates()
subscriptions = _normalize_gate_session_stream_gates(gates, max_gates)
session_id = secrets.token_hex(8)
from services.mesh.mesh_hashchain import gate_store
cursors = {
gate_id: gate_store.gate_cursor(gate_id)
for gate_id in subscriptions
}
gate_access = {
gate_id: access
for gate_id in subscriptions
for access in [_build_gate_session_stream_gate_access(gate_id)]
if access
}
gate_key_status = {
gate_id: _build_gate_session_stream_gate_key_status(gate_id)
for gate_id in subscriptions
}
async def event_stream():
try:
yield _format_gate_session_stream_event(
"hello",
{
"ok": True,
"mode": "skeleton",
"transport": "sse",
"session_id": session_id,
"subscriptions": subscriptions,
"cursors": cursors,
"gate_access": gate_access,
"gate_key_status": gate_key_status,
"heartbeat_s": heartbeat_s,
"batch_ms": batch_ms,
},
)
last_heartbeat = time.monotonic()
while True:
if await request.is_disconnected():
break
updates = await asyncio.to_thread(
gate_store.wait_for_any_gate_change,
cursors,
batch_ms / 1000.0,
)
if await request.is_disconnected():
break
if updates:
update_list = [
{"gate_id": gate_id, "cursor": cursor}
for gate_id, cursor in sorted(updates.items())
]
cursors.update({gate_id: cursor for gate_id, cursor in updates.items()})
yield _format_gate_session_stream_event(
"gate_update",
{
"session_id": session_id,
"updates": update_list,
"ts": int(time.time()),
},
)
now = time.monotonic()
if now - last_heartbeat >= heartbeat_s:
yield _format_gate_session_stream_event(
"heartbeat",
{
"session_id": session_id,
"ts": int(time.time()),
},
)
last_heartbeat = now
except asyncio.CancelledError:
pass
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.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()
access = _verify_gate_access(request, gate_id) if gate_id else ""
if not gate_id or not access:
return await _private_plane_refusal_response(
request,
status_code=403,
payload=_private_plane_access_denied_payload(),
)
return _strip_gate_for_access(evt, access)
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()
access = _verify_gate_access(request, gate_id) if gate_id else ""
if not gate_id or not access:
return await _private_plane_refusal_response(
request,
status_code=403,
payload=_private_plane_access_denied_payload(),
)
return _strip_gate_for_access(evt, access)
return _redact_public_event(infonet.decorate_event(evt))
@router.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}
@router.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",
}