wave3.1: ADS-B OSINT layers (emergency squawks, rare-type hunter, shadow, radius focus)

All FREE via adsb.lol. New generic pieces:
- serve.py proxy/adsb/<path>: regex-allowlisted (SSRF-safe) adsb.lol v2 proxy
  with per-path 120s cache + upstream call-spacing (adsb.lol 429s on bursts;
  reservation slots space call *starts* without serializing network time),
  12s fail-fast timeout, stale-on-error
- js/layers/adsb-layer.js: generic {ac:[...]} billboard factory (parallel
  allSettled fetch, live/scrub/visibility gating, optional client-side filter)
- js/adsb-registry.js: emergency (sqk 7700/7600/7500, default-on, alerts red),
  rare-type hunter (filters the shared mil feed by ICAO type — 1 cached call,
  no per-type rate-limit), shadow (LADD+PIA, purple/pink)
- js/layers/radius.js: camera-driven 'all traffic within 250nm of view'
- verified live: all four render, worst-case simultaneous-enable ok, 0 errors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-14 11:34:21 +10:00 committed by type-two
parent eb3d3cca96
commit 60ceb4a261
6 changed files with 366 additions and 0 deletions

69
js/adsb-registry.js Normal file
View File

@ -0,0 +1,69 @@
// ADS-B OSINT layer specs (Wave 3.1), rendered by createAdsbLayer. All feeds are
// FREE via adsb.lol (proxy/adsb/<path>) — no key, works forever. Emergency is on
// by default (usually empty = cheap, dramatic when not); the hunters are off.
// ICAO type designator → readable name (rare-type hunter).
const TYPE_NAMES = {
U2: 'U-2 Dragon Lady (spyplane)',
GHWK: 'RQ-4 Global Hawk (drone)',
E3TF: 'E-3 Sentry (AWACS)',
E3CF: 'E-3 Sentry (AWACS)',
E6: 'E-6 Mercury (TACAMO)',
E4: 'E-4B Nightwatch ("doomsday")',
RC135: 'RC-135 Rivet Joint (recon)',
B52: 'B-52 Stratofortress',
P8: 'P-8 Poseidon (sub-hunter)',
};
const HUNT_TYPES = Object.keys(TYPE_NAMES);
const SQUAWK_MEANING = { 7500: 'HIJACK', 7600: 'RADIO FAIL', 7700: 'EMERGENCY' };
const SQUAWK_COLOR = { 7500: '#ff2d95', 7600: '#ff9f40', 7700: '#ff453a' };
const ftRow = (id) => [
['Type', id.t || '—'],
['Reg', id.reg || '—'],
['Altitude', `${Math.round(id.altFt || 0)} ft`],
['Speed', `${Math.round(id.gs || 0)} kt`],
];
export const ADSB_LAYERS = [
{
id: 'emergency', name: 'Emergency squawks', category: 'Air', defaultOn: true,
pollMs: 60000, alertOnHits: true,
endpoints: ['sqk/7700', 'sqk/7600', 'sqk/7500'],
color: (a) => SQUAWK_COLOR[a.squawk] || '#ff453a',
scale: 1.15,
pickTitle: (id) => `${SQUAWK_MEANING[id.squawk] || 'SQUAWK'} ${id.flight || id.hex}`,
describe: (id) => [['Status', SQUAWK_MEANING[id.squawk] || id.squawk || '—'], ...ftRow(id)],
status: (n) => `${n} squawking emergency ⚠`,
emptyStatus: () => 'clear — no emergencies',
},
{
id: 'rare-types', name: 'Rare-type hunter', category: 'Air', defaultOn: false,
pollMs: 120000,
// These are all military types, so filter the shared /v2/mil feed (one cached
// call the military layer already warms) instead of 8 rate-limited /type calls.
endpoints: ['mil'],
filter: (a) => HUNT_TYPES.includes(a.t),
color: () => '#35e0ff',
scale: 0.95,
pickTitle: (id) => `${TYPE_NAMES[id.t] || id.t || 'aircraft'}`,
describe: (id) => [['Aircraft', TYPE_NAMES[id.t] || id.t || '—'], ['Callsign', id.flight || '—'],
['Reg', id.reg || '—'], ['Altitude', `${Math.round(id.altFt || 0)} ft`], ['Speed', `${Math.round(id.gs || 0)} kt`]],
status: (n) => `${n} rare-type aircraft up`,
emptyStatus: () => 'none of the tracked types airborne',
},
{
id: 'shadow', name: 'Blocked / shadow aircraft', category: 'Air', defaultOn: false,
pollMs: 120000,
endpoints: ['ladd', 'pia'],
color: (a) => (a._src === 'pia' ? '#ff7bd5' : '#b57bff'),
scale: 0.8,
pickTitle: (id) => `👤 ${id.reg || id.flight || id.hex}`,
describe: (id) => [
['Program', id.src === 'pia' ? 'PIA (privacy address)' : 'LADD (FAA limited-display)'],
...ftRow(id),
],
status: (n) => `${n} blocked/shadow aircraft`,
},
];

