Expose AISHub as an optional ships-layer backup when AISStream is silent.

Wire AISHUB_USERNAME into API Keys and onboarding, and surface backup status on the AIS outage banner so operators can keep vessel coverage without changing the AISStream proxy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-08-09 18:56:30 -06:00
co-authored by Cursor
parent 00f02c2687
commit dc0f69a0b9
12 changed files with 194 additions and 109 deletions
+4
View File
@@ -10,6 +10,10 @@ OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET= OPENSKY_CLIENT_SECRET=
AIS_API_KEY= AIS_API_KEY=
# Optional AISHub REST backup when AISStream is silent/offline (same ships layer, ~20 min cadence).
# Free registration at https://www.aishub.net/api — paste the account username (not a password).
# AISHUB_USERNAME=
# Global Fishing Watch — fishing vessel activity events (Fishing Activity map layer). # Global Fishing Watch — fishing vessel activity events (Fishing Activity map layer).
# Free API token from https://globalfishingwatch.org/our-apis/tokens # Free API token from https://globalfishingwatch.org/our-apis/tokens
# Without this the fishing_activity layer stays empty. # Without this the fishing_activity layer stays empty.
+82 -81
View File
@@ -1,4 +1,4 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -139,8 +139,8 @@ def _check_explicit_scoped_auth_local(
if not admin_key and not scoped_tokens: if not admin_key and not scoped_tokens:
if _allow_insecure_admin() or (_debug_mode_enabled() and host == "test"): if _allow_insecure_admin() or (_debug_mode_enabled() and host == "test"):
return True, "ok", "debug_override" return True, "ok", "debug_override"
return False, "Forbidden admin key not configured", "" return False, "Forbidden ΓÇö admin key not configured", ""
return False, "Forbidden invalid or missing admin key", "" return False, "Forbidden ΓÇö invalid or missing admin key", ""
def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]: def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
@@ -164,10 +164,11 @@ def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
# Docker Swarm Secrets support # Docker Swarm Secrets support
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY), # For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
# the file is read and its trimmed content is placed into VAR. # the file is read and its trimmed content is placed into VAR.
# This MUST run before service imports — modules read os.environ at import time. # This MUST run before service imports ├óΓé¼ΓÇ¥ modules read os.environ at import time.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_SECRET_VARS = [ _SECRET_VARS = [
"AIS_API_KEY", "AIS_API_KEY",
"AISHUB_USERNAME",
"OPENSKY_CLIENT_ID", "OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET", "OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY", "LTA_ACCOUNT_KEY",
@@ -212,7 +213,7 @@ if not _MESH_ONLY:
from services.ais_stream import start_ais_stream, stop_ais_stream from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
else: else:
# Lean mesh/wormhole process avoid importing the OSINT fetcher graph. # Lean mesh/wormhole process ΓÇö avoid importing the OSINT fetcher graph.
def start_scheduler(*_a, **_k): # type: ignore[misc] def start_scheduler(*_a, **_k): # type: ignore[misc]
return None return None
@@ -1642,7 +1643,7 @@ def _peer_sync_response(peer_url: str, body: dict[str, Any]) -> dict[str, Any]:
# HTTP 429 must be surfaced as a typed exception carrying the # HTTP 429 must be surfaced as a typed exception carrying the
# Retry-After value, so finish_sync can honor it and stop hammering # Retry-After value, so finish_sync can honor it and stop hammering
# the upstream. Pre-fix this path just stringified the status into # the upstream. Pre-fix this path just stringified the status into
# a ValueError, which finish_sync then ignored keeping the # a ValueError, which finish_sync then ignored ΓÇö keeping the
# upstream's rate-limit bucket full indefinitely. # upstream's rate-limit bucket full indefinitely.
if response.status_code == 429: if response.status_code == 429:
from services.mesh.mesh_infonet_sync_support import ( from services.mesh.mesh_infonet_sync_support import (
@@ -1679,8 +1680,8 @@ def _hydrate_gate_store_from_chain(events: list[dict]) -> int:
"""Copy any gate_message chain events into the local gate_store for read/decrypt. """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 Only events that are resident in the local infonet (accepted or already
present) are hydrated. The canonical infonet-resident event is used â present) are hydrated. The canonical infonet-resident event is used óΓé¼ΓÇ¥
never the raw batch event â so a forged batch entry carrying a valid never the raw batch event óΓé¼ΓÇ¥ so a forged batch entry carrying a valid
event_id but attacker-chosen payload cannot pollute gate_store. event_id but attacker-chosen payload cannot pollute gate_store.
""" """
import copy import copy
@@ -1765,7 +1766,7 @@ def _sync_from_peer(
Returns ``(ok, error, forked, retry_after_s)``. The fourth tuple Returns ``(ok, error, forked, retry_after_s)``. The fourth tuple
element is non-zero only when the peer responded with HTTP 429 element is non-zero only when the peer responded with HTTP 429
and supplied a parseable ``Retry-After`` header see the typed and supplied a parseable ``Retry-After`` header ΓÇö see the typed
``PeerSyncRateLimited`` exception in mesh_infonet_sync_support.py. ``PeerSyncRateLimited`` exception in mesh_infonet_sync_support.py.
Callers should pass that value to ``finish_sync(retry_after_s=...)`` Callers should pass that value to ``finish_sync(retry_after_s=...)``
so the next attempt actually waits. so the next attempt actually waits.
@@ -1908,7 +1909,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
except PeerSyncHTTPError as exc: except PeerSyncHTTPError as exc:
# _sync_from_peer catches PeerSyncRateLimited internally (4-tuple # _sync_from_peer catches PeerSyncRateLimited internally (4-tuple
# path for 429 with Retry-After). Other non-200 statuses surface # path for 429 with Retry-After). Other non-200 statuses surface
# here as PeerSyncHTTPError pull retry_after_s + status off it # here as PeerSyncHTTPError ΓÇö pull retry_after_s + status off it
# so the cooldown calculation below can honor server hints even # so the cooldown calculation below can honor server hints even
# for non-429 throttling responses. # for non-429 throttling responses.
ok = False ok = False
@@ -1973,7 +1974,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
failure_backoff_s=failure_backoff_s, failure_backoff_s=failure_backoff_s,
# 429 retry-storm fix: when the peer returned HTTP 429 with # 429 retry-storm fix: when the peer returned HTTP 429 with
# a Retry-After header, finish_sync uses max(exponential, # a Retry-After header, finish_sync uses max(exponential,
# retry_after) for next_sync_due_at so we actually wait # retry_after) for next_sync_due_at ΓÇö so we actually wait
# the time the upstream asked for instead of hammering # the time the upstream asked for instead of hammering
# every 60s and keeping its rate-limit bucket full forever. # every 60s and keeping its rate-limit bucket full forever.
retry_after_s=retry_after_s, retry_after_s=retry_after_s,
@@ -2147,13 +2148,13 @@ def _start_infonet_node_runtime(reason: str = "startup") -> None:
logger.warning(f"Node bootstrap runtime failed to initialize: {e}") logger.warning(f"Node bootstrap runtime failed to initialize: {e}")
# ─── Background HTTP Peer Push Worker ──────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Background HTTP Peer Push Worker ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
# Runs alongside the sync loop. Every PUSH_INTERVAL seconds, batches new # Runs alongside the sync loop. Every PUSH_INTERVAL seconds, batches new
# Infonet events and sends them via HMAC-authenticated POST to push peers. # Infonet events and sends them via HMAC-authenticated POST to push peers.
_PEER_PUSH_INTERVAL_S = 10 _PEER_PUSH_INTERVAL_S = 10
_PEER_PUSH_BATCH_SIZE = 50 _PEER_PUSH_BATCH_SIZE = 50
_peer_push_last_index: dict[str, int] = {} # peer_url → last pushed event index _peer_push_last_index: dict[str, int] = {} # peer_url ├óΓÇáΓÇÖ last pushed event index
_INFONET_SYNC_RATE_LIMIT = "600/minute" _INFONET_SYNC_RATE_LIMIT = "600/minute"
@@ -2173,7 +2174,7 @@ def _http_peer_push_loop() -> None:
# legacy global MESH_PEER_PUSH_SECRET path and the per-peer # legacy global MESH_PEER_PUSH_SECRET path and the per-peer
# MESH_PEER_SECRETS map. The per-peer skip happens below # MESH_PEER_SECRETS map. The per-peer skip happens below
# ("if not peer_key: continue"), so we don't gate the whole # ("if not peer_key: continue"), so we don't gate the whole
# loop on the global secret being set an install that only # loop on the global secret being set ΓÇö an install that only
# configures per-peer secrets is now valid. # configures per-peer secrets is now valid.
peers = _filter_infonet_peer_urls(authenticated_push_peer_urls()) peers = _filter_infonet_peer_urls(authenticated_push_peer_urls())
@@ -2233,7 +2234,7 @@ def _http_peer_push_loop() -> None:
_peer_push_last_index[normalized] = last_idx + len(batch) _peer_push_last_index[normalized] = last_idx + len(batch)
logger.info( logger.info(
f"Pushed {len(batch)} event(s) to {normalized[:40]} " f"Pushed {len(batch)} event(s) to {normalized[:40]} "
f"(idx {last_idx}→{last_idx + len(batch)})" f"(idx {last_idx}├óΓÇáΓÇÖ{last_idx + len(batch)})"
) )
else: else:
logger.warning(f"Peer push to {normalized[:40]} returned {resp.status_code}") logger.warning(f"Peer push to {normalized[:40]} returned {resp.status_code}")
@@ -2261,13 +2262,13 @@ def _swarm_manifest_pull_loop() -> None:
_NODE_SYNC_STOP.wait(max(30, interval_s)) _NODE_SYNC_STOP.wait(max(30, interval_s))
# ─── Background Gate Message Pull Worker ───────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Background Gate Message Pull Worker ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
# Periodically pulls gate events from relay peers that this node is missing. # Periodically pulls gate events from relay peers that this node is missing.
# Complements the push loop: push sends OUR events to peers, pull fetches # Complements the push loop: push sends OUR events to peers, pull fetches
# THEIR events from peers (needed when this node is behind NAT). # THEIR events from peers (needed when this node is behind NAT).
_GATE_PULL_INTERVAL_S = 10 _GATE_PULL_INTERVAL_S = 10
_gate_pull_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → known count} _gate_pull_last_count: dict[str, dict[str, int]] = {} # peer ├óΓÇáΓÇÖ {gate_id ├óΓÇáΓÇÖ known count}
def _http_gate_pull_loop() -> None: def _http_gate_pull_loop() -> None:
@@ -2404,9 +2405,9 @@ def _http_gate_pull_loop() -> None:
# ─── Background Gate Message Push Worker ───────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Background Gate Message Push Worker ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
_gate_push_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → count} _gate_push_last_count: dict[str, dict[str, int]] = {} # peer ├óΓÇáΓÇÖ {gate_id ├óΓÇáΓÇÖ count}
def _http_gate_push_loop() -> None: def _http_gate_push_loop() -> None:
@@ -2749,9 +2750,9 @@ async def lifespan(app: FastAPI):
validate_env(strict=not _MESH_ONLY) validate_env(strict=not _MESH_ONLY)
if _MESH_ONLY: if _MESH_ONLY:
logger.info("MESH_ONLY enabled — skipping global data fetchers/schedulers.") logger.info("MESH_ONLY enabled ├óΓé¼ΓÇ¥ skipping global data fetchers/schedulers.")
else: else:
# Start AIS stream first — it loads the disk cache (instant ships) then # Start AIS stream first ├óΓé¼ΓÇ¥ it loads the disk cache (instant ships) then
# begins accumulating live vessel data via WebSocket in the background. # begins accumulating live vessel data via WebSocket in the background.
start_ais_stream() start_ais_stream()
@@ -2759,7 +2760,7 @@ async def lifespan(app: FastAPI):
# in _scheduler_loop, so we do NOT call it again in the preload thread. # in _scheduler_loop, so we do NOT call it again in the preload thread.
start_carrier_tracker() start_carrier_tracker()
# Start SIGINT grid eagerly — APRS-IS TCP + Meshtastic MQTT connections # Start SIGINT grid eagerly ├óΓé¼ΓÇ¥ APRS-IS TCP + Meshtastic MQTT connections
# take a few seconds to handshake and start receiving packets. By starting # take a few seconds to handshake and start receiving packets. By starting
# now, the bridges are already accumulating signals by the time the first # now, the bridges are already accumulating signals by the time the first
# fetch_sigint() reads them during the preload cycle. # fetch_sigint() reads them during the preload cycle.
@@ -2868,7 +2869,7 @@ async def lifespan(app: FastAPI):
delay_s = float(os.environ.get("SHADOWBROKER_STARTUP_PRELOAD_DELAY_S", "2.0") or 0) delay_s = float(os.environ.get("SHADOWBROKER_STARTUP_PRELOAD_DELAY_S", "2.0") or 0)
if delay_s > 0: if delay_s > 0:
time.sleep(delay_s) time.sleep(delay_s)
logger.info("=== PRELOADING DATA (background — server already accepting requests) ===") logger.info("=== PRELOADING DATA (background ├óΓé¼ΓÇ¥ server already accepting requests) ===")
try: try:
update_all_data(startup_mode=True) update_all_data(startup_mode=True)
logger.info("=== PRELOAD COMPLETE ===") logger.info("=== PRELOAD COMPLETE ===")
@@ -2882,7 +2883,7 @@ async def lifespan(app: FastAPI):
try: try:
from services.tor_hidden_service import tor_service, HOSTNAME_PATH from services.tor_hidden_service import tor_service, HOSTNAME_PATH
if HOSTNAME_PATH.exists(): if HOSTNAME_PATH.exists():
logger.info("Previous Tor hidden service detected — auto-restarting...") logger.info("Previous Tor hidden service detected ├óΓé¼ΓÇ¥ auto-restarting...")
threading.Thread( threading.Thread(
target=tor_service.start, daemon=True target=tor_service.start, daemon=True
).start() ).start()
@@ -3195,9 +3196,9 @@ def _trusted_gate_reply_to(event: dict) -> str:
def _derive_anon_handle(node_id: str, gate_id: str) -> str: def _derive_anon_handle(node_id: str, gate_id: str) -> str:
"""Derive a stable per-session, per-gate anonymous display handle. """Derive a stable per-session, per-gate anonymous display handle.
Same node_id + same gate same handle for every message that session Same node_id + same gate → same handle for every message that session
posts (lets other members follow a conversation thread). Different posts (lets other members follow a conversation thread). Different
session (anon re-enters new node_id) new handle. Different gate session (anon re-enters → new node_id) → new handle. Different gate →
different handle for the same session (prevents cross-gate linking). different handle for the same session (prevents cross-gate linking).
Not reversible: the handle is HMAC-SHA256(node_id, gate_id) truncated Not reversible: the handle is HMAC-SHA256(node_id, gate_id) truncated
to 4 hex chars (~16 bits), which is enough to tell sessions apart in to 4 hex chars (~16 bits), which is enough to tell sessions apart in
@@ -3238,7 +3239,7 @@ def _strip_gate_identity_member(event: dict, *, envelope_policy: str = "envelope
"transport_lock": str(payload.get("transport_lock", "") or ""), "transport_lock": str(payload.get("transport_lock", "") or ""),
# gate_envelope is AES-256-GCM ciphertext encrypted under the gate's # gate_envelope is AES-256-GCM ciphertext encrypted under the gate's
# domain key (gate_secret). Only members who hold the gate_secret # domain key (gate_secret). Only members who hold the gate_secret
# can decrypt it so exposing the ciphertext itself to members is # can decrypt it ΓÇö so exposing the ciphertext itself to members is
# safe, and it's REQUIRED for the envelope_always decrypt path that # safe, and it's REQUIRED for the envelope_always decrypt path that
# gives members durable re-readable history. envelope_hash is the # gives members durable re-readable history. envelope_hash is the
# cryptographic binding (SHA-256 of gate_envelope) the decrypt path # cryptographic binding (SHA-256 of gate_envelope) the decrypt path
@@ -3301,7 +3302,7 @@ def _strip_gate_identity_privileged(event: dict) -> dict:
def _strip_gate_identity(event: dict) -> dict: def _strip_gate_identity(event: dict) -> dict:
"""Legacy alias — defaults to member (narrowed) view.""" """Legacy alias ├óΓé¼ΓÇ¥ defaults to member (narrowed) view."""
return _strip_gate_identity_member(event) return _strip_gate_identity_member(event)
@@ -3483,7 +3484,7 @@ def _verify_gate_access(request: Request, gate_id: str) -> str:
return "" return ""
# ── Non-hostile transport auto-upgrade ──────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Non-hostile transport auto-upgrade ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
# #
# The mesh/wormhole middleware can try to bring the wormhole supervisor # The mesh/wormhole middleware can try to bring the wormhole supervisor
# up in the background when a user hits a tier-gated route on a weak # up in the background when a user hits a tier-gated route on a weak
@@ -3750,8 +3751,8 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
# background and return an ok:True "preparing" response # background and return an ok:True "preparing" response
# (202 Accepted) so the client shows a spinner rather # (202 Accepted) so the client shows a spinner rather
# than an approval dialog. The request itself is NOT # than an approval dialog. The request itself is NOT
# forwarded to the handler the tier is too low for the # forwarded to the handler ΓÇö the tier is too low for the
# route's required privacy but the client can poll and # route's required privacy ΓÇö but the client can poll and
# retry transparently once the lane warms up. # retry transparently once the lane warms up.
try: try:
upgraded = await _try_transparent_transport_upgrade() upgraded = await _try_transparent_transport_upgrade()
@@ -3784,7 +3785,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
data = read_wormhole_settings() data = read_wormhole_settings()
# Tor-style: if the user selected high privacy but Wormhole # Tor-style: if the user selected high privacy but Wormhole
# isn't enabled yet, just turn it on and kick off warmup. # isn't enabled yet, just turn it on and kick off warmup.
# Don't block the request on the upgrade the transport # Don't block the request on the upgrade ΓÇö the transport
# manager will converge in the background. # manager will converge in the background.
if ( if (
private_mesh_path private_mesh_path
@@ -3807,7 +3808,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
or _is_anonymous_dm_action_path(path, request.method) or _is_anonymous_dm_action_path(path, request.method)
or _is_anonymous_wormhole_gate_admin_path(path, request.method) or _is_anonymous_wormhole_gate_admin_path(path, request.method)
): ):
# Tor-style: anonymous mode is on do whatever is required for # Tor-style: anonymous mode is on → do whatever is required for
# it to function. Auto-enable Wormhole if off, and schedule # it to function. Auto-enable Wormhole if off, and schedule
# hidden-transport warmup WITHOUT blocking this request. The # hidden-transport warmup WITHOUT blocking this request. The
# transport manager converges in the background; the user sees # transport manager converges in the background; the user sees
@@ -3971,7 +3972,7 @@ def _queue_viirs_change_refresh() -> None:
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001 async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001
"""Receive frontend map bounds. AIS stream stays global so open-ocean """Receive frontend map bounds. AIS stream stays global so open-ocean
vessels are never dropped â the frontend worker handles viewport culling.""" vessels are never dropped óΓé¼ΓÇ¥ the frontend worker handles viewport culling."""
return {"status": "ok"} return {"status": "ok"}
@@ -4139,7 +4140,7 @@ async def nearest_sdr(
return find_nearest_kiwisdr(lat, lng, kiwisdr_data) return find_nearest_kiwisdr(lat, lng, kiwisdr_data)
# ─── Per-Identity Throttle State ────────────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Per-Identity Throttle State ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
# In-memory: {node_id: {"last_send": timestamp, "daily_count": int, "daily_reset": timestamp}} # 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 # Bounded to 10000 entries with 24hr TTL to prevent unbounded memory growth
_node_throttle: TTLCache = TTLCache(maxsize=10000, ttl=86400) _node_throttle: TTLCache = TTLCache(maxsize=10000, ttl=86400)
@@ -4223,7 +4224,7 @@ def _check_throttle(
def _check_gate_post_cooldown(sender_id: str, gate_id: str) -> tuple[bool, str]: def _check_gate_post_cooldown(sender_id: str, gate_id: str) -> tuple[bool, str]:
"""Check cooldown — does NOT record it. Call _record_gate_post_cooldown() after success.""" """Check cooldown ├óΓé¼ΓÇ¥ does NOT record it. Call _record_gate_post_cooldown() after success."""
gate_key = str(gate_id or "").strip().lower() gate_key = str(gate_id or "").strip().lower()
sender_key = str(sender_id or "").strip() sender_key = str(sender_id or "").strip()
if not gate_key or not sender_key: if not gate_key or not sender_key:
@@ -4425,7 +4426,7 @@ def _prepared_signed_write(request: Request):
@limiter.limit("10/minute") @limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_SEND) @requires_signed_write(kind=SignedWriteKind.MESH_SEND)
async def mesh_send(request: Request): async def mesh_send(request: Request):
"""Unified mesh message endpoint — auto-routes via optimal transport. """Unified mesh message endpoint ├óΓé¼ΓÇ¥ auto-routes via optimal transport.
Body: { destination, message, priority?, channel?, node_id?, credentials? } Body: { destination, message, priority?, channel?, node_id?, credentials? }
The router picks APRS, Meshtastic, or Internet based on gate logic. The router picks APRS, Meshtastic, or Internet based on gate logic.
@@ -4437,7 +4438,7 @@ async def mesh_send(request: Request):
if not destination or not message: if not destination or not message:
return {"ok": False, "detail": "Missing required fields: destination, message"} return {"ok": False, "detail": "Missing required fields: destination, message"}
# ─── Byte limit enforcement ─────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Byte limit enforcement ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
payload_bytes = len(message.encode("utf-8")) payload_bytes = len(message.encode("utf-8"))
payload_type = body.get("payload_type", "text") payload_type = body.get("payload_type", "text")
max_bytes = _BYTE_LIMITS.get(payload_type, 200) max_bytes = _BYTE_LIMITS.get(payload_type, 200)
@@ -4447,7 +4448,7 @@ async def mesh_send(request: Request):
"detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.", "detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.",
} }
# ─── Signature verification & node registration ────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Signature verification & node registration ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
node_id = body.get("node_id", body.get("sender_id", "anonymous")) node_id = body.get("node_id", body.get("sender_id", "anonymous"))
public_key = body.get("public_key", "") public_key = body.get("public_key", "")
public_key_algo = body.get("public_key_algo", "") public_key_algo = body.get("public_key_algo", "")
@@ -4470,9 +4471,9 @@ async def mesh_send(request: Request):
reputation_ledger.register_node(node_id, public_key, public_key_algo) reputation_ledger.register_node(node_id, public_key, public_key_algo)
except Exception: except Exception:
pass # Non-critical — don't block sends if reputation module fails pass # Non-critical ├óΓé¼ΓÇ¥ don't block sends if reputation module fails
# ─── Per-identity throttle ──────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Per-identity throttle ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
priority_str = signed_payload["priority"] priority_str = signed_payload["priority"]
transport_lock = str(body.get("transport_lock", "") or "").lower() transport_lock = str(body.get("transport_lock", "") or "").lower()
throttle_ok, throttle_reason = _check_throttle(node_id, priority_str, transport_lock) throttle_ok, throttle_reason = _check_throttle(node_id, priority_str, transport_lock)
@@ -4495,7 +4496,7 @@ async def mesh_send(request: Request):
} }
priority = priority_map.get(priority_str, Priority.NORMAL) priority = priority_map.get(priority_str, Priority.NORMAL)
# ─── C-1 fix: compute trust_tier from Wormhole state ─────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ C-1 fix: compute trust_tier from Wormhole state ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
from services.wormhole_supervisor import get_transport_tier from services.wormhole_supervisor import get_transport_tier
computed_tier = get_transport_tier() computed_tier = get_transport_tier()
@@ -4511,7 +4512,7 @@ async def mesh_send(request: Request):
) )
credentials = body.get("credentials", {}) credentials = body.get("credentials", {})
# ─── C-2 fix: enforce tier before transport_lock dispatch ── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ C-2 fix: enforce tier before transport_lock dispatch ├óΓÇ¥Γé¼├óΓÇ¥Γé¼
private_tier = str(envelope.trust_tier or "").startswith("private_") private_tier = str(envelope.trust_tier or "").startswith("private_")
if transport_lock == "meshtastic": if transport_lock == "meshtastic":
if private_tier: if private_tier:
@@ -4547,7 +4548,7 @@ async def mesh_send(request: Request):
results = mesh_router.route(envelope, credentials) results = mesh_router.route(envelope, credentials)
any_ok = any(r.ok for r in results) any_ok = any(r.ok for r in results)
# ─── Mirror to Meshtastic bridge feed ──────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Mirror to Meshtastic bridge feed ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
# The MQTT broker won't echo our own publishes back to our subscriber, so # The MQTT broker won't echo our own publishes back to our subscriber, so
# inject successfully-sent channel broadcasts into the bridge directly. # inject successfully-sent channel broadcasts into the bridge directly.
# Node-targeted packets must not appear in the public channel feed. # Node-targeted packets must not appear in the public channel feed.
@@ -4719,14 +4720,14 @@ async def mesh_messages(
@app.get("/api/mesh/channels") @app.get("/api/mesh/channels")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def mesh_channels(request: Request): async def mesh_channels(request: Request):
"""Get Meshtastic channel population stats — nodes per region/channel.""" """Get Meshtastic channel population stats ├óΓé¼ΓÇ¥ nodes per region/channel."""
stats = get_latest_data().get("mesh_channel_stats", {}) stats = get_latest_data().get("mesh_channel_stats", {})
return stats return stats
# ─── Reputation Endpoints ───────────────────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Reputation Endpoints ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
# Cached root node_id — avoids 5 encrypted disk reads per vote. # 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_cache: dict[str, object] = {"value": None, "ts": 0.0}
_ROOT_NODE_ID_TTL = 30.0 # seconds _ROOT_NODE_ID_TTL = 30.0 # seconds
@@ -4783,7 +4784,7 @@ async def mesh_vote(request: Request):
vote_payload = {"target_id": target_id, "vote": vote, "gate": gate} vote_payload = {"target_id": target_id, "vote": vote, "gate": gate}
# Resolve stable local operator ID for duplicate-vote prevention. # Resolve stable local operator ID for duplicate-vote prevention.
# Personas generate unique keypairs, so voter_id alone is insufficient — # Personas generate unique keypairs, so voter_id alone is insufficient ├óΓé¼ΓÇ¥
# use the root identity's node_id as a stable anchor so switching personas # 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. # doesn't let the same operator vote multiple times on the same post.
stable_voter_id = voter_id stable_voter_id = voter_id
@@ -5072,7 +5073,7 @@ async def mesh_identity_revoke(request: Request):
return {"ok": True, "detail": "Identity revoked"} return {"ok": True, "detail": "Identity revoked"}
# ─── Gate Endpoints ─────────────────────────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Gate Endpoints ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
@app.post("/api/mesh/gate/create") @app.post("/api/mesh/gate/create")
@@ -5143,7 +5144,7 @@ async def gate_create(request: Request):
@app.get("/api/mesh/gate/list") @app.get("/api/mesh/gate/list")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def gate_list(request: Request): async def gate_list(request: Request):
"""List all known gates (public catalog — secrets are never included).""" """List all known gates (public catalog ├óΓé¼ΓÇ¥ secrets are never included)."""
from services.mesh.mesh_reputation import gate_manager from services.mesh.mesh_reputation import gate_manager
return {"gates": gate_manager.list_gates()} return {"gates": gate_manager.list_gates()}
@@ -5246,7 +5247,7 @@ def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str
payload_ok, payload_reason = validate_event_payload("gate_message", gate_payload) payload_ok, payload_reason = validate_event_payload("gate_message", gate_payload)
if not payload_ok: if not payload_ok:
return {"ok": False, "detail": payload_reason} return {"ok": False, "detail": payload_reason}
# gate_envelope is not part of the signed payload — envelope_hash binds it. # gate_envelope is not part of the signed payload ├óΓé¼ΓÇ¥ envelope_hash binds it.
# reply_to is signed for new compose flows; if only the legacy no-reply_to # reply_to is signed for new compose flows; if only the legacy no-reply_to
# signature verifies, strip it rather than accepting unauthenticated # signature verifies, strip it rather than accepting unauthenticated
# threading metadata. # threading metadata.
@@ -5396,13 +5397,13 @@ def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str
) )
# ─── Infonet Endpoints ─────────────────────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Infonet Endpoints ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
@app.get("/api/mesh/infonet/status") @app.get("/api/mesh/infonet/status")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def infonet_status(request: Request, verify_signatures: bool = False): async def infonet_status(request: Request, verify_signatures: bool = False):
"""Get Infonet metadata — event counts, head hash, chain size.""" """Get Infonet metadata ├óΓé¼ΓÇ¥ event counts, head hash, chain size."""
from services.mesh.mesh_hashchain import infonet from services.mesh.mesh_hashchain import infonet
from services.wormhole_supervisor import get_wormhole_state from services.wormhole_supervisor import get_wormhole_state
@@ -5581,7 +5582,7 @@ async def mesh_metrics(request: Request):
ok, detail = _check_scoped_auth(request, "mesh.audit") ok, detail = _check_scoped_auth(request, "mesh.audit")
if not ok: if not ok:
if detail == "insufficient scope": if detail == "insufficient scope":
raise HTTPException(status_code=403, detail="Forbidden — insufficient scope") raise HTTPException(status_code=403, detail="Forbidden ├óΓé¼ΓÇ¥ insufficient scope")
raise HTTPException(status_code=403, detail=detail) raise HTTPException(status_code=403, detail=detail)
return snapshot() return snapshot()
@@ -5981,7 +5982,7 @@ async def gate_peer_pull(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Peer Management API — operator endpoints for adding / removing / listing # Peer Management API ├óΓé¼ΓÇ¥ operator endpoints for adding / removing / listing
# peers without editing peer_store.json by hand. # peers without editing peer_store.json by hand.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -6038,7 +6039,7 @@ async def add_peer(request: Request):
if not transport: if not transport:
transport = peer_transport_kind(peer_url) transport = peer_transport_kind(peer_url)
if not transport: if not transport:
return {"ok": False, "detail": "Cannot determine transport for peer_url — provide transport explicitly"} return {"ok": False, "detail": "Cannot determine transport for peer_url ├óΓé¼ΓÇ¥ provide transport explicitly"}
label = str(body.get("label", "") or "").strip() label = str(body.get("label", "") or "").strip()
role = str(body.get("role", "") or "").strip().lower() or "relay" role = str(body.get("role", "") or "").strip().lower() or "relay"
@@ -6415,7 +6416,7 @@ async def infonet_events_by_type(
} }
# ─── Oracle Endpoints ───────────────────────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Oracle Endpoints ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
@app.post("/api/mesh/oracle/predict") @app.post("/api/mesh/oracle/predict")
@@ -6425,8 +6426,8 @@ async def oracle_predict(request: Request):
"""Place a prediction on a market outcome. FINAL decision. """Place a prediction on a market outcome. FINAL decision.
Body: {node_id, market_title, side, stake_amount?: number} Body: {node_id, market_title, side, stake_amount?: number}
- stake_amount = 0 or omitted â FREE PICK (earn rep if correct) - stake_amount = 0 or omitted óΓÇáΓÇÖ FREE PICK (earn rep if correct)
- stake_amount > 0 â STAKE REP (risk rep, split loser pool if correct) - stake_amount > 0 óΓÇáΓÇÖ STAKE REP (risk rep, split loser pool if correct)
- side can be "yes"/"no" or an outcome name for multi-outcome markets - side can be "yes"/"no" or an outcome name for multi-outcome markets
""" """
from services.mesh.mesh_oracle import oracle_ledger from services.mesh.mesh_oracle import oracle_ledger
@@ -6465,7 +6466,7 @@ async def oracle_predict(request: Request):
if m.get("title", "").lower() == market_title.lower(): if m.get("title", "").lower() == market_title.lower():
matched = m matched = m
break break
# Fuzzy fallback — partial match # Fuzzy fallback ├óΓé¼ΓÇ¥ partial match
if not matched: if not matched:
for m in markets: for m in markets:
if market_title.lower() in m.get("title", "").lower(): if market_title.lower() in m.get("title", "").lower():
@@ -6496,13 +6497,13 @@ async def oracle_predict(request: Request):
probability = 100.0 - probability probability = 100.0 - probability
if stake_amount > 0: if stake_amount > 0:
# STAKED prediction — risk rep for bigger reward # STAKED prediction ├óΓé¼ΓÇ¥ risk rep for bigger reward
ok, detail = oracle_ledger.place_market_stake( ok, detail = oracle_ledger.place_market_stake(
node_id, matched["title"], side, stake_amount, probability node_id, matched["title"], side, stake_amount, probability
) )
mode = "staked" mode = "staked"
else: else:
# FREE prediction — no rep risked # FREE prediction ├óΓé¼ΓÇ¥ no rep risked
ok, detail = oracle_ledger.place_prediction(node_id, matched["title"], side, probability) ok, detail = oracle_ledger.place_prediction(node_id, matched["title"], side, probability)
mode = "free" mode = "free"
@@ -6725,7 +6726,7 @@ async def oracle_resolve(request: Request):
@app.get("/api/mesh/oracle/consensus") @app.get("/api/mesh/oracle/consensus")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def oracle_consensus(request: Request, market_title: str = ""): async def oracle_consensus(request: Request, market_title: str = ""):
"""Get network consensus for a market — picks + staked rep per side.""" """Get network consensus for a market ├óΓé¼ΓÇ¥ picks + staked rep per side."""
from services.mesh.mesh_oracle import oracle_ledger from services.mesh.mesh_oracle import oracle_ledger
if not market_title: if not market_title:
@@ -6814,7 +6815,7 @@ async def oracle_stakes_for_message(request: Request, message_id: str):
@app.get("/api/mesh/oracle/profile") @app.get("/api/mesh/oracle/profile")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def oracle_profile(request: Request, node_id: str = ""): async def oracle_profile(request: Request, node_id: str = ""):
"""Get full oracle profile — rep, prediction history, win rate, farming score.""" """Get full oracle profile ├óΓé¼ΓÇ¥ rep, prediction history, win rate, farming score."""
from services.mesh.mesh_oracle import oracle_ledger from services.mesh.mesh_oracle import oracle_ledger
if not node_id: if not node_id:
@@ -6852,7 +6853,7 @@ async def oracle_resolve_stakes(request: Request):
return {"ok": True, "resolutions": resolutions, "count": len(resolutions)} return {"ok": True, "resolutions": resolutions, "count": len(resolutions)}
# ─── Encrypted DM Relay (Dead Drop) ─────────────────────────────────────── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼ Encrypted DM Relay (Dead Drop) ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
def _secure_dm_enabled() -> bool: def _secure_dm_enabled() -> bool:
@@ -6886,7 +6887,7 @@ def _anonymous_dm_hidden_transport_requested() -> bool:
is *ready* yet. is *ready* yet.
Use this (not the ``_enforced`` variant) for *protective* logic that must Use this (not the ``_enforced`` variant) for *protective* logic that must
keep stated privacy intent honored during warmup e.g., skipping direct keep stated privacy intent honored during warmup ΓÇö e.g., skipping direct
RNS metadata lookups. ``_enforced`` is for claim/telemetry paths that RNS metadata lookups. ``_enforced`` is for claim/telemetry paths that
report what is currently being honored. report what is currently being honored.
""" """
@@ -7164,7 +7165,7 @@ async def _dm_send_from_signed_request(request: Request):
return {"ok": False, "detail": "DM timestamp is too far from current time"} return {"ok": False, "detail": "DM timestamp is too far from current time"}
if delivery_class not in ("request", "shared"): if delivery_class not in ("request", "shared"):
return {"ok": False, "detail": "delivery_class must be request or shared"} return {"ok": False, "detail": "delivery_class must be request or shared"}
# Contact requests are the first-contact handshake do not require prior verification. # Contact requests are the first-contact handshake ΓÇö do not require prior verification.
if delivery_class == "shared": if delivery_class == "shared":
try: try:
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
@@ -7492,7 +7493,7 @@ async def _dm_count_secure_from_signed_request(request: Request):
mailbox_keys = dm_relay.claim_mailbox_keys(agent_id, claims) mailbox_keys = dm_relay.claim_mailbox_keys(agent_id, claims)
relay_ids = dm_relay.claim_message_ids(agent_id, claims) relay_ids = dm_relay.claim_message_ids(agent_id, claims)
direct_ids = set() direct_ids = set()
# Rec #9: requested (not merely enforced) skip direct-lane count probe # Rec #9: requested (not merely enforced) ΓÇö skip direct-lane count probe
# as soon as anonymous mode is requested, even before ready converges. # as soon as anonymous mode is requested, even before ready converges.
if not _anonymous_dm_hidden_transport_requested(): if not _anonymous_dm_hidden_transport_requested():
try: try:
@@ -7593,7 +7594,7 @@ async def dm_get_pubkey(
if key_bundle is None: if key_bundle is None:
# Invite handles are minted on the owner's node. When a remote peer # Invite handles are minted on the owner's node. When a remote peer
# pastes a short address, resolve it across the private fleet before # pastes a short address, resolve it across the private fleet before
# failing same path as prekey-bundle import. # failing ΓÇö same path as prekey-bundle import.
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/") preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
@@ -8160,7 +8161,7 @@ async def debug_latest_data(request: Request):
return list(get_latest_data().keys()) return list(get_latest_data().keys())
# ── CCTV media proxy (bypass CORS for cross-origin video/image streams) ─── # ├óΓÇ¥Γé¼├óΓÇ¥Γé¼ CCTV media proxy (bypass CORS for cross-origin video/image streams) ├óΓÇ¥Γé¼├óΓÇ¥Γé¼├óΓÇ¥Γé¼
_CCTV_PROXY_ALLOWED_HOSTS = { _CCTV_PROXY_ALLOWED_HOSTS = {
"s3-eu-west-1.amazonaws.com", # TfL JamCams "s3-eu-west-1.amazonaws.com", # TfL JamCams
"jamcams.tfl.gov.uk", "jamcams.tfl.gov.uk",
@@ -8809,12 +8810,12 @@ def api_region_dossier(
lat: float = Query(..., ge=-90, le=90), lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180), lng: float = Query(..., ge=-180, le=180),
): ):
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop.""" """Sync def so FastAPI runs it in a threadpool ├óΓé¼ΓÇ¥ prevents blocking the event loop."""
return get_region_dossier(lat, lng) return get_region_dossier(lat, lng)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Geocoding — proxy to Nominatim with caching and proper headers # Geocoding ├óΓé¼ΓÇ¥ proxy to Nominatim with caching and proper headers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from services.geocode import search_geocode, reverse_geocode from services.geocode import search_geocode, reverse_geocode
@@ -8987,7 +8988,7 @@ async def api_sentinel_tile(request: Request):
evalscript = evalscripts.get(preset, evalscripts["TRUE-COLOR"]) evalscript = evalscripts.get(preset, evalscripts["TRUE-COLOR"])
# Adaptive time range: wider window at lower zoom for better coverage. # Adaptive time range: wider window at lower zoom for better coverage.
# Sentinel-2 has 5-day revisit — a single day often has gaps. # Sentinel-2 has 5-day revisit ├óΓé¼ΓÇ¥ a single day often has gaps.
# At low zoom we mosaic over more days to fill gaps. # At low zoom we mosaic over more days to fill gaps.
from datetime import datetime as _dt, timedelta as _td from datetime import datetime as _dt, timedelta as _td
@@ -9057,7 +9058,7 @@ async def api_sentinel_tile(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API Settings — key registry & management # API Settings ├óΓé¼ΓÇ¥ key registry & management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from services.api_settings import get_api_keys, get_env_path_info from services.api_settings import get_api_keys, get_env_path_info
from services.shodan_connector import ( from services.shodan_connector import (
@@ -9132,7 +9133,7 @@ async def api_shodan_host(request: Request, body: ShodanHostRequest):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Finnhub — free market intelligence (quotes, congress trades, insider txns) # Finnhub ├óΓé¼ΓÇ¥ free market intelligence (quotes, congress trades, insider txns)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from services.unusual_whales_connector import ( from services.unusual_whales_connector import (
FinnhubConnectorError, FinnhubConnectorError,
@@ -9222,7 +9223,7 @@ async def api_reset_news_feeds(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Wormhole Settings — local agent toggle # Wormhole Settings ├óΓé¼ΓÇ¥ local agent toggle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings
from services.wormhole_status import read_wormhole_status from services.wormhole_status import read_wormhole_status
@@ -9393,7 +9394,7 @@ class NodeSettingsUpdate(BaseModel):
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_get_node_settings(request: Request): async def api_get_node_settings(request: Request):
"""Issue #243 (tg12): node mode and participant state are """Issue #243 (tg12): node mode and participant state are
operational posture. Anonymous callers receive an empty stub operational posture. Anonymous callers receive an empty stub ΓÇö
enough for the UI to know the endpoint exists but nothing enough for the UI to know the endpoint exists but nothing
fingerprintable. Authenticated callers see the full state. fingerprintable. Authenticated callers see the full state.
@@ -10097,7 +10098,7 @@ def decrypt_wormhole_dm_envelope(
if str(current_tier or "").startswith("private_"): if str(current_tier or "").startswith("private_"):
return { return {
"ok": False, "ok": False,
"detail": "MLS format required in private transport mode — legacy DM decrypt blocked", "detail": "MLS format required in private transport mode ├óΓé¼ΓÇ¥ legacy DM decrypt blocked",
} }
if not _legacy_dm1_allowed(): if not _legacy_dm1_allowed():
return { return {
@@ -10251,7 +10252,7 @@ async def api_wormhole_join(request: Request):
) )
# Enable node participation so the sync/push workers connect to peers. # Enable node participation so the sync/push workers connect to peers.
# This is the voluntary opt-in — the node only joins the network when # This is the voluntary opt-in ├óΓé¼ΓÇ¥ the node only joins the network when
# the user explicitly opens the Wormhole. # the user explicitly opens the Wormhole.
from services.node_settings import write_node_settings from services.node_settings import write_node_settings
@@ -12100,7 +12101,7 @@ async def api_set_privacy_profile(request: Request, body: PrivacyProfileUpdate):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# System — self-update # System ├óΓé¼ΓÇ¥ self-update
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from pathlib import Path from pathlib import Path
from services.updater import perform_update, schedule_restart from services.updater import perform_update, schedule_restart
@@ -12125,7 +12126,7 @@ async def system_update(request: Request):
status_code=500, status_code=500,
media_type="application/json", media_type="application/json",
) )
# Docker: skip restart — user must pull new images manually # Docker: skip restart ├óΓé¼ΓÇ¥ user must pull new images manually
if result.get("status") == "docker": if result.get("status") == "docker":
return result return result
# Schedule restart AFTER response flushes (2s delay) # Schedule restart AFTER response flushes (2s delay)
+6
View File
@@ -111,6 +111,12 @@ async def health_check(request: Request):
ais_status = ais_proxy_status() or {} ais_status = ais_proxy_status() or {}
except Exception: except Exception:
ais_status = {} ais_status = {}
try:
from services.fetchers.aishub_fallback import aishub_fallback_enabled
ais_status["aishub_configured"] = bool(aishub_fallback_enabled())
except Exception:
ais_status["aishub_configured"] = bool(str(os.environ.get("AISHUB_USERNAME", "") or "").strip())
if ais_status.get("degraded_tls") and top_status == "ok": if ais_status.get("degraded_tls") and top_status == "ok":
# Don't override a worse top-level status if SLOs already failed, # Don't override a worse top-level status if SLOs already failed,
# but escalate ok -> degraded so the field surfaces in dashboards. # but escalate ok -> degraded so the field surfaces in dashboards.
+9
View File
@@ -56,6 +56,15 @@ API_REGISTRY = [
"url": "https://aisstream.io/", "url": "https://aisstream.io/",
"required": True, "required": True,
}, },
{
"id": "aishub_username",
"env_key": "AISHUB_USERNAME",
"name": "AISHub Username (backup)",
"description": "Free AISHub account username used as a slow REST backup when AISStream is silent or offline. Does not replace live AIS — polls about every 20 minutes into the same ships layer. Register at aishub.net/api.",
"category": "Maritime",
"url": "https://www.aishub.net/api",
"required": False,
},
{ {
"id": "gfw_api_token", "id": "gfw_api_token",
"env_key": "GFW_API_TOKEN", "env_key": "GFW_API_TOKEN",
+15 -14
View File
@@ -1,4 +1,4 @@
"""Typed configuration via pydantic-settings.""" """Typed configuration via pydantic-settings."""
from functools import lru_cache from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
# Data sources # Data sources
AIS_API_KEY: str = "" AIS_API_KEY: str = ""
AISHUB_USERNAME: str = "" # Optional AISHub REST backup when AISStream is silent
OPENSKY_CLIENT_ID: str = "" OPENSKY_CLIENT_ID: str = ""
OPENSKY_CLIENT_SECRET: str = "" OPENSKY_CLIENT_SECRET: str = ""
LTA_ACCOUNT_KEY: str = "" LTA_ACCOUNT_KEY: str = ""
@@ -31,7 +32,7 @@ class Settings(BaseSettings):
MESH_RNS_ENABLED: bool = False MESH_RNS_ENABLED: bool = False
MESH_ARTI_ENABLED: bool = False MESH_ARTI_ENABLED: bool = False
# When true, trust wormhole_status.json ready bit if the child process is # When true, trust wormhole_status.json ready bit if the child process is
# alive avoids transport-tier flapping when /api/health probes time out # alive ΓÇö avoids transport-tier flapping when /api/health probes time out
# under Tor load (common during live DM E2E). # under Tor load (common during live DM E2E).
MESH_WORMHOLE_TRUST_FILE_READY: bool = False MESH_WORMHOLE_TRUST_FILE_READY: bool = False
MESH_ARTI_SOCKS_PORT: int = 9050 MESH_ARTI_SOCKS_PORT: int = 9050
@@ -80,7 +81,7 @@ class Settings(BaseSettings):
MESH_PEER_PUSH_SECRET: str = "" MESH_PEER_PUSH_SECRET: str = ""
# Issue #256 (tg12): optional per-peer HMAC secret map. Comma-separated # Issue #256 (tg12): optional per-peer HMAC secret map. Comma-separated
# `url=secret` pairs. When a peer URL appears here, only that per-peer # `url=secret` pairs. When a peer URL appears here, only that per-peer
# secret is accepted for it the global MESH_PEER_PUSH_SECRET above is # secret is accepted for it ΓÇö the global MESH_PEER_PUSH_SECRET above is
# ignored for that specific URL. Single-peer installs and unmigrated # ignored for that specific URL. Single-peer installs and unmigrated
# multi-peer installs leave this empty and behavior is unchanged. # multi-peer installs leave this empty and behavior is unchanged.
MESH_PEER_SECRETS: str = "" MESH_PEER_SECRETS: str = ""
@@ -122,7 +123,7 @@ class Settings(BaseSettings):
MESH_RNS_IBF_FAIL_THRESHOLD: int = 3 MESH_RNS_IBF_FAIL_THRESHOLD: int = 3
MESH_RNS_IBF_COOLDOWN_S: int = 120 MESH_RNS_IBF_COOLDOWN_S: int = 120
MESH_VERIFY_INTERVAL_S: int = 600 MESH_VERIFY_INTERVAL_S: int = 600
# MESH_VERIFY_SIGNATURES is intentionally removed the audit loop in main.py # MESH_VERIFY_SIGNATURES is intentionally removed ΓÇö the audit loop in main.py
# always calls validate_chain_incremental(verify_signatures=True). Any value # always calls validate_chain_incremental(verify_signatures=True). Any value
# set in the environment is ignored. # set in the environment is ignored.
MESH_DM_SECURE_MODE: bool = True MESH_DM_SECURE_MODE: bool = True
@@ -144,14 +145,14 @@ class Settings(BaseSettings):
# Anti-spam: cap on distinct UNACKED messages a single sender can have # Anti-spam: cap on distinct UNACKED messages a single sender can have
# parked in a single recipient's mailbox at any one time. Once the # parked in a single recipient's mailbox at any one time. Once the
# recipient pulls (acks) a message, the sender's quota for that pair # recipient pulls (acks) a message, the sender's quota for that pair
# frees up. Default 2 a sender who wants to deliver more must wait # frees up. Default 2 ΓÇö a sender who wants to deliver more must wait
# for the recipient to actually read the prior messages. # for the recipient to actually read the prior messages.
# #
# This cap is enforced TWICE: once on the local deposit path (the # This cap is enforced TWICE: once on the local deposit path (the
# sender's own node refuses to spool the 3rd message) AND once on # sender's own node refuses to spool the 3rd message) AND once on
# the replication-acceptance path (honest peer relays refuse to # the replication-acceptance path (honest peer relays refuse to
# accept inbound replicas that would put them over the cap). The # accept inbound replicas that would put them over the cap). The
# double enforcement makes the rule a NETWORK rule patching out # double enforcement makes the rule a NETWORK rule ΓÇö patching out
# the local check on a hostile sender's relay doesn't let extras # the local check on a hostile sender's relay doesn't let extras
# propagate, because every honest peer enforces the same cap on # propagate, because every honest peer enforces the same cap on
# inbound replication. # inbound replication.
@@ -168,7 +169,7 @@ class Settings(BaseSettings):
MESH_VOTER_BLIND_SALT_GRACE_DAYS: int = 30 MESH_VOTER_BLIND_SALT_GRACE_DAYS: int = 30
MESH_DM_MAX_MSG_BYTES: int = 8192 MESH_DM_MAX_MSG_BYTES: int = 8192
MESH_DM_ALLOW_SENDER_SEAL: bool = False MESH_DM_ALLOW_SENDER_SEAL: bool = False
# TTL for DH key and prekey bundle registrations stale entries are pruned. # TTL for DH key and prekey bundle registrations ΓÇö stale entries are pruned.
MESH_DM_KEY_TTL_DAYS: int = 30 MESH_DM_KEY_TTL_DAYS: int = 30
# TTL for invite-scoped prekey lookup aliases; shorter windows reduce # TTL for invite-scoped prekey lookup aliases; shorter windows reduce
# long-lived relay linkage between opaque lookup handles and agent IDs. # long-lived relay linkage between opaque lookup handles and agent IDs.
@@ -176,7 +177,7 @@ class Settings(BaseSettings):
# TTL for relay witness history; keep continuity metadata bounded instead # TTL for relay witness history; keep continuity metadata bounded instead
# of relying on a hidden hardcoded retention window. # of relying on a hidden hardcoded retention window.
MESH_DM_WITNESS_TTL_DAYS: int = 14 MESH_DM_WITNESS_TTL_DAYS: int = 14
# TTL for mailbox binding metadata shorter = smaller metadata footprint on disk. # TTL for mailbox binding metadata ΓÇö shorter = smaller metadata footprint on disk.
MESH_DM_BINDING_TTL_DAYS: int = 3 MESH_DM_BINDING_TTL_DAYS: int = 3
# When False, mailbox bindings are memory-only (agents re-register on restart). # When False, mailbox bindings are memory-only (agents re-register on restart).
# Enable explicitly only if restart continuity is worth persisting DM graph metadata. # Enable explicitly only if restart continuity is worth persisting DM graph metadata.
@@ -319,7 +320,7 @@ class Settings(BaseSettings):
# Second explicit opt-in for private-tier clearnet fallback. Without this # Second explicit opt-in for private-tier clearnet fallback. Without this
# acknowledgement, "allow" remains requested but not effective. # acknowledgement, "allow" remains requested but not effective.
MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE: bool = False MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE: bool = False
# Meshtastic MQTT bridge disabled by default to avoid hammering the # Meshtastic MQTT bridge ΓÇö disabled by default to avoid hammering the
# public broker. Users opt in explicitly. # public broker. Users opt in explicitly.
MESH_MQTT_ENABLED: bool = False MESH_MQTT_ENABLED: bool = False
# Meshtastic MQTT broker credentials (defaults match public firmware). # Meshtastic MQTT broker credentials (defaults match public firmware).
@@ -327,7 +328,7 @@ class Settings(BaseSettings):
MESH_MQTT_PORT: int = 1883 MESH_MQTT_PORT: int = 1883
MESH_MQTT_USER: str = "meshdev" MESH_MQTT_USER: str = "meshdev"
MESH_MQTT_PASS: str = "large4cats" MESH_MQTT_PASS: str = "large4cats"
# Hex-encoded PSK empty string means use the default LongFast key. # Hex-encoded PSK ΓÇö empty string means use the default LongFast key.
# Must decode to exactly 16 or 32 bytes when set. # Must decode to exactly 16 or 32 bytes when set.
MESH_MQTT_PSK: str = "" MESH_MQTT_PSK: str = ""
# Optional operator-provided Meshtastic node ID (e.g. "!abcd1234") included # Optional operator-provided Meshtastic node ID (e.g. "!abcd1234") included
@@ -350,16 +351,16 @@ class Settings(BaseSettings):
OPERATOR_HANDLE: str = "" OPERATOR_HANDLE: str = ""
# SAR (Synthetic Aperture Radar) data layer # SAR (Synthetic Aperture Radar) data layer
# Mode A free catalog metadata, no account, default-on # Mode A ΓÇö free catalog metadata, no account, default-on
MESH_SAR_CATALOG_ENABLED: bool = True MESH_SAR_CATALOG_ENABLED: bool = True
# Mode B free pre-processed anomalies (OPERA / EGMS / GFM / EMS / UNOSAT) # Mode B ΓÇö free pre-processed anomalies (OPERA / EGMS / GFM / EMS / UNOSAT)
# Two-step opt-in: must be "allow" AND _ACKNOWLEDGE must be true # Two-step opt-in: must be "allow" AND _ACKNOWLEDGE must be true
MESH_SAR_PRODUCTS_FETCH: str = "block" MESH_SAR_PRODUCTS_FETCH: str = "block"
MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE: bool = False MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE: bool = False
# NASA Earthdata Login (free) required for OPERA products # NASA Earthdata Login (free) ΓÇö required for OPERA products
MESH_SAR_EARTHDATA_USER: str = "" MESH_SAR_EARTHDATA_USER: str = ""
MESH_SAR_EARTHDATA_TOKEN: str = "" MESH_SAR_EARTHDATA_TOKEN: str = ""
# Copernicus Data Space (free) required for EGMS / EMS products # Copernicus Data Space (free) ΓÇö required for EGMS / EMS products
MESH_SAR_COPERNICUS_USER: str = "" MESH_SAR_COPERNICUS_USER: str = ""
MESH_SAR_COPERNICUS_TOKEN: str = "" MESH_SAR_COPERNICUS_TOKEN: str = ""
# Whether OpenClaw agents may read/act on the SAR layer # Whether OpenClaw agents may read/act on the SAR layer
+1
View File
@@ -46,6 +46,7 @@ _CRITICAL_WARN = {
_OPTIONAL = { _OPTIONAL = {
"AIS_API_KEY": "AIS vessel streaming (ships layer will be empty without it)", "AIS_API_KEY": "AIS vessel streaming (ships layer will be empty without it)",
"AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)",
"GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)", "GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)",
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)", "LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
"PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)", "PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)",
+13
View File
@@ -130,6 +130,7 @@ class TestHealthEndpointEscalation:
body = res.json() body = res.json()
assert body["ais_proxy"]["connected"] is False assert body["ais_proxy"]["connected"] is False
assert body["ais_proxy"]["proxy_spawn_count"] == 5 assert body["ais_proxy"]["proxy_spawn_count"] == 5
assert "aishub_configured" in body["ais_proxy"]
# Without API_KEY this would stay "ok"; with it set + connected=false, # Without API_KEY this would stay "ok"; with it set + connected=false,
# we expect at least "degraded" (could be "error" if an SLO is also # we expect at least "degraded" (could be "error" if an SLO is also
# red, but never "ok"). # red, but never "ok").
@@ -138,6 +139,18 @@ class TestHealthEndpointEscalation:
f"got {body['status']!r}" f"got {body['status']!r}"
) )
def test_health_reports_aishub_configured_flag(self, client, monkeypatch):
_reset_ais_module()
monkeypatch.setenv("AISHUB_USERNAME", "shadowbroker-test")
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ais_proxy"]["aishub_configured"] is True
monkeypatch.delenv("AISHUB_USERNAME", raising=False)
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ais_proxy"]["aishub_configured"] is False
def test_no_api_key_does_not_escalate(self, client, monkeypatch): def test_no_api_key_does_not_escalate(self, client, monkeypatch):
"""When AIS_API_KEY isn't set, the operator hasn't opted in. Don't """When AIS_API_KEY isn't set, the operator hasn't opted in. Don't
flag the system as degraded just because AIS isn't running — that's flag the system as degraded just because AIS isn't running — that's
@@ -0,0 +1,9 @@
from services.api_settings import ALLOWED_ENV_KEYS, API_REGISTRY
def test_aishub_username_is_in_api_registry():
entry = next((item for item in API_REGISTRY if item.get("env_key") == "AISHUB_USERNAME"), None)
assert entry is not None
assert entry["category"] == "Maritime"
assert entry["required"] is False
assert "AISHUB_USERNAME" in ALLOWED_ENV_KEYS
+1 -1
View File
@@ -993,7 +993,7 @@ export default function Dashboard() {
{/* AIS UPSTREAM OUTAGE BANNER renders only when AIS is configured {/* AIS UPSTREAM OUTAGE BANNER renders only when AIS is configured
but the WebSocket upstream is unreachable. Tells users the empty but the WebSocket upstream is unreachable. Tells users the empty
ocean isn't their fault. */} ocean isn't their fault. */}
<AisUpstreamBanner /> <AisUpstreamBanner onOpenApiKeys={() => setSettingsOpen(true)} />
{/* ONBOARDING MODAL */} {/* ONBOARDING MODAL */}
{showOnboarding && ( {showOnboarding && (
+26 -10
View File
@@ -1,15 +1,18 @@
/** /**
* AisUpstreamBanner visible notice that AIS ship data is unavailable * AisUpstreamBanner visible notice that AISStream ship data is unavailable.
* because the upstream provider (AISStream) is offline.
* *
* Renders nothing when AIS is healthy or when AIS isn't configured at all. * Renders nothing when AIS is healthy or when AIS isn't configured at all.
* Mounted at the app shell level so users see it before they wonder why * When AISStream is silent, nudge operators toward the existing AISHub REST
* the ocean looks empty. * backup (Settings API Keys) or confirm that backup is already active.
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth'; import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth';
export function AisUpstreamBanner() { type AisUpstreamBannerProps = {
onOpenApiKeys?: () => void;
};
export function AisUpstreamBanner({ onOpenApiKeys }: AisUpstreamBannerProps = {}) {
const health = useAisUpstreamHealth(); const health = useAisUpstreamHealth();
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
@@ -29,6 +32,10 @@ export function AisUpstreamBanner() {
} }
} }
const detail = health.aishubConfigured
? `AISStream is silent (${stalenessLabel}). AISHub backup polling stays active on a slower cadence (~20 min) — live WebSocket traffic will resume when AISStream recovers.`
: `AISStream is silent (${stalenessLabel}). Add a free AISHub username under Settings → API Keys → Maritime for slow backup ship coverage while AISStream is down.`;
return ( return (
<div <div
role="status" role="status"
@@ -38,12 +45,21 @@ export function AisUpstreamBanner() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span aria-hidden className="mt-0.5 text-amber-300"></span> <span aria-hidden className="mt-0.5 text-amber-300"></span>
<div className="flex-1"> <div className="flex-1">
<div className="font-semibold">Ship data temporarily unavailable</div> <div className="font-semibold">
<div className="text-xs opacity-90"> {health.aishubConfigured
AISStream upstream is offline ({stalenessLabel}). The map will ? 'Live AIS offline — AISHub backup active'
refill once their service comes back online nothing is wrong : 'Ship data temporarily unavailable'}
with your install.
</div> </div>
<div className="text-xs opacity-90">{detail}</div>
{!health.aishubConfigured && onOpenApiKeys ? (
<button
type="button"
onClick={onOpenApiKeys}
className="mt-2 text-[11px] font-mono tracking-wide text-amber-100 underline underline-offset-2 hover:text-white"
>
Open API Keys
</button>
) : null}
</div> </div>
<button <button
type="button" type="button"
+21 -3
View File
@@ -38,6 +38,20 @@ const API_GUIDES = [
url: 'https://aisstream.io/authenticate', url: 'https://aisstream.io/authenticate',
color: 'blue', color: 'blue',
}, },
{
name: 'AISHub (backup)',
icon: <Ship size={14} className="text-blue-300" />,
required: false,
description:
'Slow REST backup for the ships layer when AISStream is silent or offline. Uses the same map layer on a ~20 minute cadence.',
steps: [
'Create a free account at aishub.net',
'Open the API page and note your username',
'Paste the username into Quick Local Setup above or Settings → API Keys → Maritime',
],
url: 'https://www.aishub.net/api',
color: 'blue',
},
{ {
name: 'Global Fishing Watch', name: 'Global Fishing Watch',
icon: <Ship size={14} className="text-teal-400" />, icon: <Ship size={14} className="text-teal-400" />,
@@ -79,6 +93,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '', OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '', AIS_API_KEY: '',
AISHUB_USERNAME: '',
GFW_API_TOKEN: '', GFW_API_TOKEN: '',
}); });
const [setupSaving, setSetupSaving] = useState(false); const [setupSaving, setSetupSaving] = useState(false);
@@ -129,6 +144,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '', OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '', AIS_API_KEY: '',
AISHUB_USERNAME: '',
GFW_API_TOKEN: '', GFW_API_TOKEN: '',
}); });
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' }); setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
@@ -577,9 +593,10 @@ const OnboardingModal = React.memo(function OnboardingModal({
</p> </p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed"> <p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
OpenSky Network and AIS Stream are the free keys that make ShadowBroker OpenSky Network and AIS Stream are the free keys that make ShadowBroker
useful immediately: live aircraft and vessel tracking. Global Fishing Watch useful immediately: live aircraft and vessel tracking. Optionally add an
unlocks the fishing-activity layer. Paste them below or use Settings later; AISHub username as a slow ships-layer backup when AISStream is silent.
secrets stay on the local backend. Global Fishing Watch unlocks the fishing-activity layer. Paste them below
or use Settings later; secrets stay on the local backend.
</p> </p>
</div> </div>
</div> </div>
@@ -599,6 +616,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'], ['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'], ['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
['AIS_API_KEY', 'AIS Stream API Key'], ['AIS_API_KEY', 'AIS Stream API Key'],
['AISHUB_USERNAME', 'AISHub Username (optional backup)'],
['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'], ['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'],
].map(([key, label]) => ( ].map(([key, label]) => (
<input <input
@@ -8,6 +8,10 @@
* banner can explain "AIS upstream is offline" instead of letting users * banner can explain "AIS upstream is offline" instead of letting users
* wonder. * wonder.
* *
* When AISStream is silent, the backend can still fill ships via AISHub REST
* (`AISHUB_USERNAME`) on a slow cadence. ``aishubConfigured`` tells the banner
* whether to nudge the operator to add that backup or confirm it is active.
*
* The poll interval is intentionally relaxed (30s) this is a low-urgency UX * The poll interval is intentionally relaxed (30s) this is a low-urgency UX
* signal, not a real-time data feed. Backend already escalates top_status to * signal, not a real-time data feed. Backend already escalates top_status to
* "degraded" when AIS is configured-but-disconnected. * "degraded" when AIS is configured-but-disconnected.
@@ -35,6 +39,8 @@ export interface AisUpstreamHealth {
* seen we approximate it by requiring at least one spawn before * seen we approximate it by requiring at least one spawn before
* declaring an outage. */ * declaring an outage. */
aisEnabled: boolean; aisEnabled: boolean;
/** True when ``AISHUB_USERNAME`` is set so the REST backup can run. */
aishubConfigured: boolean;
} }
const POLL_INTERVAL_MS = 30_000; const POLL_INTERVAL_MS = 30_000;
@@ -67,6 +73,7 @@ export function useAisUpstreamHealth(): AisUpstreamHealth | null {
degradedTls: Boolean(proxy.degraded_tls), degradedTls: Boolean(proxy.degraded_tls),
proxySpawnCount: spawns, proxySpawnCount: spawns,
aisEnabled: spawns > 0, aisEnabled: spawns > 0,
aishubConfigured: Boolean(proxy.aishub_configured),
}); });
} catch { } catch {
// Backend unreachable — separate problem. Banner not relevant. // Backend unreachable — separate problem. Banner not relevant.