1
0

fix(flights): identify this service to adsb.lol, which refuses anonymous callers

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:52:35 -07:00
parent ab41302993
commit 75908774f7
4 changed files with 102 additions and 4 deletions
+11
View File
@@ -66,6 +66,16 @@ export interface FlightsConfig {
redistributable: boolean; redistributable: boolean;
/** The licence id behind `redistributable`, or `null` when nothing is live. */ /** The licence id behind `redistributable`, or `null` when nothing is live. */
licence: string | null; 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. */ /** Radius in nautical miles for the `adsb` source. */
radiusNm: number; radiusNm: number;
/** Path to a local dump1090 `aircraft.json`. */ /** Path to a local dump1090 `aircraft.json`. */
@@ -455,6 +465,7 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig {
attribution, attribution,
redistributable, redistributable,
licence, licence,
contact: str(env, "TERA_FLIGHTS_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")),
radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded), radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded),
dump1090Path, dump1090Path,
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded), epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
+6 -2
View File
@@ -17,7 +17,7 @@
*/ */
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { getJson } from "../http.ts"; import { getJson, userAgent } from "../http.ts";
import { isOpenAdsbUrl } from "./licence.ts"; import { isOpenAdsbUrl } from "./licence.ts";
import type { WireAircraft } from "../../../src/server/wire.ts"; import type { WireAircraft } from "../../../src/server/wire.ts";
@@ -73,6 +73,7 @@ export async function fetchAdsb(
endpoint: string, endpoint: string,
center: { lat: number; lng: number }, center: { lat: number; lng: number },
radiusNm: number, radiusNm: number,
contact: string,
log?: AdsbLog, log?: AdsbLog,
): Promise<FlightsSnapshot | null> { ): Promise<FlightsSnapshot | null> {
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`; 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; return null;
} }
const body = await getJson<AircraftEnvelope>(url, { timeoutMs: ADSB_TIMEOUT_MS }); const body = await getJson<AircraftEnvelope>(url, {
timeoutMs: ADSB_TIMEOUT_MS,
headers: { "user-agent": userAgent(contact) },
});
if (body === null) return null; if (body === null) return null;
return normalise(body, (dropped) => return normalise(body, (dropped) =>
log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`), log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`),
+3 -2
View File
@@ -86,7 +86,8 @@ const LIVE_MIN_TTL_SECONDS = 5;
const RECEIVER_KEY = "receiver"; const RECEIVER_KEY = "receiver";
export function createFlightsService(config: Config, log: FlightsLog): FlightsService { 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 // 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 // `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. // 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, () => const snapshot = await upstream.get(key, () =>
source === "dump1090" source === "dump1090"
? readDump1090(dump1090Path, log) ? readDump1090(dump1090Path, log)
: fetchAdsb(endpoint, region, radiusNm, log), : fetchAdsb(endpoint, region, radiusNm, contact, log),
); );
if (snapshot === null) return plan(region); if (snapshot === null) return plan(region);
+82
View File
@@ -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<string, string>; url: () => string } {
let headers: Record<string, string> = {};
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<string, string>).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/);
});
});