117
js/layers/adsb-layer.js Normal file
View File

@ -0,0 +1,117 @@
// Generic adsb.lol query-layer factory (free OSINT feeds). Powers the emergency
// squawk, rare-type hunter, and shadow-aircraft layers — each is a spec that
// names one or more adsb.lol v2 endpoints (proxy/adsb/<path>), merged by hex and
// drawn as a BillboardCollection primitive with a click overlay. Live-only
// (paused when scrubbed off-now or the tab is hidden), like the aircraft layer.
//
// spec: { id, name, category, defaultOn, pollMs,
// endpoints: ['sqk/7700', 'type/U2', ...], // fetched sequentially (anti-burst)
// color(a) -> hex, scale(a)|number, label(a) -> string|null,
// describe(a) -> [[k,v],...], status(n) -> string, emptyStatus? -> string }
export function createAdsbLayer(ctx, spec) {
const { viewer, Cesium, lib, ui } = ctx;
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
const glyph = lib.aircraftGlyph();
const pollMs = spec.pollMs || 90000;
let enabled = spec.defaultOn !== false;
let isLive = true;
let inFlight = false;
let timer = null;
let lastOk = true;
bc.show = enabled;
ui.addLayer(spec.id, spec.name, enabled, (on) => {
enabled = on;
bc.show = on && isLive;
if (on && isLive) kick();
}, spec.category);
ui.setStatus(spec.id, 'loading…', 'warn');
function canPoll() { return enabled && isLive && !document.hidden; }
function schedule(ms) { if (timer) clearTimeout(timer); timer = setTimeout(tick, ms); }
function kick() { if (!canPoll() || timer || inFlight) return; schedule(0); }
async function tick() {
timer = null;
if (!canPoll() || inFlight) return;
inFlight = true;
try { await poll(); } finally { inFlight = false; }
// On a total failure (e.g. adsb.lol rate-limited the whole batch), retry
// soon instead of waiting the full interval.
if (canPoll()) schedule(lastOk ? pollMs : 9000);
}
const altFeet = (a) => (typeof a.alt_baro === 'number' ? a.alt_baro : 0);
async function poll() {
// Fetch endpoints in PARALLEL and merge by hex. The proxy already spaces the
// upstream calls (rate-limit politeness), so parallel is safe here — and it
// means one slow/hung endpoint (adsb.lol can be flaky) never stalls the rest.
const byHex = new Map();
let anyOk = false;
const results = await Promise.allSettled(
spec.endpoints.map((ep) => fetch(`proxy/adsb/${ep}`).then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.json().then((d) => ({ ep, d }));
}))
);
for (const r of results) {
if (r.status !== 'fulfilled') continue;
anyOk = true;
const { ep, d } = r.value;
for (const a of (Array.isArray(d.ac) ? d.ac : [])) {
if (a.lat == null || a.lon == null || byHex.has(a.hex)) continue;
a._src = ep;
byHex.set(a.hex, a);
}
}
lastOk = anyOk;
if (!anyOk) { ui.setStatus(spec.id, 'busy — retrying shortly', 'warn'); return; }
// Optional client-side filter (e.g. rare-types filters the shared mil feed
// by ICAO type — one cached call, no per-type rate-limit risk).
let list = [...byHex.values()];
if (spec.filter) list = list.filter(spec.filter);
bc.removeAll();
for (const a of list) {
bc.add({
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048),
image: glyph,
color: lib.cz(spec.color(a)),
rotation: lib.headingToRotation(a.track),
scale: typeof spec.scale === 'function' ? spec.scale(a) : (spec.scale || 0.8),
id: {
layer: spec.id, hex: a.hex, flight: (a.flight || '').trim(),
t: a.t, reg: a.r, altFt: altFeet(a), gs: a.gs, track: a.track,
squawk: a.squawk, src: a._src,
},
});
}
const n = list.length;
const txt = n === 0 && spec.emptyStatus ? spec.emptyStatus() : spec.status(n);
ui.setStatus(spec.id, txt, n > 0 && spec.alertOnHits ? 'err' : 'ok');
}
function onClockTick(currentTime, live) {
isLive = live;
if (!isLive) { bc.show = false; return; }
bc.show = enabled;
if (enabled) kick();
}
function handlePick(picked) {
if (picked?.id?.layer !== spec.id) return false;
ui.showPickOverlay(spec.pickTitle ? spec.pickTitle(picked.id) : (picked.id.flight || picked.id.hex || '—'),
spec.describe(picked.id));
return true;
}
function clearPick() { ui.hidePickOverlay(); }
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
kick();
return { id: spec.id, onClockTick, handlePick, clearPick };
}

