mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Allow active TLE group, admit proxied same-origin requests, keep longest cooldown
- CelesTrak allowlist includes `active`, which Space Missions' getActiveTle() requests; unknown groups still answer 400. - The CelesTrak gate uses admitSameSiteKeyless so the documented reverse proxy keeps working while foreign/opaque Origins and cross-site Sec-Fetch-Site are still refused with 403. - A provider failure never shortens an existing cooldown (CelesTrak and Launch Library keep the later deadline). - Tests: real getActiveTle() through the middleware, proxied app request vs unrelated Origin, concurrent failures finishing in both orders. - docs/CURRENT-STATE.md: eight groups, keyless gate, non-shortening cooldown. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0caffd8b70
commit
0b5b897c11
5 files changed
+151
-14
No files matched your search
@@ -9,12 +9,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||
search keeps a successful geocode, with no search or view-based region recovery.
|
||||
|
||||
CelesTrak accepts only the seven satellite groups used by the app and gates
|
||||
requests before provider work. CelesTrak and Launch Library failures start a
|
||||
provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds
|
||||
through 30 minutes, with a 60-second fallback. During cooldown dated last-good
|
||||
data is served with stale provenance, otherwise the proxy returns a bounded
|
||||
error without another upstream attempt. Error bodies are cancelled before reading.
|
||||
CelesTrak accepts only the eight satellite groups used by the app (the seven
|
||||
satellite-layer groups plus `active` for Space Missions) and gates cross-site
|
||||
requests before provider work with the keyless same-site policy, so the
|
||||
documented reverse proxy still works. CelesTrak and Launch Library failures
|
||||
start a provider-wide cooldown that a later failure never shortens: Retry-After
|
||||
seconds or HTTP dates are bounded to 5 seconds through 30 minutes, with a
|
||||
60-second fallback. During cooldown dated last-good data is served with stale
|
||||
provenance, otherwise the proxy returns a bounded error without another upstream
|
||||
attempt. Error bodies are cancelled before reading.
|
||||
|
||||
Terrain height requests accept at most 64 points, matching the browser service's
|
||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
|
||||
import { providerCooldownMs } from './cooldown.js';
|
||||
import { admitSameSite } from '../common/same-site.js';
|
||||
import { admitSameSiteKeyless } from '../common/same-site.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
|
||||
/**
|
||||
@@ -34,6 +34,8 @@ export function celestrakProxy() {
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
// Space Missions (`getActiveTle`) reads the full active catalog.
|
||||
'active',
|
||||
]);
|
||||
let cooldownUntil = 0;
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
@@ -77,7 +79,12 @@ export function celestrakProxy() {
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
cooldownUntil = Date.now() + providerCooldownMs(res);
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
cooldownUntil = Math.max(
|
||||
cooldownUntil,
|
||||
Date.now() + providerCooldownMs(res),
|
||||
);
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
@@ -96,7 +103,9 @@ export function celestrakProxy() {
|
||||
const group = String(req.url || '')
|
||||
.replace(/^\//, '')
|
||||
.split('?')[0];
|
||||
if (admitSameSite(req, res)) return;
|
||||
// Keyless and needed to render the satellites layer: refuse cross-site
|
||||
// browsers but keep the documented reverse-proxy deployment working.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405);
|
||||
res.end();
|
||||
|
||||
@@ -90,8 +90,12 @@ export function rocketLaunchesProxy() {
|
||||
headers: launchLibraryRequestHeaders(),
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
cooldownUntil = Date.now() + providerCooldownMs(upstream);
|
||||
cooldownStatus = upstream.status;
|
||||
const deadline = Date.now() + providerCooldownMs(upstream);
|
||||
// Never shorten a longer cooldown that is already in force.
|
||||
if (deadline >= cooldownUntil) {
|
||||
cooldownUntil = deadline;
|
||||
cooldownStatus = upstream.status;
|
||||
}
|
||||
void upstream.body?.cancel().catch(() => {});
|
||||
const error = new Error(`upstream HTTP ${upstream.status}`);
|
||||
error.upstreamStatus = upstream.status;
|
||||
|
||||
@@ -25,7 +25,6 @@ function fixture(name, overrides = {}, preview = false) {
|
||||
const logs = [];
|
||||
const deps = {
|
||||
readResponseTextCapped, coalesceProxyRequest, providerCooldownMs,
|
||||
admitSameSite: () => false,
|
||||
// The rate-limited proxies reach for these; give them the real ones so a
|
||||
// fixture request is throttled exactly as production would be.
|
||||
makeRateLimiter, clientKey,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
launchLibraryRecentUrl,
|
||||
} from 'gods-eye-view/sources/space';
|
||||
import * as compatibility from '../../server/providers/local.js';
|
||||
import { createLaunchSource } from 'gods-eye-view/layers/launches/source';
|
||||
|
||||
function install(plugin, preview = false) {
|
||||
const routes = new Map();
|
||||
@@ -22,17 +23,23 @@ function install(plugin, preview = false) {
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (route, url = '/', method = 'GET') => {
|
||||
return async (route, url = '/', method = 'GET', headers = {}) => {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
headers: {},
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers, headersSent: true });
|
||||
},
|
||||
// The same-site gates answer 403 through statusCode + setHeader.
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
this.status ??= this.statusCode;
|
||||
},
|
||||
};
|
||||
await routes.get(route)({ url, method }, res);
|
||||
await routes.get(route)({ url, method, headers, socket: {} }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -270,3 +277,118 @@ test('space providers honor Retry-After, gate groups, preserve stale dates and r
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => {
|
||||
isolateDisk(t);
|
||||
const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n';
|
||||
const fetched = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
fetched.push(new URL(url).searchParams.get('GROUP'));
|
||||
return new Response(tle);
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
// Route the browser source's real request into the middleware.
|
||||
const source = createLaunchSource({
|
||||
fetchImpl: async (url) => {
|
||||
const prefix = '/api/celestrak';
|
||||
assert.ok(url.startsWith(`${prefix}/`));
|
||||
const res = await request(prefix, url.slice(prefix.length));
|
||||
return new Response(res.body, { status: res.status });
|
||||
},
|
||||
});
|
||||
assert.equal(await source.getActiveTle(), tle);
|
||||
assert.deepEqual(fetched, ['active']);
|
||||
for (const group of ['/not-a-group', '/active-extra', '/../active'])
|
||||
assert.equal((await request('/api/celestrak', group)).status, 400);
|
||||
assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream');
|
||||
});
|
||||
|
||||
test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => {
|
||||
isolateDisk(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n');
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
const proxied = {
|
||||
host: 'gev.example.test',
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'x-forwarded-host': 'gev.example.test',
|
||||
'x-forwarded-proto': 'https',
|
||||
via: '1.1 proxy',
|
||||
};
|
||||
const app = await request('/api/celestrak', '/stations', 'GET', {
|
||||
...proxied,
|
||||
origin: 'https://gev.example.test',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(app.status, 200);
|
||||
assert.equal(app.headers['x-tle-cache'], 'MISS');
|
||||
assert.equal(calls, 1);
|
||||
for (const headers of [
|
||||
{ ...proxied, origin: 'https://unrelated.example' },
|
||||
{ ...proxied, origin: 'null' },
|
||||
{ ...proxied, 'sec-fetch-site': 'cross-site' },
|
||||
]) {
|
||||
const refused = await request('/api/celestrak', '/visual', 'GET', headers);
|
||||
assert.equal(refused.status, 403);
|
||||
}
|
||||
assert.equal(calls, 1, 'refused requests do no provider or cache work');
|
||||
});
|
||||
|
||||
for (const longFirst of [true, false])
|
||||
test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => {
|
||||
isolateDisk(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const pending = new Map();
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
calls++;
|
||||
const group = new URL(url).searchParams.get('GROUP');
|
||||
await new Promise((resolve) => pending.set(group, resolve));
|
||||
return group === 'stations'
|
||||
? new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '1800' },
|
||||
})
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
const long = request('/api/celestrak', '/stations');
|
||||
const short = request('/api/celestrak', '/visual');
|
||||
while (pending.size < 2) await new Promise(setImmediate);
|
||||
const order = longFirst
|
||||
? [
|
||||
['stations', long],
|
||||
['visual', short],
|
||||
]
|
||||
: [
|
||||
['visual', short],
|
||||
['stations', long],
|
||||
];
|
||||
for (const [group, response] of order) {
|
||||
pending.get(group)();
|
||||
assert.equal((await response).status, 502);
|
||||
}
|
||||
assert.equal(calls, 2);
|
||||
const cooling = await request('/api/celestrak', '/geo');
|
||||
assert.equal(cooling.status, 502);
|
||||
assert.equal(cooling.headers['Retry-After'], '1800');
|
||||
now += 61_000;
|
||||
assert.equal((await request('/api/celestrak', '/geo')).status, 502);
|
||||
assert.equal(
|
||||
calls,
|
||||
2,
|
||||
'the 60 s fallback did not replace the 30 min deadline',
|
||||
);
|
||||
now += 1800_000;
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'),
|
||||
);
|
||||
assert.equal((await request('/api/celestrak', '/geo')).status, 200);
|
||||
});
|
||||
Reference in new issue
Block a user