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=
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).
# Free API token from https://globalfishingwatch.org/our-apis/tokens
# 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
load_dotenv()
@@ -139,8 +139,8 @@ def _check_explicit_scoped_auth_local(
if not admin_key and not scoped_tokens:
if _allow_insecure_admin() or (_debug_mode_enabled() and host == "test"):
return True, "ok", "debug_override"
return False, "Forbidden admin key not configured", ""
return False, "Forbidden invalid or missing admin key", ""
return False, "Forbidden ΓÇö admin key not configured", ""
return False, "Forbidden ΓÇö invalid or missing admin key", ""
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
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
# the file is read and its trimmed content is placed into VAR.
# This MUST run before service imports — modules read os.environ at import time.
# This MUST run before service imports — modules read os.environ at import time.
# ---------------------------------------------------------------------------
_SECRET_VARS = [
"AIS_API_KEY",
"AISHUB_USERNAME",
"OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
@@ -212,7 +213,7 @@ if not _MESH_ONLY:
from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
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]
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
# Retry-After value, so finish_sync can honor it and stop hammering
# 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.
if response.status_code == 429:
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.
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
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
@@ -1765,7 +1766,7 @@ def _sync_from_peer(
Returns ``(ok, error, forked, retry_after_s)``. The fourth tuple
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.
Callers should pass that value to ``finish_sync(retry_after_s=...)``
so the next attempt actually waits.
@@ -1908,7 +1909,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
except PeerSyncHTTPError as exc:
# _sync_from_peer catches PeerSyncRateLimited internally (4-tuple
# 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
# for non-429 throttling responses.
ok = False
@@ -1973,7 +1974,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
failure_backoff_s=failure_backoff_s,
# 429 retry-storm fix: when the peer returned HTTP 429 with
# 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
# every 60s and keeping its rate-limit bucket full forever.
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}")
# ─── Background HTTP Peer Push Worker ────────────────────────────────────
# ─── Background HTTP Peer Push Worker ────────────────────────────────────
# Runs alongside the sync loop. Every PUSH_INTERVAL seconds, batches new
# Infonet events and sends them via HMAC-authenticated POST to push peers.
_PEER_PUSH_INTERVAL_S = 10
_PEER_PUSH_BATCH_SIZE = 50
_peer_push_last_index: dict[str, int] = {} # peer_url → last pushed event index
_peer_push_last_index: dict[str, int] = {} # peer_url → last pushed event index
_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
# MESH_PEER_SECRETS map. The per-peer skip happens below
# ("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.
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)
logger.info(
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:
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))
# ─── Background Gate Message Pull Worker ─────────────────────────────────
# ─── Background Gate Message Pull Worker ─────────────────────────────────
# Periodically pulls gate events from relay peers that this node is missing.
# Complements the push loop: push sends OUR events to peers, pull fetches
# THEIR events from peers (needed when this node is behind NAT).
_GATE_PULL_INTERVAL_S = 10
_gate_pull_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → known count}
_gate_pull_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → known count}
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:
@@ -2749,9 +2750,9 @@ async def lifespan(app: FastAPI):
validate_env(strict=not _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:
# 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.
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.
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
# now, the bridges are already accumulating signals by the time the first
# 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)
if delay_s > 0:
time.sleep(delay_s)
logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
try:
update_all_data(startup_mode=True)
logger.info("=== PRELOAD COMPLETE ===")
@@ -2882,7 +2883,7 @@ async def lifespan(app: FastAPI):
try:
from services.tor_hidden_service import tor_service, HOSTNAME_PATH
if HOSTNAME_PATH.exists():
logger.info("Previous Tor hidden service detected — auto-restarting...")
logger.info("Previous Tor hidden service detected — auto-restarting...")
threading.Thread(
target=tor_service.start, daemon=True
).start()
@@ -3195,9 +3196,9 @@ def _trusted_gate_reply_to(event: dict) -> str:
def _derive_anon_handle(node_id: str, gate_id: str) -> str:
"""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
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).
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
@@ -3238,7 +3239,7 @@ def _strip_gate_identity_member(event: dict, *, envelope_policy: str = "envelope
"transport_lock": str(payload.get("transport_lock", "") or ""),
# gate_envelope is AES-256-GCM ciphertext encrypted under the gate's
# 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
# gives members durable re-readable history. envelope_hash is the
# 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:
"""Legacy alias — defaults to member (narrowed) view."""
"""Legacy alias — defaults to member (narrowed) view."""
return _strip_gate_identity_member(event)
@@ -3483,7 +3484,7 @@ def _verify_gate_access(request: Request, gate_id: str) -> str:
return ""
# ── Non-hostile transport auto-upgrade ────────────────────────────────
# ── Non-hostile transport auto-upgrade ────────────────────────────────
#
# 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
@@ -3750,8 +3751,8 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
# background and return an ok:True "preparing" response
# (202 Accepted) so the client shows a spinner rather
# than an approval dialog. The request itself is NOT
# forwarded to the handler the tier is too low for the
# route's required privacy but the client can poll and
# forwarded to the handler ΓÇö the tier is too low for the
# route's required privacy ΓÇö but the client can poll and
# retry transparently once the lane warms up.
try:
upgraded = await _try_transparent_transport_upgrade()
@@ -3784,7 +3785,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
data = read_wormhole_settings()
# Tor-style: if the user selected high privacy but Wormhole
# 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.
if (
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_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
# hidden-transport warmup WITHOUT blocking this request. The
# transport manager converges in the background; the user sees
@@ -3971,7 +3972,7 @@ def _queue_viirs_change_refresh() -> None:
@limiter.limit("60/minute")
async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001
"""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"}
@@ -4139,7 +4140,7 @@ async def nearest_sdr(
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}}
# Bounded to 10000 entries with 24hr TTL to prevent unbounded memory growth
_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]:
"""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()
sender_key = str(sender_id or "").strip()
if not gate_key or not sender_key:
@@ -4425,7 +4426,7 @@ def _prepared_signed_write(request: Request):
@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.
"""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.
@@ -4437,7 +4438,7 @@ async def mesh_send(request: Request):
if not destination or not message:
return {"ok": False, "detail": "Missing required fields: destination, message"}
# ─── Byte limit enforcement ───────────────────────────────────
# ─── 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)
@@ -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.",
}
# ─── Signature verification & node registration ──────────────
# ─── 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", "")
@@ -4470,9 +4471,9 @@ async def mesh_send(request: Request):
reputation_ledger.register_node(node_id, public_key, public_key_algo)
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"]
transport_lock = str(body.get("transport_lock", "") or "").lower()
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)
# ─── 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
computed_tier = get_transport_tier()
@@ -4511,7 +4512,7 @@ async def mesh_send(request: Request):
)
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_")
if transport_lock == "meshtastic":
if private_tier:
@@ -4547,7 +4548,7 @@ async def mesh_send(request: Request):
results = mesh_router.route(envelope, credentials)
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
# inject successfully-sent channel broadcasts into the bridge directly.
# Node-targeted packets must not appear in the public channel feed.
@@ -4719,14 +4720,14 @@ async def mesh_messages(
@app.get("/api/mesh/channels")
@limiter.limit("30/minute")
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", {})
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_TTL = 30.0 # seconds
@@ -4783,7 +4784,7 @@ async def mesh_vote(request: Request):
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 —
# 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
@@ -5072,7 +5073,7 @@ async def mesh_identity_revoke(request: Request):
return {"ok": True, "detail": "Identity revoked"}
# ─── Gate Endpoints ───────────────────────────────────────────────────────
# ─── Gate Endpoints ───────────────────────────────────────────────────────
@app.post("/api/mesh/gate/create")
@@ -5143,7 +5144,7 @@ async def gate_create(request: Request):
@app.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)."""
"""List all known gates (public catalog — secrets are never included)."""
from services.mesh.mesh_reputation import gate_manager
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)
if not payload_ok:
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
# signature verifies, strip it rather than accepting unauthenticated
# 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")
@limiter.limit("30/minute")
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.wormhole_supervisor import get_wormhole_state
@@ -5581,7 +5582,7 @@ async def mesh_metrics(request: Request):
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="Forbidden — insufficient scope")
raise HTTPException(status_code=403, detail=detail)
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.
# ---------------------------------------------------------------------------
@@ -6038,7 +6039,7 @@ async def add_peer(request: Request):
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"}
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"
@@ -6415,7 +6416,7 @@ async def infonet_events_by_type(
}
# ─── Oracle Endpoints ─────────────────────────────────────────────────────
# ─── Oracle Endpoints ─────────────────────────────────────────────────────
@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.
Body: {node_id, market_title, side, stake_amount?: number}
- stake_amount = 0 or omitted â FREE PICK (earn rep if correct)
- stake_amount > 0 â STAKE REP (risk rep, split loser pool if correct)
- stake_amount = 0 or omitted óΓÇáΓÇÖ FREE PICK (earn rep if correct)
- stake_amount > 0 óΓÇáΓÇÖ STAKE REP (risk rep, split loser pool if correct)
- side can be "yes"/"no" or an outcome name for multi-outcome markets
"""
from services.mesh.mesh_oracle import oracle_ledger
@@ -6465,7 +6466,7 @@ async def oracle_predict(request: Request):
if m.get("title", "").lower() == market_title.lower():
matched = m
break
# Fuzzy fallback — partial match
# Fuzzy fallback — partial match
if not matched:
for m in markets:
if market_title.lower() in m.get("title", "").lower():
@@ -6496,13 +6497,13 @@ async def oracle_predict(request: Request):
probability = 100.0 - probability
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(
node_id, matched["title"], side, stake_amount, probability
)
mode = "staked"
else:
# FREE prediction — no rep risked
# FREE prediction — no rep risked
ok, detail = oracle_ledger.place_prediction(node_id, matched["title"], side, probability)
mode = "free"
@@ -6725,7 +6726,7 @@ async def oracle_resolve(request: Request):
@app.get("/api/mesh/oracle/consensus")
@limiter.limit("30/minute")
async def oracle_consensus(request: Request, market_title: str = ""):
"""Get network consensus for a market — picks + staked rep per side."""
"""Get network consensus for a market — picks + staked rep per side."""
from services.mesh.mesh_oracle import oracle_ledger
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")
@limiter.limit("30/minute")
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
if not node_id:
@@ -6852,7 +6853,7 @@ async def oracle_resolve_stakes(request: Request):
return {"ok": True, "resolutions": resolutions, "count": len(resolutions)}
# ─── Encrypted DM Relay (Dead Drop) ───────────────────────────────────────
# ─── Encrypted DM Relay (Dead Drop) ───────────────────────────────────────
def _secure_dm_enabled() -> bool:
@@ -6886,7 +6887,7 @@ def _anonymous_dm_hidden_transport_requested() -> bool:
is *ready* yet.
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
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"}
if delivery_class not in ("request", "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":
try:
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)
relay_ids = dm_relay.claim_message_ids(agent_id, claims)
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.
if not _anonymous_dm_hidden_transport_requested():
try:
@@ -7593,7 +7594,7 @@ async def dm_get_pubkey(
if key_bundle is None:
# Invite handles are minted on the owner's node. When a remote peer
# 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
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())
# ── 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 = {
"s3-eu-west-1.amazonaws.com", # TfL JamCams
"jamcams.tfl.gov.uk",
@@ -8809,12 +8810,12 @@ def api_region_dossier(
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
):
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
return get_region_dossier(lat, lng)
# ---------------------------------------------------------------------------
# Geocoding — proxy to Nominatim with caching and proper headers
# Geocoding — proxy to Nominatim with caching and proper headers
# ---------------------------------------------------------------------------
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"])
# 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.
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.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 (
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_status import read_wormhole_status
@@ -9393,7 +9394,7 @@ class NodeSettingsUpdate(BaseModel):
@limiter.limit("30/minute")
async def api_get_node_settings(request: Request):
"""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
fingerprintable. Authenticated callers see the full state.
@@ -10097,7 +10098,7 @@ def decrypt_wormhole_dm_envelope(
if str(current_tier or "").startswith("private_"):
return {
"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():
return {
@@ -10251,7 +10252,7 @@ async def api_wormhole_join(request: Request):
)
# 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.
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 services.updater import perform_update, schedule_restart
@@ -12125,7 +12126,7 @@ async def system_update(request: Request):
status_code=500,
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":
return result
# 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 {}
except Exception:
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":
# Don't override a worse top-level status if SLOs already failed,
# but escalate ok -> degraded so the field surfaces in dashboards.
+9
View File
@@ -56,6 +56,15 @@ API_REGISTRY = [
"url": "https://aisstream.io/",
"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",
"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 pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
# Data sources
AIS_API_KEY: str = ""
AISHUB_USERNAME: str = "" # Optional AISHub REST backup when AISStream is silent
OPENSKY_CLIENT_ID: str = ""
OPENSKY_CLIENT_SECRET: str = ""
LTA_ACCOUNT_KEY: str = ""
@@ -31,7 +32,7 @@ class Settings(BaseSettings):
MESH_RNS_ENABLED: bool = False
MESH_ARTI_ENABLED: bool = False
# 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).
MESH_WORMHOLE_TRUST_FILE_READY: bool = False
MESH_ARTI_SOCKS_PORT: int = 9050
@@ -80,7 +81,7 @@ class Settings(BaseSettings):
MESH_PEER_PUSH_SECRET: str = ""
# Issue #256 (tg12): optional per-peer HMAC secret map. Comma-separated
# `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
# multi-peer installs leave this empty and behavior is unchanged.
MESH_PEER_SECRETS: str = ""
@@ -122,7 +123,7 @@ class Settings(BaseSettings):
MESH_RNS_IBF_FAIL_THRESHOLD: int = 3
MESH_RNS_IBF_COOLDOWN_S: int = 120
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
# set in the environment is ignored.
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
# 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
# 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.
#
# 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
# the replication-acceptance path (honest peer relays refuse to
# 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
# propagate, because every honest peer enforces the same cap on
# inbound replication.
@@ -168,7 +169,7 @@ class Settings(BaseSettings):
MESH_VOTER_BLIND_SALT_GRACE_DAYS: int = 30
MESH_DM_MAX_MSG_BYTES: int = 8192
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
# TTL for invite-scoped prekey lookup aliases; shorter windows reduce
# 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
# of relying on a hidden hardcoded retention window.
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
# When False, mailbox bindings are memory-only (agents re-register on restart).
# 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
# acknowledgement, "allow" remains requested but not effective.
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.
MESH_MQTT_ENABLED: bool = False
# Meshtastic MQTT broker credentials (defaults match public firmware).
@@ -327,7 +328,7 @@ class Settings(BaseSettings):
MESH_MQTT_PORT: int = 1883
MESH_MQTT_USER: str = "meshdev"
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.
MESH_MQTT_PSK: str = ""
# Optional operator-provided Meshtastic node ID (e.g. "!abcd1234") included
@@ -350,16 +351,16 @@ class Settings(BaseSettings):
OPERATOR_HANDLE: str = ""
# 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
# 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
MESH_SAR_PRODUCTS_FETCH: str = "block"
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_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_TOKEN: str = ""
# Whether OpenClaw agents may read/act on the SAR layer
+1
View File
@@ -46,6 +46,7 @@ _CRITICAL_WARN = {
_OPTIONAL = {
"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)",
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
"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()
assert body["ais_proxy"]["connected"] is False
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,
# we expect at least "degraded" (could be "error" if an SLO is also
# red, but never "ok").
@@ -138,6 +139,18 @@ class TestHealthEndpointEscalation:
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):
"""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
@@ -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
but the WebSocket upstream is unreachable. Tells users the empty
ocean isn't their fault. */}
<AisUpstreamBanner />
<AisUpstreamBanner onOpenApiKeys={() => setSettingsOpen(true)} />
{/* ONBOARDING MODAL */}
{showOnboarding && (
+26 -10
View File
@@ -1,15 +1,18 @@
/**
* AisUpstreamBanner — visible notice that AIS ship data is unavailable
* because the upstream provider (AISStream) is offline.
* AisUpstreamBanner — visible notice that AISStream ship data is unavailable.
*
* 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
* the ocean looks empty.
* When AISStream is silent, nudge operators toward the existing AISHub REST
* backup (Settings → API Keys) or confirm that backup is already active.
*/
import { useState } from 'react';
import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth';
export function AisUpstreamBanner() {
type AisUpstreamBannerProps = {
onOpenApiKeys?: () => void;
};
export function AisUpstreamBanner({ onOpenApiKeys }: AisUpstreamBannerProps = {}) {
const health = useAisUpstreamHealth();
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 (
<div
role="status"
@@ -38,12 +45,21 @@ export function AisUpstreamBanner() {
<div className="flex items-start gap-3">
<span aria-hidden className="mt-0.5 text-amber-300"></span>
<div className="flex-1">
<div className="font-semibold">Ship data temporarily unavailable</div>
<div className="text-xs opacity-90">
AISStream upstream is offline ({stalenessLabel}). The map will
refill once their service comes back online nothing is wrong
with your install.
<div className="font-semibold">
{health.aishubConfigured
? 'Live AIS offline — AISHub backup active'
: 'Ship data temporarily unavailable'}
</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>
<button
type="button"
+21 -3
View File
@@ -38,6 +38,20 @@ const API_GUIDES = [
url: 'https://aisstream.io/authenticate',
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',
icon: <Ship size={14} className="text-teal-400" />,
@@ -79,6 +93,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
AISHUB_USERNAME: '',
GFW_API_TOKEN: '',
});
const [setupSaving, setSetupSaving] = useState(false);
@@ -129,6 +144,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
AISHUB_USERNAME: '',
GFW_API_TOKEN: '',
});
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 className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
useful immediately: live aircraft and vessel tracking. Global Fishing Watch
unlocks the fishing-activity layer. Paste them below or use Settings later;
secrets stay on the local backend.
useful immediately: live aircraft and vessel tracking. Optionally add an
AISHub username as a slow ships-layer backup when AISStream is silent.
Global Fishing Watch unlocks the fishing-activity layer. Paste them below
or use Settings later; secrets stay on the local backend.
</p>
</div>
</div>
@@ -599,6 +616,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
['AIS_API_KEY', 'AIS Stream API Key'],
['AISHUB_USERNAME', 'AISHub Username (optional backup)'],
['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'],
].map(([key, label]) => (
<input
@@ -8,6 +8,10 @@
* banner can explain "AIS upstream is offline" instead of letting users
* 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
* signal, not a real-time data feed. Backend already escalates top_status to
* "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
* declaring an outage. */
aisEnabled: boolean;
/** True when ``AISHUB_USERNAME`` is set so the REST backup can run. */
aishubConfigured: boolean;
}
const POLL_INTERVAL_MS = 30_000;
@@ -67,6 +73,7 @@ export function useAisUpstreamHealth(): AisUpstreamHealth | null {
degradedTls: Boolean(proxy.degraded_tls),
proxySpawnCount: spawns,
aisEnabled: spawns > 0,
aishubConfigured: Boolean(proxy.aishub_configured),
});
} catch {
// Backend unreachable — separate problem. Banner not relevant.