mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(overpass): keep proxied traffic out of the direct-loopback admission reserve; keep a measured arrival-angle failure through a reveal timeout
- /api/overpass: a loopback socket with forwarding headers (a local reverse proxy) is admitted from the remote pool under a socket-keyed proxied: bucket, never the owner's direct-loopback window; the upstream limiter uses the same key. Regression: owner cache hits survive an exhausted proxy bucket. - Arrival check: a reveal timeout keeps the settle angle it measured; the new observeArrivalRotationWithRetry retries only inconclusive timeouts, so a real violation cannot be retried into a pass. Regression: wrong settle angle -> reveal timeout -> clean second attempt still fails. - SECURITY.md, README, changelog and CURRENT-STATE: scope the guarantee to admission isolation, name the forwarding headers a local proxy must send, and state that the upstream budget stays shared.
This commit is contained in:
1 parent
7a421c35fc
commit
8f16d10f7a
9 files changed
+295
-67
No files matched your search
@@ -197,7 +197,7 @@ env UID=1026 GID=100 docker compose up -d
|
||||
|
||||
Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you.
|
||||
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). If the proxy runs on the same machine, make it send a forwarding header such as `X-Forwarded-For` (Caddy does by default; nginx needs `proxy_set_header`), so its users are rate-limited apart from your own browser.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all non-loopback peers before it reads a body; loopback has its own per-peer window, so LAN peers cannot lock the owner out.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-*`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` or similar), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`; cache hits are unaffected.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
||||
now uses the keyless same-site gate, admits forwarding headers and still
|
||||
refuses cross-site requests; loopback keeps its own admission window and
|
||||
IPv6 peers are keyed by /64, so a few LAN peers cannot lock the owner out
|
||||
(asturksever, #1308).
|
||||
refuses cross-site requests. Direct loopback requests keep their own
|
||||
admission window; proxied requests, including those a local reverse proxy
|
||||
relays over loopback, are admitted as remote traffic keyed by their socket,
|
||||
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
||||
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||
cache miss can still be rate-limited (asturksever, #1308).
|
||||
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||
retry an inconclusive timeout, so slow software-rendered frames no longer
|
||||
fail it (asturksever, #1308).
|
||||
retry only an inconclusive timeout: a measured wrong settle angle is kept
|
||||
through a reveal timeout and fails without retry, so slow software-rendered
|
||||
frames no longer fail the check and a real violation is never retried into a
|
||||
pass (asturksever, #1308).
|
||||
@@ -2843,9 +2843,12 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
|
||||
> per peer — IPv6 peers keyed by /64 — and 900/min across all non-loopback
|
||||
> peers before reading or validating bodies; loopback has its own 300/min
|
||||
> window outside that global.
|
||||
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
|
||||
> before reading or validating bodies. Direct loopback (no forwarding
|
||||
> headers) has its own 300/min window outside that global; a local reverse
|
||||
> proxy's requests carry forwarding headers and are counted as remote, keyed
|
||||
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
|
||||
> quota stays shared, so owner cache misses can still hit 429.
|
||||
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||
@@ -5122,7 +5125,7 @@ pins and share links already supply complete boxes.
|
||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout that already measured a wrong settle angle keeps it and fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
|
||||
### TR-3B conversion Easter egg (August 2026)
|
||||
|
||||
|
||||
@@ -2708,7 +2708,7 @@ async function main() {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2774,39 +2774,39 @@ async function main() {
|
||||
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
|
||||
// landed where the 1 s idle refresh could already have repaired it
|
||||
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
|
||||
// observation gets one more attempt; a wrong first angle never does.
|
||||
let observed;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
// Freshen both layers' rotation passes right before the probe, so the
|
||||
// settle frame is measured against a pass that just ran rather than
|
||||
// one left over from the previous probe — with the camera idle, a
|
||||
// layer otherwise runs a pass only every ROTATION_REFRESH_MS.
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(1);
|
||||
observed = await observeArrivalRotation({
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
},
|
||||
startReveal() {
|
||||
// Hide immediately after the confirmed settle frame. The next
|
||||
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
shown: bb.show,
|
||||
rotationError: angDiff(bb.rotation,
|
||||
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||
};
|
||||
},
|
||||
});
|
||||
observed.attempts = attempt;
|
||||
if (!observed.error) break;
|
||||
}
|
||||
// observation gets one more attempt; one that measured a wrong angle —
|
||||
// a wrong first reveal, or a wrong settle frame before a reveal
|
||||
// timeout — never does.
|
||||
const observed = await observeArrivalRotationWithRetry({
|
||||
async prepare() {
|
||||
// Freshen both layers' rotation passes right before the probe, so
|
||||
// the settle frame is measured against a pass that just ran rather
|
||||
// than one left over from the previous probe — with the camera
|
||||
// idle, a layer otherwise runs a pass only every
|
||||
// ROTATION_REFRESH_MS.
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(1);
|
||||
},
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
},
|
||||
startReveal() {
|
||||
// Hide immediately after the confirmed settle frame. The next
|
||||
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
shown: bb.show,
|
||||
rotationError: angDiff(bb.rotation,
|
||||
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||
};
|
||||
},
|
||||
});
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
@@ -2816,7 +2816,7 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
clientNetworkKey,
|
||||
isLoopbackPeer,
|
||||
} from './common/rate-limit.js';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import {
|
||||
@@ -34,12 +35,17 @@ const _overpassInFlight = new Map();
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
// Bound body-reading and validation work, including malformed requests and
|
||||
// cache hits, before a byte is read. Non-loopback peers share one global
|
||||
// window so address rotation cannot mint admission without bound (IPv6 peers
|
||||
// are keyed by /64 for the same reason); loopback — the owner's own browser
|
||||
// and the repo QA harnesses — has its own per-peer window outside that global,
|
||||
// so a few busy LAN peers cannot lock the owner out of the map. Keep the
|
||||
// tighter upstream budget below independent of this gate.
|
||||
// cache hits, before a byte is read. Remote peers share one global window so
|
||||
// address rotation cannot mint admission without bound (IPv6 peers are keyed
|
||||
// by /64 for the same reason). A direct loopback request — the owner's own
|
||||
// browser and the repo QA harnesses, with no forwarding headers — has its own
|
||||
// per-peer window outside that global, so busy LAN peers cannot lock the owner
|
||||
// out of the map. A request relayed by a reverse proxy on this machine also
|
||||
// arrives from loopback, but it is remote traffic: it is admitted from the
|
||||
// remote pool under its own socket-keyed bucket, never from the owner's. The
|
||||
// forwarded client address is not trusted as identity. Only admission is
|
||||
// isolated: the upstream budget below (90/min per key, 300/min globally) is
|
||||
// shared, so an owner request that misses cache can still be refused there.
|
||||
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
|
||||
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
|
||||
const _overpassPeerAdmission = makeRateLimiter({
|
||||
@@ -54,17 +60,30 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
});
|
||||
|
||||
/**
|
||||
* Spend one admission slot for this request's peer; false when it is over its
|
||||
* window (or, for a non-loopback peer, when the shared global window is full).
|
||||
* Classify a request for admission. Direct loopback — a loopback socket with
|
||||
* no forwarding header — is the owner's own pool; everything else, including a
|
||||
* loopback socket that carries forwarding headers (a local reverse proxy), is
|
||||
* remote. A proxied request keeps its socket identity under a `proxied:`
|
||||
* prefix, so it never shares a bucket with the owner's direct requests.
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {string} peer - `clientNetworkKey(req)`.
|
||||
* @returns {{direct: boolean, key: string}}
|
||||
*/
|
||||
function overpassAdmissionClass(req) {
|
||||
const peer = clientNetworkKey(req);
|
||||
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
|
||||
if (hasProxySignals(req.headers || {}))
|
||||
return { direct: false, key: `proxied:${peer}` };
|
||||
return { direct: true, key: peer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend one admission slot for this request; false when its bucket is over its
|
||||
* window (or, for remote traffic, when the shared global window is full).
|
||||
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function admitOverpassRequest(req, peer) {
|
||||
const admit = isLoopbackPeer(clientKey(req))
|
||||
? _overpassLoopbackAdmission
|
||||
: _overpassPeerAdmission;
|
||||
return admit(peer);
|
||||
function admitOverpassRequest({ direct, key }) {
|
||||
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,8 +152,8 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// adds — the layers that depend on Overpass must keep working for an
|
||||
// instance deployed the way the README documents.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const peer = clientNetworkKey(req);
|
||||
if (!admitOverpassRequest(req, peer)) {
|
||||
const admission = overpassAdmissionClass(req);
|
||||
if (!admitOverpassRequest(admission)) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -193,7 +212,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(peer),
|
||||
allowUpstream: () => _overpassRateLimiter(admission.key),
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, {
|
||||
|
||||
@@ -320,3 +320,71 @@ test('cache hits spend admission budget but not the 90/min upstream budget', asy
|
||||
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
|
||||
assert.equal(exhausted.reads, 0);
|
||||
});
|
||||
|
||||
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
|
||||
let now = Date.now() + 14_400_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
const hit = 'node(around:20,3,3);out;';
|
||||
const hitKey = `data=${encodeURIComponent(hit)}`;
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
_overpassCache.delete(hitKey);
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
_overpassCache.set(hitKey, {
|
||||
status: 200,
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: now,
|
||||
});
|
||||
const route = handler();
|
||||
// The proxy connects over loopback; a rotating forwarded client address
|
||||
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
|
||||
const proxied = (i) => ({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
|
||||
body: form(hit),
|
||||
});
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, proxied(i))).statusCode, 200);
|
||||
}
|
||||
const refused = await call(route, { ...proxied(300), forbidRead: true });
|
||||
assert.equal(refused.statusCode, 429, 'the proxy bucket is exhausted');
|
||||
assert.equal(refused.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1']) {
|
||||
const owner = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
|
||||
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
now += 60_001;
|
||||
assert.equal((await call(route, proxied(0))).statusCode, 200);
|
||||
});
|
||||
|
||||
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
|
||||
let now = Date.now() + 18_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
for (const peer of ['10.0.0.1', '10.0.0.2']) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, { peer })).statusCode, 400, peer);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal(
|
||||
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
|
||||
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
|
||||
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
|
||||
});
|
||||
@@ -10,6 +10,12 @@
|
||||
*/
|
||||
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||
|
||||
/** A measured rotation error at or above this fails the arrival check. */
|
||||
export const ARRIVAL_MAX_ERROR_DEG = 10;
|
||||
|
||||
/** Attempts allowed when every earlier one timed out without a wrong angle. */
|
||||
export const ARRIVAL_MAX_ATTEMPTS = 2;
|
||||
|
||||
/**
|
||||
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||
* The settle deadline starts when moveEnd is forced, before its rotation
|
||||
@@ -45,10 +51,13 @@ export function observeArrivalRotation({
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
// A reveal timeout keeps the settle angle it already measured, so a wrong
|
||||
// settle frame is still visible to the caller and cannot be retried away.
|
||||
const expired = () => ({
|
||||
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||
elapsedMs: now() - startedAt,
|
||||
moveEndMs,
|
||||
...(moveEnd ? { dMoveEnd: moveEnd.rotationError } : {}),
|
||||
flipFrame: -1,
|
||||
});
|
||||
const armDeadline = () => {
|
||||
@@ -105,6 +114,41 @@ export function observeArrivalRotation({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an observation measured a rotation error over the threshold on any
|
||||
* frame it captured, including the settle frame of a reveal that then timed
|
||||
* out. Such an observation is a demonstrated failure, never inconclusive.
|
||||
*/
|
||||
export function arrivalAngleViolated(observation) {
|
||||
const limit = (ARRIVAL_MAX_ERROR_DEG * Math.PI) / 180;
|
||||
return [observation?.dMoveEnd, observation?.dReveal].some(
|
||||
(error) => Number.isFinite(error) && error >= limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe arrival rotation, retrying only an inconclusive timeout. A wall-clock
|
||||
* timeout means the frame landed where the idle refresh could already have
|
||||
* repaired it, so it earns another attempt; an observation that measured a
|
||||
* wrong angle (or succeeded) is final, so a later clean attempt can never
|
||||
* replace it. `prepare` runs before every attempt; the result carries
|
||||
* `attempts`.
|
||||
*/
|
||||
export async function observeArrivalRotationWithRetry({
|
||||
prepare = async () => {},
|
||||
observe = observeArrivalRotation,
|
||||
maxAttempts = ARRIVAL_MAX_ATTEMPTS,
|
||||
...options
|
||||
}) {
|
||||
let observed;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
await prepare();
|
||||
observed = { ...(await observe(options)), attempts: attempt };
|
||||
if (!observed.error || arrivalAngleViolated(observed)) break;
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
/** Test the captured frames without retrying a bad first observation. */
|
||||
export function arrivalRotationPassed(observation) {
|
||||
return (
|
||||
@@ -113,7 +157,8 @@ export function arrivalRotationPassed(observation) {
|
||||
observation.shown === true &&
|
||||
observation.moveEndMs < ARRIVAL_OBSERVATION_MS &&
|
||||
observation.revealMs < ARRIVAL_OBSERVATION_MS &&
|
||||
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||
(observation.dReveal * 180) / Math.PI < 10
|
||||
Number.isFinite(observation.dMoveEnd) &&
|
||||
Number.isFinite(observation.dReveal) &&
|
||||
!arrivalAngleViolated(observation)
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,20 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ARRIVAL_OBSERVATION_MS,
|
||||
arrivalAngleViolated,
|
||||
arrivalRotationPassed,
|
||||
observeArrivalRotation,
|
||||
observeArrivalRotationWithRetry,
|
||||
} from './trackArrivalObservation.mjs';
|
||||
|
||||
function fixture() {
|
||||
function fixture(start = observeArrivalRotation) {
|
||||
let time = 0;
|
||||
let state = { shown: true, rotationError: 0 };
|
||||
const listeners = new Set();
|
||||
const timers = new Map();
|
||||
let renders = 0;
|
||||
let moveEnds = 0;
|
||||
const observation = observeArrivalRotation({
|
||||
const observation = start({
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
@@ -176,3 +178,89 @@ test('missing moveEnd frames fail without waiting for a render callback', async
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
// Let a retry's next attempt register its postRender listener.
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function retryFixture() {
|
||||
const counts = { prepares: 0 };
|
||||
const f = fixture((options) =>
|
||||
observeArrivalRotationWithRetry({
|
||||
...options,
|
||||
async prepare() {
|
||||
counts.prepares++;
|
||||
},
|
||||
}),
|
||||
);
|
||||
f.counts = counts;
|
||||
return f;
|
||||
}
|
||||
|
||||
test('a wrong settle angle followed by a reveal timeout is final, not retried into a pass', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
// 0.5 rad ≈ 29°: a real threshold violation on the settle frame.
|
||||
f.frame(8, { shown: true, rotationError: 0.5 });
|
||||
f.expire(508); // the reveal never comes inside its budget
|
||||
await settle();
|
||||
// An otherwise clean second attempt, which must never be started.
|
||||
f.frame(516, { shown: true, rotationError: 0 });
|
||||
f.frame(524, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.dMoveEnd, 0.5, 'the measured settle angle is kept');
|
||||
assert.equal(arrivalAngleViolated(result), true);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(f.counts.prepares, 1);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
test('an inconclusive reveal timeout after a correct settle earns one more attempt', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.expire(508);
|
||||
await settle();
|
||||
f.frame(516, { shown: true, rotationError: 0 });
|
||||
f.frame(596, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(f.counts.prepares, 2);
|
||||
assert.equal(f.resources.moveEnds, 2);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
});
|
||||
|
||||
test('timeouts stop after the attempt limit and still fail', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.expire(500);
|
||||
await settle();
|
||||
f.expire(1000);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
test('a missing or non-finite angle never passes', () => {
|
||||
const clean = {
|
||||
shown: true,
|
||||
moveEndMs: 8,
|
||||
revealMs: 80,
|
||||
dMoveEnd: 0,
|
||||
dReveal: 0,
|
||||
};
|
||||
assert.equal(arrivalRotationPassed(clean), true);
|
||||
for (const field of ['dMoveEnd', 'dReveal']) {
|
||||
assert.equal(arrivalRotationPassed({ ...clean, [field]: NaN }), false);
|
||||
assert.equal(
|
||||
arrivalRotationPassed({ ...clean, [field]: undefined }),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user