feat: authenticate OpenSky via OAuth2 (4000/day vs 400 anonymous)

serve.py now exchanges the gitignored openskycredentials.json (clientId/
clientSecret) for a 30-min bearer token (cached, refreshed ~1min before expiry)
and adds it to the /states/all upstream fetch — lifting the aircraft quota to
4000 credits/day. Graceful fallback to anonymous if the file is absent or auth
fails. Token exchange only happens on a cache miss (the 120s shared cache still
applies first).

Verified: dev proxy fetch shows X-Rate-Limit-Remaining 3992 (authenticated tier)
vs the anonymous ~400. Key stays server-side (gitignored, never in the browser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-13 23:35:35 +10:00
parent 0bbdb30719
commit 594a0437e4

View File

@ -36,6 +36,15 @@ UPSTREAMS = {
# 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=<epoch_s> returns the nearest recorded snapshot within
# ±10 min, or 404. Prod has no recorder → the file is absent → graceful 404.
@ -64,6 +73,35 @@ def opensky_cache_file():
return Path(override if override else "~/.cache/godverse/opensky-states.json").expanduser()
def opensky_bearer():
# A valid access token (refreshed ~1 min before expiry), or None if creds are
# missing/auth fails → caller fetches anonymously.
try:
d = json.loads(OPENSKY_CREDS.read_text())
cid, csec = d.get("clientId"), d.get("clientSecret")
except (OSError, ValueError):
return None
if not cid or not csec:
return None
now = time.time()
if _opensky_token["value"] and now < _opensky_token["exp"]:
return _opensky_token["value"]
try:
data = urllib.parse.urlencode({
"grant_type": "client_credentials", "client_id": cid, "client_secret": csec,
}).encode()
req = urllib.request.Request(
OPENSKY_TOKEN_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=15) as r:
tok = json.loads(r.read())
_opensky_token["value"] = tok["access_token"]
_opensky_token["exp"] = now + max(60, int(tok.get("expires_in", 1800)) - 60)
return _opensky_token["value"]
except Exception:
return None
def has_bbox(query):
# A global states/all request has no bounding-box params; only those bypass the cache.
q = query.lower()
@ -150,8 +188,13 @@ class Handler(SimpleHTTPRequestHandler):
if hdrs.get(k) is not None:
passthrough[k] = hdrs.get(k)
req_headers = {"User-Agent": "godsigh-dev/0.1"}
if name == "opensky":
tok = opensky_bearer() # authenticated → 4000/day; None → anonymous 400/day
if tok:
req_headers["Authorization"] = "Bearer " + tok
try:
req = urllib.request.Request(url, headers={"User-Agent": "godsigh-dev/0.1"})
req = urllib.request.Request(url, headers=req_headers)
with urllib.request.urlopen(req, timeout=45) as r:
body = r.read()
status, ctype = r.status, r.headers.get("Content-Type", "text/plain")