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;
|
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),
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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