mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-04 05:18:13 +02:00
729ea78cb2
External report from @jmleclercq: AISStream's Let's Encrypt cert
expired on 2026-05-20 (verified — their renewal pipeline failed), so
the AIS WebSocket connection dies with CERT_HAS_EXPIRED and the
maritime layer empties out. The reporter worked around it locally by
passing { rejectUnauthorized: false } to the WebSocket constructor and
asked whether we should add an env var for that.
That fix is the wrong fix. Disabling TLS validation entirely lets any
network attacker MITM the WebSocket and inject fake ship positions —
same class as the GDELT plaintext-HTTP MITM we just closed in #199.
Adding an env var for it would be an attractive nuisance: operators
set it once during a bad cert week and then forget, leaving themselves
open to MITM forever.
Right fix: SPKI pinning, same pattern as the Tor bundle digest pinning
in #201. The insight is that Let's Encrypt renewals keep the SAME
public key by default, so the SPKI hash survives normal cert rotation.
We can relax the date check while keeping the identity check.
Mechanics:
backend/data/aisstream_spki_pins.json (new)
Pinned SHA-256 hashes of the DER-encoded SPKI bytes for
stream.aisstream.io. Captured 2026-05-20 from the live cert.
Format is base64(sha256(pubkey_der)), matching the canonical
openssl pipeline. Whitelisted in .gitignore alongside the other
static reference data files (KiwiSDR directory, Tor bundle
digests).
backend/ais_proxy.js
Path A (99.9% of the time): normal TLS validation. Untouched.
Path B (on CERT_HAS_EXPIRED only): re-handshake with
rejectUnauthorized=false JUST to read the leaf cert, compute its
SPKI hash, compare against the pinned list. If match → upstream
is still the genuine AISStream → re-open the WebSocket with
rejectUnauthorized=false and log DEGRADED MODE. If no match →
refuse the connection, log loudly: this would be a real MITM.
Pin file is looked up in three locations so the same code works
in the Docker backend, the Tauri desktop runtime, and any
operator-relocated layout (SHADOWBROKER_AIS_PINS env var).
Embedded fallback list inside the JS so portable installs that
haven't shipped the JSON still work.
backend/services/ais_stream.py
Captures the proxy's status markers from stdout
({"__ais_proxy_status": {"degraded_tls": true}}) into a module-
level snapshot. Exposes ais_proxy_status() for the health
endpoint. Doesn't touch the data plane — degraded mode keeps
receiving vessel data, just with weaker MITM protection.
backend/routers/health.py + backend/services/schemas.py
/api/health now includes an ais_proxy block with degraded_tls.
Top-level status escalates ok -> degraded when AIS is in
degraded TLS mode (but won't downgrade a worse SLO status).
Operators get a visible signal that they're in degraded mode
without needing to grep logs.
Tests: backend/tests/test_ais_spki_pinning.py (7 tests)
- Pin file structure validation (JSON, host entry, base64 SHA-256)
- ais_proxy_status() snapshot semantics (starts empty, defensive copy)
- /api/health surfaces ais_proxy.degraded_tls when set
- /api/health returns empty ais_proxy when proxy hasn't reported
Node.js syntax check passes (node --check) on both backend/ais_proxy.js
and the Tauri runtime mirror.
When AISStream renews their cert (likely within hours-to-days), the
normal-TLS path succeeds on next reconnect and degraded_tls clears
automatically. No operator action needed. If they instead rotate their
server key, the SPKI check will fail and we'll need to add the new
hash to backend/data/aisstream_spki_pins.json before removing the old
one.
Credit: @jmleclercq for the clear report and the careful workaround
verification (Node version, ws version, manual probe).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
103 lines
3.6 KiB
Python
103 lines
3.6 KiB
Python
import time as _time_mod
|
|
from fastapi import APIRouter, Request, Depends
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
from limiter import limiter
|
|
from auth import require_admin
|
|
from services.data_fetcher import get_latest_data
|
|
from services.schemas import HealthResponse
|
|
import os
|
|
|
|
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.79")
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _get_app_version() -> str:
|
|
# Import lazily to avoid circular import; main sets APP_VERSION before including routers
|
|
try:
|
|
import main as _main
|
|
return _main.APP_VERSION
|
|
except Exception:
|
|
return APP_VERSION
|
|
|
|
|
|
_start_time_ref: dict = {"value": None}
|
|
|
|
|
|
def _get_start_time() -> float:
|
|
if _start_time_ref["value"] is None:
|
|
try:
|
|
import main as _main
|
|
_start_time_ref["value"] = _main._start_time
|
|
except Exception:
|
|
_start_time_ref["value"] = _time_mod.time()
|
|
return _start_time_ref["value"]
|
|
|
|
|
|
@router.get("/api/health", response_model=HealthResponse)
|
|
@limiter.limit("30/minute")
|
|
async def health_check(request: Request):
|
|
from services.fetchers._store import get_source_timestamps_snapshot
|
|
from services.slo import compute_all_statuses, summarise_statuses
|
|
|
|
d = get_latest_data()
|
|
last = d.get("last_updated")
|
|
timestamps = get_source_timestamps_snapshot()
|
|
slo_statuses = compute_all_statuses(d, timestamps)
|
|
slo_summary = summarise_statuses(slo_statuses)
|
|
# Top-level status reflects worst SLO result — "degraded" if any
|
|
# yellow, "error" if any red, "ok" otherwise. This is the single
|
|
# field an external probe / pager can watch.
|
|
top_status = "ok"
|
|
if slo_summary.get("red", 0) > 0:
|
|
top_status = "error"
|
|
elif slo_summary.get("yellow", 0) > 0:
|
|
top_status = "degraded"
|
|
|
|
# Issue #258: surface AIS proxy degraded TLS state so operators can see
|
|
# when the SPKI-pinned fallback is in effect. The data plane keeps
|
|
# flowing (this is by design — see ais_proxy.js comments) but observers
|
|
# who care about MITM-protection posture deserve a visible signal.
|
|
ais_status: dict = {}
|
|
try:
|
|
from services.ais_stream import ais_proxy_status
|
|
ais_status = ais_proxy_status() or {}
|
|
except Exception:
|
|
ais_status = {}
|
|
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.
|
|
top_status = "degraded"
|
|
|
|
return {
|
|
"status": top_status,
|
|
"version": _get_app_version(),
|
|
"last_updated": last,
|
|
"sources": {
|
|
"flights": len(d.get("commercial_flights", [])),
|
|
"military": len(d.get("military_flights", [])),
|
|
"ships": len(d.get("ships", [])),
|
|
"satellites": len(d.get("satellites", [])),
|
|
"earthquakes": len(d.get("earthquakes", [])),
|
|
"cctv": len(d.get("cctv", [])),
|
|
"news": len(d.get("news", [])),
|
|
"uavs": len(d.get("uavs", [])),
|
|
"firms_fires": len(d.get("firms_fires", [])),
|
|
"liveuamap": len(d.get("liveuamap", [])),
|
|
"gdelt": len(d.get("gdelt", [])),
|
|
"uap_sightings": len(d.get("uap_sightings", [])),
|
|
},
|
|
"freshness": timestamps,
|
|
"uptime_seconds": round(_time_mod.time() - _get_start_time()),
|
|
"slo": slo_statuses,
|
|
"slo_summary": slo_summary,
|
|
"ais_proxy": ais_status,
|
|
}
|
|
|
|
|
|
@router.get("/api/debug-latest", dependencies=[Depends(require_admin)])
|
|
@limiter.limit("30/minute")
|
|
async def debug_latest_data(request: Request):
|
|
return list(get_latest_data().keys())
|