solargod/serve.py
monsterrobotparty a6538fee1c 🚀 herald: production hardening — SOLARGOD_READONLY gates /snap; unit adapted to the tunnel topology (fable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:34:24 +10:00

237 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""SOLARGOD dev server: static files + a same-origin proxy for the JPL feeds a
browser can't call directly (no CORS on ssd.jpl.nasa.gov / ssd-api.jpl.nasa.gov).
Zero dependencies (stdlib only). Quickstart: python3 serve.py → http://127.0.0.1:8147
Proxy endpoints (allowlisted upstreams only — never a general proxy):
/proxy/horizons?<q> → https://ssd.jpl.nasa.gov/api/horizons.api?<q>
/proxy/sbdb?<q> → https://ssd-api.jpl.nasa.gov/sbdb_query.api?<q>
/proxy/cad?<q> → https://ssd-api.jpl.nasa.gov/cad.api?<q>
Disk cache in cache/ keyed by sha256(upstream URL). Responses whose requested
time span lies entirely in the past are immutable (never expire); everything else
gets a 24 h TTL. The cache is the rate limiter — be polite to JPL (brief §6, §12).
"""
import base64
import hashlib
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8147
ROOT = Path(__file__).resolve().parent
CACHE_DIR = ROOT / "cache"
CACHE_TTL_SEC = 24 * 3600
# Production (HERALD): SOLARGOD_READONLY=1 disables the dev-only /snap write
# endpoint — behind a public tunnel, the server must accept no writes at all.
READONLY = os.environ.get("SOLARGOD_READONLY") == "1"
UPSTREAMS = {
"horizons": "https://ssd.jpl.nasa.gov/api/horizons.api",
"sbdb": "https://ssd-api.jpl.nasa.gov/sbdb_query.api",
"sbdb_lookup": "https://ssd-api.jpl.nasa.gov/sbdb.api",
"cad": "https://ssd-api.jpl.nasa.gov/cad.api",
}
JD_UNIX_EPOCH = 2440587.5
def _jd_to_unix(jd):
return (jd - JD_UNIX_EPOCH) * 86400.0
def _parse_epoch_to_unix(tok):
"""Best-effort: a Horizons/CAD time token → unix seconds, or None."""
tok = tok.strip().strip("'\"")
if not tok:
return None
try: # bare Julian Date (e.g. 2461236.5)
f = float(tok)
if f > 2400000:
return _jd_to_unix(f)
except ValueError:
pass
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y-%b-%d %H:%M", "%Y-%b-%d"):
try:
return datetime.strptime(tok, fmt).replace(tzinfo=timezone.utc).timestamp()
except ValueError:
continue
return None
def _requested_span_is_past(name, query):
"""True if the whole requested time span is safely in the past → immutable."""
q = urllib.parse.parse_qs(query, keep_blank_values=True)
latest = None
def bump(u):
nonlocal latest
if u is not None:
latest = u if latest is None else max(latest, u)
if name == "horizons":
for key in ("STOP_TIME", "TLIST"):
for raw in q.get(key, []):
for tok in re.split(r"[,\s]+", raw):
bump(_parse_epoch_to_unix(tok))
if "STOP_TIME" not in q and "TLIST" not in q:
return False
# CAD is NOT immutable even for past dates: close-approach records keep growing
# as new NEOs are discovered. SBDB elements advance too. Both get the 24 h TTL.
else:
return False
# Margin: only call it immutable if the latest epoch is > 1 day ago.
return latest is not None and latest < time.time() - 86400
def _cache_paths(url):
h = hashlib.sha256(url.encode()).hexdigest()
return CACHE_DIR / f"{h}.body", CACHE_DIR / f"{h}.meta"
def _cache_read(url):
body_p, meta_p = _cache_paths(url)
try:
meta = json.loads(meta_p.read_text())
body = body_p.read_bytes()
except (OSError, ValueError):
return None
if meta.get("immutable"):
return body, meta.get("ctype", "text/plain")
if time.time() - meta.get("ts", 0) < CACHE_TTL_SEC:
return body, meta.get("ctype", "text/plain")
return None
def _cache_write(url, body, ctype, immutable):
body_p, meta_p = _cache_paths(url)
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
_atomic_write(body_p, body)
_atomic_write(meta_p, json.dumps(
{"ts": time.time(), "immutable": bool(immutable), "ctype": ctype,
"url": url}).encode())
except OSError:
pass # caching must never break the actual response
def _atomic_write(path, data):
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(ROOT), **kw)
def end_headers(self):
# Dev server: never cache static assets so js/css edits always take effect.
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if self.path.startswith("/proxy/"):
return self._serve_proxy()
return super().do_GET()
def _serve_proxy(self):
name, _, query = self.path[len("/proxy/"):].partition("?")
base = UPSTREAMS.get(name)
if not base:
return self._send_json(404, {"error": f"unknown upstream {name!r}"})
url = base + ("?" + query if query else "")
cached = _cache_read(url)
if cached is not None:
return self._send(200, cached[1], cached[0], {"X-Solargod-Cache": "hit"})
try:
req = urllib.request.Request(url, headers={"User-Agent": "solargod-dev/0.1"})
with urllib.request.urlopen(req, timeout=60) as r:
body = r.read()
ctype = r.headers.get("Content-Type", "text/plain")
# Horizons returns HTTP 200 even for "no ephemeris" errors — don't cache
# those (a transient/burst failure would otherwise poison the cache). A
# real ephemeris always contains the $$SOE marker.
if name != "horizons" or b"$$SOE" in body:
_cache_write(url, body, ctype, _requested_span_is_past(name, query))
return self._send(200, ctype, body, {"X-Solargod-Cache": "miss"})
except urllib.error.HTTPError as e:
body = e.read() or str(e).encode()
return self._send(e.code, "text/plain", body, {"X-Solargod-Cache": "err"})
except Exception as e:
return self._send(502, "text/plain", str(e).encode(), {"X-Solargod-Cache": "err"})
def do_POST(self):
# Dev-only screenshot sink: POST /snap?name=<n> with base64 PNG (or a
# data:image/png;base64,... URL) → docs/<n>.png. Bound to 127.0.0.1; the
# name is sanitized so the write can never escape docs/. Disabled entirely
# in production (SOLARGOD_READONLY=1) — a tunneled deploy must not write.
if READONLY:
return self.send_error(403, "read-only deployment")
if not (self.path == "/snap" or self.path.startswith("/snap?")):
return self.send_error(404, "not found")
_, _, query = self.path.partition("?")
raw = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
name = re.sub(r"[^a-z0-9-]", "", raw.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"})
if not png:
return self._send_json(400, {"error": "empty image"})
docs = ROOT / "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 _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-Solargod-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 _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra)
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
if __name__ == "__main__":
CACHE_DIR.mkdir(parents=True, exist_ok=True)
print(f"SOLARGOD serving on http://127.0.0.1:{PORT} (proxy: horizons · sbdb · cad · cache→cache/)")
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()