openvibers/openvibe.media Commits
trust proxy is loopback only (was true): req.ip is the X-Forwarded-For that the local nginx set when a request came through it, and the socket address for anyone else, so a caller reaching port 4100 directly (HOST defaults to 0.0.0.0) can no longer choose its IP. The view counter read CF-Connecting-IP / X-Forwarded-For itself, from any peer; it now keys on req.ip through server/client-ip.js, like paste limits and the live-frame rate limit. The unused getRequesterIp header reader in public/routes.js is gone. test/trust-proxy.test.js checks the setting on an app (a loopback proxy's forwarded address is believed, a non-loopback peer's X-Forwarded-For and CF-Connecting-IP are not), and boots the real server: forwarded clients get their own live-frame budgets, while a direct caller rotating X-Forwarded-For is rate-limited as one address (it fails with trust proxy true). README: client addresses, and the new jobs and scripts in the layout.
Changes
@@@ -49,12 +49,17 @@ server/change's transaction; payload queue.jobEvent: ids, state, ISO times,has_result, never params/result/error text/creator), worker (light/heavy lanes, leases, retries,cancellation), routes (/api/v2/:app/jobs), types: thumbnail.regenerate,-- invariant.scan (proposes split/remux), object.split, object.remux++ invariant.scan (proposes split/remux), object.split, object.remux,++ vod.finalize (orphans and failed finalizes, with backoff),++ vod.duration.reconcile (stored vs measured durations, local and B2/R2)++ client-ip.js trust proxy = loopback; req.ip is the only client address(openvibe-shared v1.5.1, openvibe-contracts v0.33.0, openvibe-sdk v0.5.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/backfill-objects.js / reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposalsscripts/r2-eviction-drill.js evict one VOD's R2 copy, prove B2 serves it, re-warm R2; JSON artifact (dry run by default)++scripts/vod-duration-reconcile.js stored VOD durations vs the real files (dry run by default; --apply --backup; --rollback)++scripts/vods-orphans-report.js read-only report on the B2 vods-orphans/ prefix (matches, sizes, recommendations)```## Visitor sign-in@@@ -300,6 +305,14 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URLand checks each point.++## Client addresses++++Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the++local nginx set when the request came through it, and the socket address for anyone else. A caller++that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or++`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,++never the headers (`test/trust-proxy.test.js`).++## Health, readiness and metrics| path | behavior |nnew file mode 100644@@@ -0,0 +1,19 @@++/**++ * OpenVibe.Media — who the client is.++ *++ * Media sits behind nginx on the same host (deploy/nginx/openvibe.media.conf), which sets++ * X-Forwarded-For / X-Real-IP / CF-Connecting-IP from its own $remote_addr. Only that loopback peer++ * is trusted (Express 'trust proxy' = loopback): req.ip is the forwarded address when the request++ * came through the local proxy and the socket address otherwise, so a caller that reaches the port++ * directly cannot choose its IP by sending the headers itself. Code reads req.ip, never the headers.++ */++'use strict';++++const TRUST_PROXY = 'loopback';++++/** The client's address as Express resolved it under TRUST_PROXY. */++function clientIp(req) {++ return String((req && (req.ip || (req.socket && req.socket.remoteAddress))) || 'unknown');++}++++module.exports = { TRUST_PROXY, clientIp };@@@ -48,7 +48,10 @@ if (!drill.enabled) {}const app = express();--app.set('trust proxy', true);++// Only the local reverse proxy (nginx on this host) is trusted to say who the client is: req.ip is++// its X-Forwarded-For for loopback peers and the socket address for anyone else, so a direct caller++// cannot pick its own IP (views, rate limits). `true` trusted every peer's header.++app.set('trust proxy', require('./client-ip').TRUST_PROXY);// Metrics first, so every route below is measured (GET /metrics: loopback callers only).const release = require('openvibe-shared/release').createRelease({ service: 'media', root: require('path').join(__dirname, '..') });const observability = require('./observability');@@@ -38,10 +38,6 @@ const MIME_TYPES = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',};--function getRequesterIp(req) {-- return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';--}--const views = require('../views/service');// Views + unique views live in ../views/service (visit-based with a cooldown, hashed// visitors, owner/bot/rate-limit exclusions). A playback session counts once: only the@@@ -85,9 +85,9 @@ function ensureSchema() {} catch { /* */ }}--function clientIp(req) {-- return (req.headers['cf-connecting-ip'] || (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.ip || (req.socket && req.socket.remoteAddress) || 'unknown').toString();--}++// req.ip under 'trust proxy' = loopback (server/client-ip.js): the forwarding headers count only++// when the local proxy sent them, so a direct caller cannot pose as many visitors.++const { clientIp } = require('../client-ip');function visitorForIp(ip) { return 'ip:' + crypto.createHmac('sha256', secret()).update(String(ip || 'unknown')).digest('hex').slice(0, 24); }// Per-IP event budget (sliding minute), in memory — protects the DB from a spammer, not a metric.nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++// 'trust proxy' is loopback only (server/client-ip.js, server/index.js): a request that came through++// the local nginx is attributed to the X-Forwarded-For address nginx set, and a caller reaching the++// port directly cannot choose its IP by sending X-Forwarded-For / CF-Connecting-IP itself (views and++// rate limits key on req.ip). The real server is booted to prove index.js applies it.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const net = require('net');++const path = require('path');++const http = require('http');++const { spawn } = require('child_process');++const express = require('express');++++const { TRUST_PROXY, clientIp } = require('../server/client-ip');++++const get = (host, port, p, headers = {}) => new Promise((resolve, reject) => {++ const req = http.request({ host, port, path: p, headers }, (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: b })); });++ req.on('error', reject);++ req.end();++});++const externalAddress = () => {++ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) return a.address;++ return null;++};++++(async () => {++ assert.strictEqual(TRUST_PROXY, 'loopback');++++ // ── 1. The setting, on an app like index.js's: loopback proxies are believed, nobody else ──++ const app = express();++ app.set('trust proxy', TRUST_PROXY);++ app.get('/ip', (req, res) => res.json({ ip: req.ip, client: clientIp(req) }));++ const server = await new Promise((r) => { const s = app.listen(0, '0.0.0.0', () => r(s)); });++ const port = server.address().port;++ let r = JSON.parse((await get('127.0.0.1', port, '/ip', { 'X-Forwarded-For': '203.0.113.7' })).body);++ assert.deepStrictEqual([r.ip, r.client], ['203.0.113.7', '203.0.113.7'], 'through the local proxy: the forwarded client');++ r = JSON.parse((await get('127.0.0.1', port, '/ip')).body);++ assert.ok(/127\.0\.0\.1$/.test(r.ip), `loopback caller without a proxy header: ${r.ip}`);++ const ext = externalAddress();++ if (ext) {++ r = JSON.parse((await get(ext, port, '/ip', { 'X-Forwarded-For': '198.51.100.9', 'CF-Connecting-IP': '198.51.100.9' })).body);++ assert.ok(r.ip.endsWith(ext) && r.client.endsWith(ext), `a direct caller cannot choose its IP (got ${r.ip})`);++ console.log('✅ a non-loopback peer\'s X-Forwarded-For / CF-Connecting-IP are ignored');++ } else {++ console.log('⚠️ no non-loopback interface: the direct-caller check is skipped');++ }++ server.close();++ // The view counter keys on req.ip, not on the headers.++ assert.strictEqual(require('../server/views/service').clientIp({ ip: '192.0.2.4', headers: { 'cf-connecting-ip': '9.9.9.9', 'x-forwarded-for': '8.8.8.8' } }), '192.0.2.4');++ assert.strictEqual(clientIp({ socket: { remoteAddress: '::1' }, headers: {} }), '::1');++ console.log('✅ trust proxy loopback: the local proxy\'s forwarded address, else the socket; views key on req.ip');++++ // ── 2. index.js applies it (the real server, booted) ──++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');++ assert.ok(!/set\('trust proxy',\s*true\)/.test(src), 'no trust-everyone left');++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-trust-'));++ const freePort = await new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });++ const env = { ...process.env, DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: path.join(tmp, 'vods'), CLIPS_PATH: path.join(tmp, 'clips'), PASTES_PATH: path.join(tmp, 'pastes'),++ THUMBNAILS_PATH: path.join(tmp, 'thumbs'), FILES_PATH: path.join(tmp, 'files'), OBJECTS_PATH: path.join(tmp, 'objects'), PORT: String(freePort), HOST: ext ? '0.0.0.0' : '127.0.0.1',++ NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '', MEDIA_APP_KEYS: 'live:live-key-for-trust-test' };++ const child = spawn(process.execPath, [path.join(__dirname, '../server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });++ let log = '';++ child.stdout.on('data', (d) => { log += d; });++ child.stderr.on('data', (d) => { log += d; });++ let up = false;++ for (let i = 0; i < 100 && !up; i++) {++ try { up = (await get('127.0.0.1', freePort, '/healthz')).status === 200; } catch { await new Promise((res) => setTimeout(res, 100)); }++ }++ assert.ok(up, `the server booted: ${log.slice(-500)}`);++ // /metrics answers loopback callers only and refuses anything that came through a proxy; with++ // trust proxy loopback, Express agrees the request came from the local proxy.++ const direct = await get('127.0.0.1', freePort, '/metrics');++ const proxied = await get('127.0.0.1', freePort, '/metrics', { 'X-Forwarded-For': '203.0.113.7' });++ assert.strictEqual(direct.status, 200);++ assert.strictEqual(proxied.status, 404, 'a proxied caller is not local');++ // The live-frame endpoint rate-limits per req.ip (burst 30). Through the local proxy every++ // forwarded client has its own bucket; a direct caller rotating X-Forwarded-For does not.++ const burst = async (host, n) => {++ let limited = 0;++ for (let i = 0; i < n; i++) {++ const res = await get(host, freePort, '/live/1/frame.jpg?format=json', { 'X-Forwarded-For': `198.51.100.${i + 1}` });++ if (res.status === 429) limited++;++ }++ return limited;++ };++ assert.strictEqual(await burst('127.0.0.1', 45), 0, 'forwarded clients each get their own budget');++ if (ext) {++ const limited = await burst(ext, 45);++ assert.ok(limited > 0, 'a direct caller cannot mint new identities with X-Forwarded-For (the real server trusts loopback only)');++ }++ child.kill('SIGTERM');++ await new Promise((res) => child.on('exit', res));++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('✅ the booted server runs with trust proxy loopback');++++ console.log('\n✅ All trust-proxy tests passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -49,12 +49,17 @@ server/
@@ -49,12 +49,17 @@ server/
change's transaction; payload queue.jobEvent: ids, state, ISO times,
change's transaction; payload queue.jobEvent: ids, state, ISO times,
has_result, never params/result/error text/creator), worker (light/heavy lanes, leases, retries,
has_result, never params/result/error text/creator), worker (light/heavy lanes, leases, retries,
cancellation), routes (/api/v2/:app/jobs), types: thumbnail.regenerate,
cancellation), routes (/api/v2/:app/jobs), types: thumbnail.regenerate,
- invariant.scan (proposes split/remux), object.split, object.remux
+ invariant.scan (proposes split/remux), object.split, object.remux,
+ vod.finalize (orphans and failed finalizes, with backoff),
+ vod.duration.reconcile (stored vs measured durations, local and B2/R2)
+ client-ip.js trust proxy = loopback; req.ip is the only client address
(openvibe-shared v1.5.1, openvibe-contracts v0.33.0, openvibe-sdk v0.5.0: pinned release tarballs, installed by npm)
(openvibe-shared v1.5.1, openvibe-contracts v0.33.0, openvibe-sdk v0.5.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/backfill-objects.js / reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/backfill-objects.js / reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/r2-eviction-drill.js evict one VOD's R2 copy, prove B2 serves it, re-warm R2; JSON artifact (dry run by default)
scripts/r2-eviction-drill.js evict one VOD's R2 copy, prove B2 serves it, re-warm R2; JSON artifact (dry run by default)
+scripts/vod-duration-reconcile.js stored VOD durations vs the real files (dry run by default; --apply --backup; --rollback)
+scripts/vods-orphans-report.js read-only report on the B2 vods-orphans/ prefix (matches, sizes, recommendations)
```
```
## Visitor sign-in
## Visitor sign-in
@@ -300,6 +305,14 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED
@@ -300,6 +305,14 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED
`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URL
`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URL
and checks each point.
and checks each point.
+## Client addresses
+
+Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
+local nginx set when the request came through it, and the socket address for anyone else. A caller
+that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or
+`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,
+never the headers (`test/trust-proxy.test.js`).
+
## Health, readiness and metrics
## Health, readiness and metrics
| path | behavior |
| path | behavior |
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+/**
+ * OpenVibe.Media — who the client is.
+ *
+ * Media sits behind nginx on the same host (deploy/nginx/openvibe.media.conf), which sets
+ * X-Forwarded-For / X-Real-IP / CF-Connecting-IP from its own $remote_addr. Only that loopback peer
+ * is trusted (Express 'trust proxy' = loopback): req.ip is the forwarded address when the request
+ * came through the local proxy and the socket address otherwise, so a caller that reaches the port
+ * directly cannot choose its IP by sending the headers itself. Code reads req.ip, never the headers.
+ */
+'use strict';
+
+const TRUST_PROXY = 'loopback';
+
+/** The client's address as Express resolved it under TRUST_PROXY. */
+function clientIp(req) {
+ return String((req && (req.ip || (req.socket && req.socket.remoteAddress))) || 'unknown');
+}
+
+module.exports = { TRUST_PROXY, clientIp };
@@ -48,7 +48,10 @@ if (!drill.enabled) {
@@ -48,7 +48,10 @@ if (!drill.enabled) {
}
}
const app = express();
const app = express();
-app.set('trust proxy', true);
+// Only the local reverse proxy (nginx on this host) is trusted to say who the client is: req.ip is
+// its X-Forwarded-For for loopback peers and the socket address for anyone else, so a direct caller
+// cannot pick its own IP (views, rate limits). `true` trusted every peer's header.
+app.set('trust proxy', require('./client-ip').TRUST_PROXY);
// Metrics first, so every route below is measured (GET /metrics: loopback callers only).
// Metrics first, so every route below is measured (GET /metrics: loopback callers only).
const release = require('openvibe-shared/release').createRelease({ service: 'media', root: require('path').join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'media', root: require('path').join(__dirname, '..') });
const observability = require('./observability');
const observability = require('./observability');
@@ -38,10 +38,6 @@ const MIME_TYPES = {
@@ -38,10 +38,6 @@ const MIME_TYPES = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
};
};
-function getRequesterIp(req) {
- return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';
-}
-
const views = require('../views/service');
const views = require('../views/service');
// Views + unique views live in ../views/service (visit-based with a cooldown, hashed
// Views + unique views live in ../views/service (visit-based with a cooldown, hashed
// visitors, owner/bot/rate-limit exclusions). A playback session counts once: only the
// visitors, owner/bot/rate-limit exclusions). A playback session counts once: only the
@@ -85,9 +85,9 @@ function ensureSchema() {
@@ -85,9 +85,9 @@ function ensureSchema() {
} catch { /* */ }
} catch { /* */ }
}
}
-function clientIp(req) {
- return (req.headers['cf-connecting-ip'] || (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.ip || (req.socket && req.socket.remoteAddress) || 'unknown').toString();
-}
+// req.ip under 'trust proxy' = loopback (server/client-ip.js): the forwarding headers count only
+// when the local proxy sent them, so a direct caller cannot pose as many visitors.
+const { clientIp } = require('../client-ip');
function visitorForIp(ip) { return 'ip:' + crypto.createHmac('sha256', secret()).update(String(ip || 'unknown')).digest('hex').slice(0, 24); }
function visitorForIp(ip) { return 'ip:' + crypto.createHmac('sha256', secret()).update(String(ip || 'unknown')).digest('hex').slice(0, 24); }
// Per-IP event budget (sliding minute), in memory — protects the DB from a spammer, not a metric.
// Per-IP event budget (sliding minute), in memory — protects the DB from a spammer, not a metric.
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+// 'trust proxy' is loopback only (server/client-ip.js, server/index.js): a request that came through
+// the local nginx is attributed to the X-Forwarded-For address nginx set, and a caller reaching the
+// port directly cannot choose its IP by sending X-Forwarded-For / CF-Connecting-IP itself (views and
+// rate limits key on req.ip). The real server is booted to prove index.js applies it.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const net = require('net');
+const path = require('path');
+const http = require('http');
+const { spawn } = require('child_process');
+const express = require('express');
+
+const { TRUST_PROXY, clientIp } = require('../server/client-ip');
+
+const get = (host, port, p, headers = {}) => new Promise((resolve, reject) => {
+ const req = http.request({ host, port, path: p, headers }, (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: b })); });
+ req.on('error', reject);
+ req.end();
+});
+const externalAddress = () => {
+ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) return a.address;
+ return null;
+};
+
+(async () => {
+ assert.strictEqual(TRUST_PROXY, 'loopback');
+
+ // ── 1. The setting, on an app like index.js's: loopback proxies are believed, nobody else ──
+ const app = express();
+ app.set('trust proxy', TRUST_PROXY);
+ app.get('/ip', (req, res) => res.json({ ip: req.ip, client: clientIp(req) }));
+ const server = await new Promise((r) => { const s = app.listen(0, '0.0.0.0', () => r(s)); });
+ const port = server.address().port;
+ let r = JSON.parse((await get('127.0.0.1', port, '/ip', { 'X-Forwarded-For': '203.0.113.7' })).body);
+ assert.deepStrictEqual([r.ip, r.client], ['203.0.113.7', '203.0.113.7'], 'through the local proxy: the forwarded client');
+ r = JSON.parse((await get('127.0.0.1', port, '/ip')).body);
+ assert.ok(/127\.0\.0\.1$/.test(r.ip), `loopback caller without a proxy header: ${r.ip}`);
+ const ext = externalAddress();
+ if (ext) {
+ r = JSON.parse((await get(ext, port, '/ip', { 'X-Forwarded-For': '198.51.100.9', 'CF-Connecting-IP': '198.51.100.9' })).body);
+ assert.ok(r.ip.endsWith(ext) && r.client.endsWith(ext), `a direct caller cannot choose its IP (got ${r.ip})`);
+ console.log('✅ a non-loopback peer\'s X-Forwarded-For / CF-Connecting-IP are ignored');
+ } else {
+ console.log('⚠️ no non-loopback interface: the direct-caller check is skipped');
+ }
+ server.close();
+ // The view counter keys on req.ip, not on the headers.
+ assert.strictEqual(require('../server/views/service').clientIp({ ip: '192.0.2.4', headers: { 'cf-connecting-ip': '9.9.9.9', 'x-forwarded-for': '8.8.8.8' } }), '192.0.2.4');
+ assert.strictEqual(clientIp({ socket: { remoteAddress: '::1' }, headers: {} }), '::1');
+ console.log('✅ trust proxy loopback: the local proxy\'s forwarded address, else the socket; views key on req.ip');
+
+ // ── 2. index.js applies it (the real server, booted) ──
+ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
+ assert.ok(!/set\('trust proxy',\s*true\)/.test(src), 'no trust-everyone left');
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-trust-'));
+ const freePort = await new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
+ const env = { ...process.env, DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: path.join(tmp, 'vods'), CLIPS_PATH: path.join(tmp, 'clips'), PASTES_PATH: path.join(tmp, 'pastes'),
+ THUMBNAILS_PATH: path.join(tmp, 'thumbs'), FILES_PATH: path.join(tmp, 'files'), OBJECTS_PATH: path.join(tmp, 'objects'), PORT: String(freePort), HOST: ext ? '0.0.0.0' : '127.0.0.1',
+ NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '', MEDIA_APP_KEYS: 'live:live-key-for-trust-test' };
+ const child = spawn(process.execPath, [path.join(__dirname, '../server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
+ let log = '';
+ child.stdout.on('data', (d) => { log += d; });
+ child.stderr.on('data', (d) => { log += d; });
+ let up = false;
+ for (let i = 0; i < 100 && !up; i++) {
+ try { up = (await get('127.0.0.1', freePort, '/healthz')).status === 200; } catch { await new Promise((res) => setTimeout(res, 100)); }
+ }
+ assert.ok(up, `the server booted: ${log.slice(-500)}`);
+ // /metrics answers loopback callers only and refuses anything that came through a proxy; with
+ // trust proxy loopback, Express agrees the request came from the local proxy.
+ const direct = await get('127.0.0.1', freePort, '/metrics');
+ const proxied = await get('127.0.0.1', freePort, '/metrics', { 'X-Forwarded-For': '203.0.113.7' });
+ assert.strictEqual(direct.status, 200);
+ assert.strictEqual(proxied.status, 404, 'a proxied caller is not local');
+ // The live-frame endpoint rate-limits per req.ip (burst 30). Through the local proxy every
+ // forwarded client has its own bucket; a direct caller rotating X-Forwarded-For does not.
+ const burst = async (host, n) => {
+ let limited = 0;
+ for (let i = 0; i < n; i++) {
+ const res = await get(host, freePort, '/live/1/frame.jpg?format=json', { 'X-Forwarded-For': `198.51.100.${i + 1}` });
+ if (res.status === 429) limited++;
+ }
+ return limited;
+ };
+ assert.strictEqual(await burst('127.0.0.1', 45), 0, 'forwarded clients each get their own budget');
+ if (ext) {
+ const limited = await burst(ext, 45);
+ assert.ok(limited > 0, 'a direct caller cannot mint new identities with X-Forwarded-For (the real server trusts loopback only)');
+ }
+ child.kill('SIGTERM');
+ await new Promise((res) => child.on('exit', res));
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('✅ the booted server runs with trust proxy loopback');
+
+ console.log('\n✅ All trust-proxy tests passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });