GODSIGH/serve.py
jing 0bbdb30719 feat: Military (ADS-B Exchange) layer — real unfiltered military aircraft
Wires John's paid ADS-B Exchange RapidAPI feed ($10/mo, ~10k req/month) into a
new layer showing the military traffic OpenSky/FR24 hide (C-17 "Reach" airlift,
tankers, the Area 51 "Janet" shuttle, etc.).

- serve.py: proxy/adsbx-mil injects the x-rapidapi-key/host headers SERVER-SIDE
  (key read from gitignored adsbxcredentials.json, never in the browser) and
  HARD-caches /v2/mil/ for 5 min — the quota guard: <=~288 upstream calls/day
  (~8.6k/month) no matter how many tabs poll. Stale-on-error; 503 if unconfigured.
- js/layers/military.js: BillboardCollection like the civilian layer but a
  distinct red; live-only (no history feed), 5-min poll matched to the cache.
  ADSBx altitude is FEET (→metres for Cesium). Emergency squawks (7500/7600/
  7700) highlight brighter+bigger and flip the HUD status to err. Click overlay
  shows type / tail / altitude / speed / squawk.
- config.js: adsbx block + militaryReal/militaryEmerg colours.

Verified in dev: proxy miss→hit (5-min cache, key not leaked), 412 military
aircraft render over CONUS, click RCH042 → C17 / 99-0062 / 34000ft / 424kt,
zero console errors. adsbxcredentials.json stays gitignored + server-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:07:40 +10:00

298 lines
13 KiB
Python

