mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(cctv): admit and bound HLS leases by peer
This commit is contained in:
1 parent
de2138e84c
commit
167aa947ce
6 files changed
+154
-17
No files matched your search
@@ -1,5 +1,12 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
CCTV live-media requests are same-site gated before catalog or media work, including
|
||||||
|
HLS renewal, segment reads and DELETE cleanup. HLS leases belong to their socket
|
||||||
|
peer (IPv4-mapped IPv6 is normalized), with eight leases per peer across the two
|
||||||
|
global sessions. Peers behind the same NAT share that budget; this is resource
|
||||||
|
admission, not authentication. Native same-origin video and range requests remain
|
||||||
|
supported.
|
||||||
|
|
||||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||||
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
||||||
|
|||||||
@@ -187,6 +187,8 @@
|
|||||||
"exports": ["./server/providers/cctv"],
|
"exports": ["./server/providers/cctv"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/cctv.js",
|
"server/providers/cctv.js",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
|
"src/localRequestGate.mjs",
|
||||||
"server/providers/cctv/cap.js",
|
"server/providers/cctv/cap.js",
|
||||||
"server/providers/cctv/groundHeights.js",
|
"server/providers/cctv/groundHeights.js",
|
||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
CCTV_MAX_SOURCES_CEILING,
|
CCTV_MAX_SOURCES_CEILING,
|
||||||
} from './cctv/constants.js';
|
} from './cctv/constants.js';
|
||||||
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||||
|
import { admitSameSite } from './common/same-site.js';
|
||||||
import { createHlsPuller } from './cctv/stream.js';
|
import { createHlsPuller } from './cctv/stream.js';
|
||||||
import { googleServerApiKey } from './places/google-key.js';
|
import { googleServerApiKey } from './places/google-key.js';
|
||||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||||
@@ -135,11 +136,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
});
|
});
|
||||||
server.middlewares.use('/api/cctv', async (req, res) => {
|
server.middlewares.use('/api/cctv', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
// Refuse before catalog I/O, session creation, renewal or release.
|
||||||
|
if (url.pathname.startsWith('/media/') && admitSameSite(req, res))
|
||||||
|
return;
|
||||||
|
const clientId = String(req.socket?.remoteAddress || 'unknown').replace(
|
||||||
|
/^::ffff:/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
const sources = await getCctvSources();
|
const sources = await getCctvSources();
|
||||||
const sourceById = new Map(
|
const sourceById = new Map(
|
||||||
sources.map((source) => [source.id, source]),
|
sources.map((source) => [source.id, source]),
|
||||||
);
|
);
|
||||||
const url = new URL(req.url || '/', 'http://localhost');
|
|
||||||
|
|
||||||
if (url.pathname === '/sources') {
|
if (url.pathname === '/sources') {
|
||||||
const body = {
|
const body = {
|
||||||
@@ -238,7 +246,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
if (leaseId) puller.release(cameraId, leaseId);
|
if (leaseId) puller.release(cameraId, leaseId, clientId);
|
||||||
res.writeHead(204);
|
res.writeHead(204);
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
@@ -268,6 +276,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
url.searchParams.get('session'),
|
url.searchParams.get('session'),
|
||||||
Number(match[3]),
|
Number(match[3]),
|
||||||
leaseId,
|
leaseId,
|
||||||
|
clientId,
|
||||||
);
|
);
|
||||||
res.writeHead(body ? 200 : 404, {
|
res.writeHead(body ? 200 : 404, {
|
||||||
'Content-Type': 'video/mp2t',
|
'Content-Type': 'video/mp2t',
|
||||||
@@ -279,10 +288,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
const downstream = watchDownstreamClose(res);
|
const downstream = watchDownstreamClose(res);
|
||||||
let entry;
|
let entry;
|
||||||
const cancelPending = () => {
|
const cancelPending = () => {
|
||||||
if (entry) puller.release(cameraId, leaseId);
|
if (entry) puller.release(cameraId, leaseId, clientId);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
entry = await puller.ensure(
|
||||||
|
cameraId,
|
||||||
|
mediaUrl,
|
||||||
|
leaseId,
|
||||||
|
clientId,
|
||||||
|
);
|
||||||
if (downstream.closed) {
|
if (downstream.closed) {
|
||||||
cancelPending();
|
cancelPending();
|
||||||
return;
|
return;
|
||||||
@@ -296,6 +310,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
entry,
|
entry,
|
||||||
cameraId,
|
cameraId,
|
||||||
leaseId,
|
leaseId,
|
||||||
|
clientId,
|
||||||
);
|
);
|
||||||
if (downstream.closed) return;
|
if (downstream.closed) return;
|
||||||
if (!playlist) throw new Error('Stream unavailable');
|
if (!playlist) throw new Error('Stream unavailable');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
export const HLS_LIMITS = Object.freeze({
|
export const HLS_LIMITS = Object.freeze({
|
||||||
sessions: 2,
|
sessions: 2,
|
||||||
leasesPerSession: 8,
|
leasesPerSession: 8,
|
||||||
|
leasesPerClient: 8,
|
||||||
playlistBytes: 256 * 1024,
|
playlistBytes: 256 * 1024,
|
||||||
segmentBytes: 4 * 1024 * 1024,
|
segmentBytes: 4 * 1024 * 1024,
|
||||||
sessionBytes: 24 * 1024 * 1024,
|
sessionBytes: 24 * 1024 * 1024,
|
||||||
@@ -138,25 +139,37 @@ export function createHlsPuller({
|
|||||||
entry.upstream.clear();
|
entry.upstream.clear();
|
||||||
entry.bytes = 0;
|
entry.bytes = 0;
|
||||||
};
|
};
|
||||||
const release = (cameraId, leaseId) => {
|
const release = (cameraId, leaseId, clientId = 'legacy') => {
|
||||||
const entry = active.get(cameraId);
|
const entry = active.get(cameraId);
|
||||||
const lease = entry?.leases.get(leaseId);
|
const lease = entry?.leases.get(leaseId);
|
||||||
if (!lease) return;
|
if (!lease || lease.clientId !== clientId) return;
|
||||||
clearTimeout(lease.timer);
|
clearTimeout(lease.timer);
|
||||||
entry.leases.delete(leaseId);
|
entry.leases.delete(leaseId);
|
||||||
if (!entry.leases.size) stop(cameraId, entry.token);
|
if (!entry.leases.size) stop(cameraId, entry.token);
|
||||||
};
|
};
|
||||||
const touch = (entry, leaseId, create = false) => {
|
const touch = (entry, leaseId, create = false, clientId = 'legacy') => {
|
||||||
let lease = entry.leases.get(leaseId);
|
let lease = entry.leases.get(leaseId);
|
||||||
|
if (lease && lease.clientId !== clientId)
|
||||||
|
throw new Error('HLS lease belongs to another client');
|
||||||
if (!lease) {
|
if (!lease) {
|
||||||
|
const clientLeases = [...active.values()].reduce(
|
||||||
|
(count, session) =>
|
||||||
|
count +
|
||||||
|
[...session.leases.values()].filter(
|
||||||
|
(item) => item.clientId === clientId,
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (clientLeases >= limits.leasesPerClient)
|
||||||
|
throw new Error('HLS client lease capacity reached');
|
||||||
if (!create || entry.leases.size >= limits.leasesPerSession)
|
if (!create || entry.leases.size >= limits.leasesPerSession)
|
||||||
throw new Error('HLS lease capacity reached');
|
throw new Error('HLS lease capacity reached');
|
||||||
lease = {};
|
lease = { clientId };
|
||||||
entry.leases.set(leaseId, lease);
|
entry.leases.set(leaseId, lease);
|
||||||
}
|
}
|
||||||
clearTimeout(lease.timer);
|
clearTimeout(lease.timer);
|
||||||
lease.timer = setTimeout(
|
lease.timer = setTimeout(
|
||||||
() => release(entry.cameraId, leaseId),
|
() => release(entry.cameraId, leaseId, clientId),
|
||||||
limits.idleMs,
|
limits.idleMs,
|
||||||
);
|
);
|
||||||
lease.timer.unref?.();
|
lease.timer.unref?.();
|
||||||
@@ -248,7 +261,12 @@ export function createHlsPuller({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const ensure = async (cameraId, url, leaseId = 'legacy') => {
|
const ensure = async (
|
||||||
|
cameraId,
|
||||||
|
url,
|
||||||
|
leaseId = 'legacy',
|
||||||
|
clientId = 'legacy',
|
||||||
|
) => {
|
||||||
if (closed) throw new Error('HLS service closed');
|
if (closed) throw new Error('HLS service closed');
|
||||||
sameOriginHlsUrl(url, url);
|
sameOriginHlsUrl(url, url);
|
||||||
let entry = active.get(cameraId);
|
let entry = active.get(cameraId);
|
||||||
@@ -257,7 +275,7 @@ export function createHlsPuller({
|
|||||||
entry = null;
|
entry = null;
|
||||||
}
|
}
|
||||||
if (entry) {
|
if (entry) {
|
||||||
touch(entry, leaseId, true);
|
touch(entry, leaseId, true, clientId);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
// Reserve synchronously, before any await: concurrent creation is singleflight.
|
// Reserve synchronously, before any await: concurrent creation is singleflight.
|
||||||
@@ -279,8 +297,8 @@ export function createHlsPuller({
|
|||||||
pendingDiscontinuity: false,
|
pendingDiscontinuity: false,
|
||||||
discontinuitiesRemoved: 0,
|
discontinuitiesRemoved: 0,
|
||||||
};
|
};
|
||||||
|
touch(entry, leaseId, true, clientId);
|
||||||
active.set(cameraId, entry);
|
active.set(cameraId, entry);
|
||||||
touch(entry, leaseId, true);
|
|
||||||
entry.polling = poll(entry);
|
entry.polling = poll(entry);
|
||||||
return entry;
|
return entry;
|
||||||
};
|
};
|
||||||
@@ -292,9 +310,14 @@ export function createHlsPuller({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const buildPlaylist = async (entry, cameraId, leaseId = 'legacy') => {
|
const buildPlaylist = async (
|
||||||
|
entry,
|
||||||
|
cameraId,
|
||||||
|
leaseId = 'legacy',
|
||||||
|
clientId = 'legacy',
|
||||||
|
) => {
|
||||||
if (entry.stopping || entry.segments.size < 2) return null;
|
if (entry.stopping || entry.segments.size < 2) return null;
|
||||||
touch(entry, leaseId);
|
touch(entry, leaseId, false, clientId);
|
||||||
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
|
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
|
||||||
const lines = [
|
const lines = [
|
||||||
'#EXTM3U',
|
'#EXTM3U',
|
||||||
@@ -318,12 +341,22 @@ export function createHlsPuller({
|
|||||||
}
|
}
|
||||||
return lines.join('\n') + '\n';
|
return lines.join('\n') + '\n';
|
||||||
};
|
};
|
||||||
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
|
const getSegment = (
|
||||||
|
cameraId,
|
||||||
|
token,
|
||||||
|
seq,
|
||||||
|
leaseId = 'legacy',
|
||||||
|
clientId = 'legacy',
|
||||||
|
) => {
|
||||||
const entry = active.get(cameraId);
|
const entry = active.get(cameraId);
|
||||||
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
|
if (
|
||||||
|
!entry ||
|
||||||
|
entry.token !== token ||
|
||||||
|
entry.leases.get(leaseId)?.clientId !== clientId
|
||||||
|
)
|
||||||
return null;
|
return null;
|
||||||
const body = entry.segments.get(seq)?.body;
|
const body = entry.segments.get(seq)?.body;
|
||||||
if (body) touch(entry, leaseId);
|
if (body) touch(entry, leaseId, false, clientId);
|
||||||
return body || null;
|
return body || null;
|
||||||
};
|
};
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
|
|||||||
@@ -223,3 +223,40 @@ test('an abandoned consumer expires while a renewed consumer keeps the session',
|
|||||||
assert.equal(entry.controller.signal.aborted, false);
|
assert.equal(entry.controller.signal.aborted, false);
|
||||||
await manager.shutdown();
|
await manager.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client lease ownership covers refresh, segments and release, with a bounded client budget', async (t) => {
|
||||||
|
const manager = createHlsPuller({
|
||||||
|
limits: { ...HLS_LIMITS, leasesPerClient: 1, pollMs: 100000 },
|
||||||
|
fetchImpl: async (url) =>
|
||||||
|
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
|
||||||
|
});
|
||||||
|
t.after(() => manager.shutdown());
|
||||||
|
const entry = await manager.ensure('a', base, 'lease-a', 'peer-a');
|
||||||
|
await manager.waitReady(entry);
|
||||||
|
await assert.rejects(
|
||||||
|
manager.ensure('a', base, 'lease-a', 'peer-b'),
|
||||||
|
/another client/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
manager.getSegment('a', entry.token, 0, 'lease-a', 'peer-b'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
manager.buildPlaylist(entry, 'a', 'lease-a', 'peer-b'),
|
||||||
|
/another client/,
|
||||||
|
);
|
||||||
|
manager.release('a', 'lease-a', 'peer-b');
|
||||||
|
assert.equal(entry.leases.size, 1);
|
||||||
|
await assert.rejects(
|
||||||
|
manager.ensure('b', base, 'lease-b', 'peer-a'),
|
||||||
|
/client lease capacity/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
manager.stats().sessions,
|
||||||
|
1,
|
||||||
|
'failed admission reserves no session',
|
||||||
|
);
|
||||||
|
await manager.ensure('b', base, 'lease-b', 'peer-b');
|
||||||
|
manager.release('a', 'lease-a', 'peer-a');
|
||||||
|
assert.equal(entry.stopping, true);
|
||||||
|
});
|
||||||
@@ -268,3 +268,46 @@ test('/frame does not follow a still camera off its origin', async (t) => {
|
|||||||
[STILL.url],
|
[STILL.url],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cross-site media creation, segment reads and release fail before any catalog or media fetch', async (t) => {
|
||||||
|
let handler;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let calls = 0;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
calls++;
|
||||||
|
throw new Error('must not fetch');
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
cctvProxy().configureServer({
|
||||||
|
middlewares: {
|
||||||
|
use: (_path, fn) => {
|
||||||
|
handler = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const headers of [
|
||||||
|
{ origin: 'https://foreign.example' },
|
||||||
|
{ origin: 'null' },
|
||||||
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
|
{ 'sec-fetch-site': 'same-site' },
|
||||||
|
]) {
|
||||||
|
for (const [method, url] of [
|
||||||
|
['GET', '/media/camera?lease=test'],
|
||||||
|
['DELETE', '/media/camera?lease=test'],
|
||||||
|
['GET', '/media/camera/seg_0.ts?lease=test'],
|
||||||
|
]) {
|
||||||
|
const { res } = recordingResponse();
|
||||||
|
res.setHeader = (name, value) => {
|
||||||
|
res.headers[name] = value;
|
||||||
|
};
|
||||||
|
await handler(
|
||||||
|
{ method, url, headers: { host: 'localhost:4173', ...headers } },
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
assert.equal(res.statusCode, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user