106
js/layers/radius.js Normal file
View File

@ -0,0 +1,106 @@
// Radius focus (Wave 3.1) — all traffic within 250 nm of where you're looking,
// via adsb.lol's free lat/lon/dist query (proxy/adsb/lat/.../lon/.../dist/250).
// Camera-driven: refetches when you stop panning/zooming, plus a slow refresh
// for freshness. Most useful zoomed into a region. Live-only; off by default.
export default function create(ctx) {
const { viewer, Cesium, lib, ui } = ctx;
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
const glyph = lib.aircraftGlyph();
const DIST_NM = 250; // adsb.lol free radius cap
let enabled = false;
let isLive = true;
let inFlight = false;
let debounce = null;
let poller = null;
let lastKey = null;
bc.show = false;
ui.addLayer('radius', `Traffic near view (${DIST_NM}nm)`, false, (on) => {
enabled = on;
bc.show = on && isLive;
if (on && isLive) { refresh(); ensurePoller(); }
else { clearInterval(poller); poller = null; ui.setStatus('radius', on ? 'locating…' : '', on ? 'warn' : 'ok'); }
});
viewer.camera.percentageChanged = 0.25; // fire camera.changed a bit more eagerly
viewer.camera.changed.addEventListener(() => {
if (!enabled || !isLive) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(refresh, 1800); // refetch after panning stops
});
function ensurePoller() {
if (poller) return;
poller = setInterval(() => { if (enabled && isLive && !document.hidden) refresh(); }, 60000);
}
// Center = camera nadir point, rounded (also improves proxy cache hits).
function center() {
const c = viewer.camera.positionCartographic;
return [
+Cesium.Math.toDegrees(c.latitude).toFixed(2),
+Cesium.Math.toDegrees(c.longitude).toFixed(2),
];
}
async function refresh() {
if (!enabled || !isLive || document.hidden || inFlight) return;
const [lat, lon] = center();
if (!isFinite(lat) || !isFinite(lon)) return;
const key = `${lat}/${lon}`;
lastKey = key;
inFlight = true;
try {
const res = await fetch(`proxy/adsb/lat/${lat}/lon/${lon}/dist/${DIST_NM}`);
if (key !== lastKey) return; // camera moved again mid-fetch — stale
if (!res.ok) { ui.setStatus('radius', `HTTP ${res.status}`, 'warn'); return; }
const data = await res.json();
const list = Array.isArray(data.ac) ? data.ac : [];
bc.removeAll();
let n = 0;
for (const a of list) {
if (a.lat == null || a.lon == null) continue;
const altFt = typeof a.alt_baro === 'number' ? a.alt_baro : 0;
bc.add({
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFt * 0.3048),
image: glyph,
color: lib.cz('#7bff9d'),
rotation: lib.headingToRotation(a.track),
scale: 0.7,
id: { layer: 'radius', hex: a.hex, flight: (a.flight || '').trim(), t: a.t,
reg: a.r, altFt, gs: a.gs, track: a.track },
});
n++;
}
ui.setStatus('radius', `${n} within ${DIST_NM}nm of view`, 'ok');
} catch {
ui.setStatus('radius', 'network error', 'warn');
} finally {
inFlight = false;
}
}
function onClockTick(currentTime, live) {
isLive = live;
if (!isLive) { bc.show = false; return; }
bc.show = enabled;
if (enabled) { refresh(); ensurePoller(); }
}
function handlePick(picked) {
if (picked?.id?.layer !== 'radius') return false;
const id = picked.id;
ui.showPickOverlay('✈ ' + (id.flight || id.hex || '—'), [
['Type', id.t || '—'], ['Reg', id.reg || '—'],
['Altitude', `${Math.round(id.altFt || 0)} ft`], ['Speed', `${Math.round(id.gs || 0)} kt`],
]);
return true;
}
function clearPick() { ui.hidePickOverlay(); }
document.addEventListener('visibilitychange', () => { if (!document.hidden && enabled && isLive) refresh(); });
return { id: 'radius', onClockTick, handlePick, clearPick };
}

