From 594a0437e49f74ddc3ac54865f8dfaeb1ff72f3f Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 23:35:35 +1000 Subject: [PATCH] feat: authenticate OpenSky via OAuth2 (4000/day vs 400 anonymous) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- serve.py | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/serve.py b/serve.py index fe1b5b0..943db3b 100644 --- a/serve.py +++ b/serve.py @@ -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= 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")