From 75908774f7d657430346ea71da4c693dc13a8201 Mon Sep 17 00:00:00 2001 From: Kartios Date: Fri, 21 Aug 2026 19:52:35 -0700 Subject: [PATCH] fix(flights): identify this service to adsb.lol, which refuses anonymous callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real reason the sky was fake, and it was not the timeout. `api.adsb.lol` answers `403` with the body `"User-Agent too generic; include valid contact info."` to any request that does not say who is making it. Node's `fetch` sends nothing useful, so every live flight fetch was refused — in 268 ms, not at the 6 s timeout, which is why the previous commit's longer budget changed nothing. `http.ts` already exported `userAgent()` and `weather/metno.ts` already used it; flights simply never did. `config.flights.contact` now follows the pattern satellites already established: `TERA_FLIGHTS_CONTACT`, falling back to `TERA_WEATHER_CONTACT`, because an operator has one contact address and not four. This failed in the way worth being angry about. A `403` became `null` in `getJson`, `null` means "serve the simulated plan" in `flights/index.ts`, and both of those are correct in isolation. So visitors got fabricated aircraft while `/health` reported `flights: adsb` — the source *was* configured and the host *was* reachable — and `degraded[]` stayed empty because nothing had degraded at boot. Every signal the service publishes about itself said it was fine. `server/src/test/adsbUserAgent.test.ts` asserts the header carries the contact, that the URL is still the region asked for, and that a non-allowlisted host is refused before a single byte reaches the network. A load-bearing header with no test is a header that comes off in a refactor. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/config.ts | 11 ++++ server/src/flights/adsb.ts | 8 ++- server/src/flights/index.ts | 5 +- server/src/test/adsbUserAgent.test.ts | 82 +++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 server/src/test/adsbUserAgent.test.ts diff --git a/server/src/config.ts b/server/src/config.ts index 609e157..feaad4d 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -66,6 +66,16 @@ export interface FlightsConfig { redistributable: boolean; /** The licence id behind `redistributable`, or `null` when nothing is live. */ licence: string | null; + /** + * Sent as the User-Agent on the `adsb` fetch, and required by the feed. + * + * adsb.lol answers `403 "User-Agent too generic; include valid contact info."` + * to a request that does not identify its caller, which is a policy their + * hosted endpoint applies and not something this build can opt out of. It + * falls back to `TERA_WEATHER_CONTACT` for the same reason the satellites + * contact does: an operator has one contact address, not four. + */ + contact: string; /** Radius in nautical miles for the `adsb` source. */ radiusNm: number; /** Path to a local dump1090 `aircraft.json`. */ @@ -455,6 +465,7 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig { attribution, redistributable, licence, + contact: str(env, "TERA_FLIGHTS_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")), radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded), dump1090Path, epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded), diff --git a/server/src/flights/adsb.ts b/server/src/flights/adsb.ts index ffa9287..b66191f 100644 --- a/server/src/flights/adsb.ts +++ b/server/src/flights/adsb.ts @@ -17,7 +17,7 @@ */ import { readFile } from "node:fs/promises"; -import { getJson } from "../http.ts"; +import { getJson, userAgent } from "../http.ts"; import { isOpenAdsbUrl } from "./licence.ts"; import type { WireAircraft } from "../../../src/server/wire.ts"; @@ -73,6 +73,7 @@ export async function fetchAdsb( endpoint: string, center: { lat: number; lng: number }, radiusNm: number, + contact: string, log?: AdsbLog, ): Promise { const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`; @@ -83,7 +84,10 @@ export async function fetchAdsb( ); return null; } - const body = await getJson(url, { timeoutMs: ADSB_TIMEOUT_MS }); + const body = await getJson(url, { + timeoutMs: ADSB_TIMEOUT_MS, + headers: { "user-agent": userAgent(contact) }, + }); if (body === null) return null; return normalise(body, (dropped) => log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`), diff --git a/server/src/flights/index.ts b/server/src/flights/index.ts index a3eb46a..b549aca 100644 --- a/server/src/flights/index.ts +++ b/server/src/flights/index.ts @@ -86,7 +86,8 @@ const LIVE_MIN_TTL_SECONDS = 5; const RECEIVER_KEY = "receiver"; export function createFlightsService(config: Config, log: FlightsLog): FlightsService { - const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights; + const { source, endpoint, radiusNm, contact, dump1090Path, epochMs, seed, ttlSeconds } = + config.flights; // Both derived in `config.ts` from the host that will actually answer, by // `flights/licence.ts`. Read once here so that no code path in this file can // construct a live body with a credit line it made up. @@ -125,7 +126,7 @@ export function createFlightsService(config: Config, log: FlightsLog): FlightsSe const snapshot = await upstream.get(key, () => source === "dump1090" ? readDump1090(dump1090Path, log) - : fetchAdsb(endpoint, region, radiusNm, log), + : fetchAdsb(endpoint, region, radiusNm, contact, log), ); if (snapshot === null) return plan(region); diff --git a/server/src/test/adsbUserAgent.test.ts b/server/src/test/adsbUserAgent.test.ts new file mode 100644 index 0000000..36e510c --- /dev/null +++ b/server/src/test/adsbUserAgent.test.ts @@ -0,0 +1,82 @@ +/** + * The hosted ADS-B feed will not answer an anonymous caller. + * + * `api.adsb.lol` replies `403` with the body + * `"User-Agent too generic; include valid contact info."` to a request that does + * not identify who is making it. Node's `fetch` sends no useful User-Agent, so + * every live flight fetch this service made was refused — and refused in the one + * way nothing notices: `getJson` turns a non-2xx into `null`, `flights/index.ts` + * reads `null` as "serve the simulated plan", `/health` still reports + * `flights: adsb` because the source is configured and reachable, and + * `degraded[]` stays empty because nothing degraded at boot. Every indicator + * said the sky was live while it was fabricated. + * + * The header is therefore load-bearing, and a header that is load-bearing and + * untested is a header that comes off in a refactor. These tests exist so that + * removing it fails here rather than in production, silently, months later. + */ + +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import { fetchAdsb } from "../flights/adsb.ts"; + +const realFetch = globalThis.fetch; +after(() => { + globalThis.fetch = realFetch; +}); + +/** Capture the request the module makes, and answer with one empty sky. */ +function captureRequest(): { headers: () => Record; url: () => string } { + let headers: Record = {}; + let url = ""; + globalThis.fetch = (async (input: unknown, init?: { headers?: HeadersInit }) => { + url = String(input); + const raw = init?.headers ?? {}; + headers = Object.fromEntries( + Object.entries(raw as Record).map(([k, v]) => [k.toLowerCase(), v]), + ); + return { + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + text: async () => JSON.stringify({ ac: [], now: 1 }), + }; + }) as unknown as typeof globalThis.fetch; + return { headers: () => headers, url: () => url }; +} + +const CENTRE = { lat: 33.82, lng: -118.05 }; + +describe("adsb user-agent", () => { + it("sends a User-Agent carrying the configured contact", async () => { + const seen = captureRequest(); + await fetchAdsb("https://api.adsb.lol", CENTRE, 60, "ops@example.com"); + + const ua = seen.headers()["user-agent"]; + assert.ok(ua !== undefined, "no User-Agent was sent; adsb.lol answers 403 to that request"); + assert.match(ua, /ops@example\.com/, "the contact must reach the feed, not just a product name"); + assert.ok( + ua.length > "tera-api".length, + "a bare product token is what the feed calls too generic", + ); + }); + + it("still asks the allowlisted host for the region it was given", async () => { + const seen = captureRequest(); + await fetchAdsb("https://api.adsb.lol", CENTRE, 60, "ops@example.com"); + assert.equal(seen.url(), "https://api.adsb.lol/v2/point/33.8200/-118.0500/60"); + }); + + it("refuses a host that is not on the allowlist before it sends anything", async () => { + const seen = captureRequest(); + const warned: string[] = []; + const out = await fetchAdsb("https://data-cloud.flightradar24.com", CENTRE, 60, "ops@e.com", { + warn: (m) => warned.push(m), + }); + + assert.equal(out, null); + assert.equal(seen.url(), "", "a refused endpoint must never reach the network"); + assert.equal(warned.length, 1); + assert.match(warned[0]!, /not an openly-licensed feed/); + }); +});