View File

@ -20,6 +20,8 @@ import * as lib from './lib.js';
import * as ui from './ui.js'; import * as ui from './ui.js';
import { REGISTRY } from './registry.js'; import { REGISTRY } from './registry.js';
import { createGeoJsonLayer } from './layers/geojson-layer.js'; import { createGeoJsonLayer } from './layers/geojson-layer.js';
import { ADSB_LAYERS } from './adsb-registry.js';
import { createAdsbLayer } from './layers/adsb-layer.js';
const Cesium = window.Cesium; const Cesium = window.Cesium;
@ -178,6 +180,7 @@ const LAYER_MODULES = [
'./layers/ships.js', './layers/ships.js',
'./layers/aircraft.js', './layers/aircraft.js',
'./layers/military.js', './layers/military.js',
'./layers/radius.js',
]; ];
const activeLayers = []; const activeLayers = [];
@ -210,6 +213,16 @@ async function loadLayers() {
console.error(`[godsigh] registry layer ${spec.id} threw during init:`, err); console.error(`[godsigh] registry layer ${spec.id} threw during init:`, err);
} }
} }
// ADS-B OSINT layers (Wave 3.1): emergency squawks, rare-type hunter, shadow.
for (const spec of ADSB_LAYERS) {
try {
const layer = createAdsbLayer(ctx, spec);
if (layer) activeLayers.push(layer);
} catch (err) {
console.error(`[godsigh] adsb layer ${spec.id} threw during init:`, err);
}
}
} }
// ---- live/scrub gating (throttled ~1/s) ---- // ---- live/scrub gating (throttled ~1/s) ----

View File