#!/usr/bin/env python3
"""GODSIGH dev server: static files + same-origin proxy for feeds that block CORS.
Also implements the **OpenSky shared cache v1** contract (see SPEC2.md §4), which
lets GODSIGH and its sibling project godstrument poll OpenSky from the same IP
without racing each other into the anonymous daily quota. The contract is
mirrored verbatim in godstrument's brief — keep the two in sync.
"""
import base64
import json
import os
import re
import sqlite3
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
UPSTREAMS = {
# OpenSky pins Access-Control-Allow-Origin to its own domain, so the browser
# can't call it directly — we forward it here instead.
"opensky": "https://opensky-network.org/api/states/all",
# Celestrak sends ACAO:* today; proxied too so the app keeps working if that changes.
"celestrak": "https://celestrak.org/NORAD/elements/gp.php",
}
# ---- OpenSky shared cache v1 ------------------------------------------------
# Path: ~/.cache/godverse/opensky-states.json (overridable via OPENSKY_CACHE_FILE).
# Content: the raw, unmodified /states/all JSON body (global, no bbox). Freshness
# = file mtime; readers treat <120 s as fresh. On upstream 429/failure a reader
# may serve a stale cache rather than nothing.
OPENSKY_CACHE_FRESH_SEC = 120
# ---- Historical replay (record.py writes this db; dev-only) -----------------
# GET history/aircraft?t=<epoch_s> returns the nearest recorded snapshot within
# ±10 min, or 404. Prod has no recorder → the file is absent → graceful 404.
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
HISTORY_TOLERANCE_SEC = 600
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
# HARD-caches the /v2/mil/ response for 5 min — the quota guard: even with many
# open tabs, at most ~288 upstream calls/day (~8.6k/month) are spent.
ADSBX_CREDS = Path(__file__).resolve().parent / "adsbxcredentials.json"
ADSBX_CACHE_SEC = 300
_adsbx_cache = {"body": None, "ts": 0.0}
def load_adsbx_creds():
try:
d = json.loads(ADSBX_CREDS.read_text())
return d.get("host"), d.get("key")
except (OSError, ValueError):
return None, None
def opensky_cache_file():
override = os.environ.get("OPENSKY_CACHE_FILE")
return Path(override if override else "~/.cache/godverse/opensky-states.json").expanduser()
def has_bbox(query):
# A global states/all request has no bounding-box params; only those bypass the cache.
q = query.lower()
return any(k in q for k in ("lamin", "lomin", "lamax", "lomax"))
def read_cache_if_fresh(path, max_age):
try:
st = path.stat()
except OSError:
return None
if time.time() - st.st_mtime >= max_age:
return None
try:
return path.read_bytes()
except OSError:
return None
def read_cache_any(path):
try:
return path.read_bytes()
except OSError:
return None
def write_cache(path, body):
# Atomic write: a unique temp file *beside* the target (same filesystem, so
# os.replace is atomic) then os.replace over the target. A unique temp name
# means concurrent writers (GODSIGH + godstrument, or two threads here) never
# corrupt each other's cache or expose a half-written file. Best-effort —
# caching must never break the actual response.
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(body)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass
class Handler(SimpleHTTPRequestHandler):
def end_headers(self):
# Dev server: never let the browser cache our static assets, so edits to
# js/css always take effect on reload (module graphs cache aggressively).
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if self.path.startswith("/history/"):
return self._serve_history()
if not self.path.startswith("/proxy/"):
return super().do_GET()
name, _, query = self.path[len("/proxy/"):].partition("?")
if name == "adsbx-mil":
return self._serve_adsbx_mil()
base = UPSTREAMS.get(name)
if not base:
return self.send_error(404, f"unknown upstream {name!r}")
# Shared cache applies only to the OpenSky global states feed.
opensky_global = name == "opensky" and not has_bbox(query)
cache = opensky_cache_file()
# 1) Serve-from-cache: fresh (<120 s) global request costs zero quota.
if opensky_global:
fresh = read_cache_if_fresh(cache, OPENSKY_CACHE_FRESH_SEC)
if fresh is not None:
return self._send(200, "application/json", fresh, {"X-Godsigh-Cache": "hit"})
url = base + ("?" + query if query else "")
passthrough = {}
def grab(hdrs):
for k in ("X-Rate-Limit-Remaining", "X-Rate-Limit-Limit", "X-Expires-After"):
if hdrs.get(k) is not None:
passthrough[k] = hdrs.get(k)
try:
req = urllib.request.Request(url, headers={"User-Agent": "godsigh-dev/0.1"})
with urllib.request.urlopen(req, timeout=45) as r:
body = r.read()
status, ctype = r.status, r.headers.get("Content-Type", "text/plain")
grab(r.headers)
# 2) Write-through: a healthy global fetch refreshes the shared cache.
if opensky_global and status == 200:
write_cache(cache, body)
passthrough["X-Godsigh-Cache"] = "miss"
except urllib.error.HTTPError as e:
# 3) Stale-on-error: on 429 (quota) or any 5xx (upstream failure), keep
# the client working from the last-known cache. 4xx client errors are
# NOT masked with stale data.
if opensky_global and (e.code == 429 or e.code >= 500):
stale = read_cache_any(cache)
if stale is not None:
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
body = e.read() or str(e).encode()
status, ctype = e.code, "text/plain"
grab(e.headers)
except Exception as e:
# Same stale fallback for connection errors/timeouts.
if opensky_global:
stale = read_cache_any(cache)
if stale is not None:
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
body, status, ctype = str(e).encode(), 502, "text/plain"
self._send(status, ctype, body, passthrough)
def _send(self, status, ctype, body, extra=None):
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header(
"Access-Control-Expose-Headers",
"X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After, X-Godsigh-Cache",
)
for k, v in (extra or {}).items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
# Dev-only screenshot sink: POST snap?name=<name> with a base64 PNG (or a
# full data:image/png;base64,... URL) body → writes docs/<name>.png. The
# server binds 127.0.0.1 only, and the name is sanitized to [a-z0-9-] so
# the write can never escape docs/. serve.py is never deployed.
if not (self.path == "/snap" or self.path.startswith("/snap?")):
return self.send_error(404, "not found")
_, _, query = self.path.partition("?")
raw_name = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
name = re.sub(r"[^a-z0-9-]", "", raw_name.lower()) or "screenshot"
length = int(self.headers.get("Content-Length") or 0)
text = self.rfile.read(length).decode("utf-8", "replace") if length else ""
if text.strip().startswith("data:") and "," in text:
text = text.split(",", 1)[1]
try:
png = base64.b64decode(text, validate=False)
except Exception:
return self._send_json(400, {"error": "body must be base64 PNG (optionally a data: URL)"})
if not png:
return self._send_json(400, {"error": "empty image"})
docs = (Path(__file__).resolve().parent / "docs")
docs.mkdir(parents=True, exist_ok=True)
out = (docs / f"{name}.png").resolve()
if out.parent != docs.resolve():
return self._send_json(400, {"error": "invalid name"})
out.write_bytes(png)
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
def _serve_adsbx_mil(self):
host, key = load_adsbx_creds()
if not host or not key:
# Not configured (e.g. no key file) — the layer degrades gracefully.
return self._send_json(503, {"error": "ADS-B Exchange not configured"})
now = time.time()
if _adsbx_cache["body"] is not None and now - _adsbx_cache["ts"] < ADSBX_CACHE_SEC:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "hit"})
try:
req = urllib.request.Request(
f"https://{host}/v2/mil/",
headers={"x-rapidapi-host": host, "x-rapidapi-key": key,
"User-Agent": "godsigh-dev/0.1"},
)
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
_adsbx_cache["body"] = body
_adsbx_cache["ts"] = now
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if _adsbx_cache["body"] is not None:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(e.code, {"error": f"adsbx upstream {e.code}"})
except Exception as e:
if _adsbx_cache["body"] is not None:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra)
def _serve_history(self):
# /history/aircraft?t=<epoch_s> → nearest snapshot within ±10 min, or 404.
kind, _, query = self.path[len("/history/"):].partition("?")
params = urllib.parse.parse_qs(query)
t_raw = (params.get("t") or [None])[0]
if kind != "aircraft" or t_raw is None:
return self._send_json(400, {"error": "usage: history/aircraft?t=<epoch_s>"})
try:
t = int(float(t_raw))
except (ValueError, OverflowError): # OverflowError: t=inf / 1e999
return self._send_json(400, {"error": "t must be epoch seconds"})
if not HISTORY_DB.exists():
return self._send_json(404, {"error": "no history for this time"})
try:
# Read-only, short-lived connection per request — WAL lets us read
# without ever locking out the recorder's writes.
con = sqlite3.connect(f"file:{HISTORY_DB}?mode=ro", uri=True, timeout=2)
try:
row = con.execute(
"SELECT ts, body FROM snapshots WHERE kind='aircraft' "
"ORDER BY ABS(ts - ?) ASC LIMIT 1", (t,)).fetchone()
finally:
con.close()
except sqlite3.Error as e:
return self._send_json(500, {"error": f"history db error: {e}"})
if not row or abs(row[0] - t) > HISTORY_TOLERANCE_SEC:
return self._send_json(404, {"error": "no history for this time"})
try:
snap = zlib.decompress(row[1]) # already-JSON bytes: {"t":..,"planes":[...]}
except zlib.error:
return self._send_json(500, {"error": "corrupt snapshot"})
return self._send(200, "application/json", snap, {"X-Godsigh-History": "hit"})
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8137
print(f"GODSIGH serving on http://127.0.0.1:{port}")
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()