mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 10:01:31 +02:00
e36d1fc79c
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>
2339 lines
85 KiB
Python
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",
|
|
}
|
|
|