#!/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 threading 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 # OpenSky OAuth2 (client-credentials) → 4000 credits/day vs 400 anonymous. The # clientId/clientSecret live in gitignored openskycredentials.json; a 30-min # bearer token is fetched on demand and cached. If the file is absent or auth # fails, we simply fetch anonymously (graceful fallback). OPENSKY_CREDS = Path(__file__).resolve().parent / "openskycredentials.json" OPENSKY_TOKEN_URL = ("https://auth.opensky-network.org/auth/realms/" "opensky-network/protocol/openid-connect/token") _opensky_token = {"value": None, "exp": 0.0} # ---- Historical replay (record.py writes this db; dev-only) ----------------- # GET history/aircraft?t= 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 # ---- Military ADS-B via adsb.lol (FREE, no key, community feed) -------------- # proxy/mil forwards api.adsb.lol/v2/mil (no CORS header, so it needs a same-origin # proxy) with a short in-memory cache. Free and unmetered — the cache is just # politeness/coalescing, not a quota guard. Same {ac:[...]} shape as ADS-B # Exchange, so js/layers/military.js is source-agnostic. ADSBLOL_MIL_URL = "https://api.adsb.lol/v2/mil" ADSBLOL_CACHE_SEC = 60 _adsblol_cache = {"body": None, "ts": 0.0} # ---- Generic adsb.lol query proxy (proxy/adsb/) --------------------- # Powers the OSINT layers (emergency squawks, rare-type hunter, LADD/PIA shadow # aircraft, radius focus). The regex is the security allowlist — only these v2 # shapes are forwardable, so it can't be turned into an open proxy/SSRF. Each # distinct path is cached 60s, and upstream calls are spaced (adsb.lol 429s on # rapid bursts — the same guard the prod nginx regex-location relies on). The # prod nginx block mirrors this allowlist; keep the two in sync. ADSB_PATH_RE = re.compile( r'^(mil|ladd|pia' r'|sqk/\d{3,4}' r'|type/[A-Za-z0-9]{2,5}' r'|hex/[0-9a-fA-F]{6}' r'|callsign/[A-Za-z0-9]{1,8}' r'|reg/[A-Za-z0-9\-]{1,10}' r'|lat/-?\d{1,3}(\.\d+)?/lon/-?\d{1,3}(\.\d+)?/dist/\d{1,4}(\.\d+)?' r'|closest/-?\d{1,3}(\.\d+)?/-?\d{1,3}(\.\d+)?/\d{1,4}(\.\d+)?)$') ADSB_CACHE_SEC = 120 ADSB_MIN_INTERVAL = 1.3 # min seconds between upstream adsb.lol calls (anti-burst; it 429s fast) _adsb_cache = {} # path -> (body, ts) _adsb_lock = threading.Lock() _adsb_next = [0.0] # earliest wall-time the next upstream call may start # ---- Radio Garden proxy (proxy/rg/) ------------------------------------ # Unofficial API, no CORS header → needs this same-origin hop. Only two shapes # are forwardable (the allowlist prevents open-proxy/SSRF): the global places # list (~1.8 MB, changes rarely → 12 h cache) and one place's channel list # (1 h cache). Audio streams do NOT go through here — the browser's