@ -10,6 +10,7 @@ const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']);
const CATEGORY_BY_ID = { const CATEGORY_BY_ID = {
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', satellites: 'Space', 'sat-paths': 'Space', launches: 'Space',
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air', aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
ships: 'Sea', ships: 'Sea',
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards', quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
infra: 'Context', events: 'Context', infra: 'Context', events: 'Context',

View File

@ -13,6 +13,7 @@ import re
import sqlite3 import sqlite3
import sys import sys
import tempfile import tempfile
import threading
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@ -60,6 +61,28 @@ ADSBLOL_MIL_URL = "https://api.adsb.lol/v2/mil"
ADSBLOL_CACHE_SEC = 60 ADSBLOL_CACHE_SEC = 60
_adsblol_cache = {"body": None, "ts": 0.0} _adsblol_cache = {"body": None, "ts": 0.0}
# ---- Generic adsb.lol query proxy (proxy/adsb/<v2-path>) ---------------------
# Powers the OSINT layers (emergency squawks, rare-type hunter, LADD/PIA shadow
# aircraft, radius focus). The regex is the security allowlist — only these v2
# shapes are forwardable, so it can't be turned into an open proxy/SSRF. Each
# distinct path is cached 60s, and upstream calls are spaced (adsb.lol 429s on
# rapid bursts — the same guard the prod nginx regex-location relies on). The
# prod nginx block mirrors this allowlist; keep the two in sync.
ADSB_PATH_RE = re.compile(
r'^(mil|ladd|pia'
r'|sqk/\d{3,4}'
r'|type/[A-Za-z0-9]{2,5}'
r'|hex/[0-9a-fA-F]{6}'
r'|callsign/[A-Za-z0-9]{1,8}'
r'|reg/[A-Za-z0-9\-]{1,10}'
r'|lat/-?\d{1,3}(\.\d+)?/lon/-?\d{1,3}(\.\d+)?/dist/\d{1,4}(\.\d+)?'
r'|closest/-?\d{1,3}(\.\d+)?/-?\d{1,3}(\.\d+)?/\d{1,4}(\.\d+)?)$')
ADSB_CACHE_SEC = 120
ADSB_MIN_INTERVAL = 1.0 # min seconds between upstream adsb.lol calls (anti-burst; it 429s fast)
_adsb_cache = {} # path -> (body, ts)
_adsb_lock = threading.Lock()
_adsb_next = [0.0] # earliest wall-time the next upstream call may start
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------ # ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and # proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
# HARD-caches the /v2/mil/ response — the quota guard. The $10 BASIC tier bills # HARD-caches the /v2/mil/ response — the quota guard. The $10 BASIC tier bills
@ -176,6 +199,8 @@ class Handler(SimpleHTTPRequestHandler):
name, _, query = self.path[len("/proxy/"):].partition("?") name, _, query = self.path[len("/proxy/"):].partition("?")
if name == "mil": if name == "mil":
return self._serve_mil() return self._serve_mil()
if name.startswith("adsb/"):
return self._serve_adsb(name[len("adsb/"):])
if name == "adsbx-mil": if name == "adsbx-mil":
return self._serve_adsbx_mil() return self._serve_adsbx_mil()
base = UPSTREAMS.get(name) base = UPSTREAMS.get(name)
@ -299,6 +324,41 @@ class Handler(SimpleHTTPRequestHandler):
return self._send(200, "application/json", _adsblol_cache["body"], {"X-Godsigh-Cache": "stale"}) return self._send(200, "application/json", _adsblol_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)}) return self._send_json(502, {"error": str(e)})
def _serve_adsb(self, path):
# Generic adsb.lol v2 query proxy, allowlisted by ADSB_PATH_RE.
if not ADSB_PATH_RE.match(path):
return self._send_json(400, {"error": "unsupported adsb path"})
hit = _adsb_cache.get(path)
if hit and time.time() - hit[1] < ADSB_CACHE_SEC:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
# Reserve a rate-limit slot so call STARTS are spaced ≥ADSB_MIN_INTERVAL,
# but do NOT hold the lock during the fetch — otherwise a big response
# (ladd, ~300 aircraft) would stall every other layer's poll.
with _adsb_lock:
hit = _adsb_cache.get(path) # re-check under lock
if hit and time.time() - hit[1] < ADSB_CACHE_SEC:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
start_at = max(time.time(), _adsb_next[0])
_adsb_next[0] = start_at + ADSB_MIN_INTERVAL
wait = start_at - time.time()
if wait > 0:
time.sleep(wait)
try:
req = urllib.request.Request(f"https://api.adsb.lol/v2/{path}",
headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=12) as r: # fail fast; adsb.lol can hang
body = r.read()
_adsb_cache[path] = (body, time.time())
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(e.code, {"error": f"adsb.lol upstream {e.code}"})
except Exception as e:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _serve_adsbx_mil(self): def _serve_adsbx_mil(self):
host, key = load_adsbx_creds() host, key = load_adsbx_creds()
if not host or not key: if not host or not key: