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:
@@ -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),
|
||||
|
||||
@@ -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<FlightsSnapshot | null> {
|
||||
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<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;
|
||||
return normalise(body, (dropped) =>
|
||||
log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user