mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
/video signaling proxy and a global public server http path config item
This commit is contained in:
@@ -20,19 +20,17 @@ function normalizeAdditionalHosts(rawHosts) {
|
||||
|
||||
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
const media = config?.media || {};
|
||||
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||
if (!additionalHosts.length && media.whepBaseUrl) {
|
||||
try {
|
||||
/*
|
||||
Existing installations predate media.additionalHosts. Using the already-configured
|
||||
WHEP hostname as a one-host migration default keeps them reachable on first restart;
|
||||
administrators can still list every public and LAN candidate explicitly afterward.
|
||||
*/
|
||||
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
|
||||
} catch {
|
||||
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
|
||||
}
|
||||
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||
let publicHostname = '';
|
||||
try {
|
||||
publicHostname = new URL(config?.publicUrl).hostname;
|
||||
} catch {
|
||||
// The assembled configuration schema normally prevents this. Keeping the
|
||||
// builder tolerant makes its pure tests and startup error path explicit.
|
||||
}
|
||||
// The canonical public hostname is always advertised once. Operators only
|
||||
// maintain genuinely additional LAN names, aliases, or fixed IP addresses.
|
||||
const additionalHosts = [...new Set([publicHostname, ...configuredHosts].filter(Boolean))];
|
||||
const authPort = Number(serverPort) || 8080;
|
||||
|
||||
return {
|
||||
@@ -56,6 +54,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
hls: false,
|
||||
|
||||
webrtc: true,
|
||||
// WHEP and WHIP signaling is public only through the Node /video proxy.
|
||||
// ICE transport on 8189 remains directly reachable by browsers.
|
||||
webrtcAddress: '127.0.0.1:8889',
|
||||
webrtcLocalUDPAddress: ':8189',
|
||||
webrtcLocalTCPAddress: ':8189',
|
||||
webrtcAdditionalHosts: additionalHosts,
|
||||
|
||||
@@ -8,8 +8,8 @@ const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
|
||||
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||
const generated = buildMediaMtxConfig({
|
||||
config: {
|
||||
publicUrl: 'https://public.example.com',
|
||||
media: {
|
||||
whepBaseUrl: 'http://media.internal:8889/video',
|
||||
additionalHosts: ['public.example.com', '10.20.30.40'],
|
||||
},
|
||||
},
|
||||
@@ -23,12 +23,13 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
|
||||
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
|
||||
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
|
||||
assert.equal(generated.webrtcAddress, '127.0.0.1:8889');
|
||||
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
|
||||
});
|
||||
|
||||
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
|
||||
test('derives the primary ICE hostname from the canonical public URL', () => {
|
||||
const generated = buildMediaMtxConfig({
|
||||
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
|
||||
config: { publicUrl: 'https://second-server.example', media: { additionalHosts: [] } },
|
||||
serverPort: 8080,
|
||||
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
|
||||
});
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
// Media Transport Configuration
|
||||
// Purpose: Defines browser WHEP addressing and additional MediaMTX ICE hosts.
|
||||
// Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL.
|
||||
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
||||
const { strictObject, string, stringArray } = require('../../configuration/schemaHelpers');
|
||||
const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: {
|
||||
// Signaling remains server-local because the internal `/video` proxy owns
|
||||
// browser access; only ICE transport addresses come from the legacy sample.
|
||||
whepBaseUrl: 'http://127.0.0.1:8889/video',
|
||||
additionalHosts: ['rover.example.com', 'media-server.local'],
|
||||
additionalHosts: [],
|
||||
},
|
||||
schema: strictObject({
|
||||
whepBaseUrl: string({ title: 'WHEP base URL', description: 'Base HTTP URL used to build browser WHEP playback and WHIP audio-publishing endpoints.', format: 'uri', maxLength: 2048 }),
|
||||
additionalHosts: stringArray({
|
||||
title: 'Additional ICE hosts',
|
||||
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
|
||||
array: { description: 'Additional public or LAN hostnames and addresses browsers may use to reach MediaMTX WebRTC transport.', uniqueItems: true },
|
||||
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
|
||||
}),
|
||||
}, { title: 'Media', description: 'Controls browser signaling addresses and WebRTC network candidates generated for the managed MediaMTX process.', required: ['whepBaseUrl', 'additionalHosts'] }),
|
||||
}, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }),
|
||||
};
|
||||
|
||||
@@ -26,14 +26,17 @@ function stopSupervisor() {
|
||||
return new Promise((resolve) => supervisor.stop(resolve));
|
||||
}
|
||||
|
||||
registerConfigurationHandler('media', async () => {
|
||||
async function reloadMediaMtx() {
|
||||
// MediaMTX consumes a generated document rather than the Node configuration
|
||||
// object directly. Replace its child process so every media setting is
|
||||
// regenerated and applied as one coherent revision.
|
||||
// object directly. Replace its child process when either explicit media
|
||||
// hosts or the canonical public hostname changes.
|
||||
await stopSupervisor();
|
||||
supervisor = createSupervisor();
|
||||
if (started) supervisor.start();
|
||||
});
|
||||
}
|
||||
|
||||
registerConfigurationHandler('media', reloadMediaMtx);
|
||||
registerConfigurationHandler('publicUrl', reloadMediaMtx);
|
||||
|
||||
/*
|
||||
Other services already use process signal hooks for their own workers. This hook performs
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// MediaMTX HTTP Proxy
|
||||
// Purpose: Exposes WHEP and WHIP signaling beneath the server-owned /video path while MediaMTX remains loopback-only.
|
||||
// Scope: Proxies signaling HTTP only; WebRTC media continues to travel directly through MediaMTX's ICE listener.
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
const PUBLIC_MEDIA_PREFIX = '/video';
|
||||
const INTERNAL_WEBRTC_ORIGIN = 'http://127.0.0.1:8889';
|
||||
const SIGNALING_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
|
||||
function rewriteSessionLocation(location, internalOrigin = INTERNAL_WEBRTC_ORIGIN) {
|
||||
const value = String(location || '');
|
||||
if (!value) return value;
|
||||
|
||||
/*
|
||||
MediaMTX normally returns a root-relative WHEP/WHIP session URL. Browsers
|
||||
subsequently PATCH and DELETE that exact Location, so restore the public
|
||||
mount prefix that was removed before proxying the initial request.
|
||||
*/
|
||||
if (value.startsWith('/') && !value.startsWith(`${PUBLIC_MEDIA_PREFIX}/`)) {
|
||||
return `${PUBLIC_MEDIA_PREFIX}${value}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.origin === new URL(internalOrigin).origin) {
|
||||
return `${PUBLIC_MEDIA_PREFIX}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch {
|
||||
// A path relative to the WHEP/WHIP endpoint already resolves beneath
|
||||
// /video in the browser and must not be converted into a root path.
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createMediaMtxProxy({ target = INTERNAL_WEBRTC_ORIGIN, logger = console } = {}) {
|
||||
return createProxyMiddleware({
|
||||
target,
|
||||
changeOrigin: true,
|
||||
proxyTimeout: SIGNALING_TIMEOUT_MS,
|
||||
timeout: SIGNALING_TIMEOUT_MS,
|
||||
logger,
|
||||
on: {
|
||||
proxyRes(proxyResponse) {
|
||||
const location = proxyResponse.headers.location;
|
||||
if (location) proxyResponse.headers.location = rewriteSessionLocation(location, target);
|
||||
},
|
||||
error(error, request, response) {
|
||||
logger.warn?.('MediaMTX signaling proxy failed', {
|
||||
method: request.method,
|
||||
path: request.originalUrl || request.url,
|
||||
error: error.message,
|
||||
});
|
||||
if (response.headersSent) {
|
||||
response.destroy(error);
|
||||
return;
|
||||
}
|
||||
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
response.end('Media signaling is temporarily unavailable.');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INTERNAL_WEBRTC_ORIGIN,
|
||||
PUBLIC_MEDIA_PREFIX,
|
||||
createMediaMtxProxy,
|
||||
rewriteSessionLocation,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
// MediaMTX HTTP Proxy Tests
|
||||
// Purpose: Verifies streaming WHEP/WHIP method, body, header, path, and session-location behavior.
|
||||
// Scope: Uses ephemeral loopback HTTP servers and always closes them; it never starts MediaMTX or the application server.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy, rewriteSessionLocation } = require('./proxy');
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
|
||||
test('streams WHEP session requests and keeps follow-up locations beneath /video', async () => {
|
||||
const received = [];
|
||||
const mediaMtx = http.createServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
received.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
contentType: request.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
response.writeHead(201, {
|
||||
'Content-Type': 'application/sdp',
|
||||
Location: '/rover-one/whep/session-id',
|
||||
});
|
||||
response.end('answer');
|
||||
});
|
||||
});
|
||||
const mediaPort = await listen(mediaMtx);
|
||||
|
||||
const app = express();
|
||||
const silentLogger = { info() {}, warn() {}, error() {} };
|
||||
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({
|
||||
target: `http://127.0.0.1:${mediaPort}`,
|
||||
logger: silentLogger,
|
||||
}));
|
||||
// If the proxy accidentally falls through, this parser would consume the
|
||||
// request and make the integration failure explicit instead of timing out.
|
||||
app.use(express.json());
|
||||
const nodeServer = http.createServer(app);
|
||||
const nodePort = await listen(nodeServer);
|
||||
|
||||
try {
|
||||
const requests = [
|
||||
{ method: 'POST', path: '/video/rover-one/whep', type: 'application/sdp', body: 'v=0\r\no=offer' },
|
||||
{ method: 'PATCH', path: '/video/rover-one/whep/session-id', type: 'application/trickle-ice-sdpfrag', body: 'a=candidate' },
|
||||
{ method: 'DELETE', path: '/video/rover-one/whep/session-id', type: 'application/trickle-ice-sdpfrag', body: '' },
|
||||
];
|
||||
for (const request of requests) {
|
||||
const response = await fetch(`http://127.0.0.1:${nodePort}${request.path}`, {
|
||||
method: request.method,
|
||||
headers: { Authorization: 'Bearer session-token', 'Content-Type': request.type },
|
||||
body: request.method === 'DELETE' ? undefined : request.body,
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get('location'), '/video/rover-one/whep/session-id');
|
||||
assert.equal(await response.text(), 'answer');
|
||||
}
|
||||
|
||||
assert.deepEqual(received, requests.map((request) => ({
|
||||
method: request.method,
|
||||
path: request.path.slice('/video'.length),
|
||||
authorization: 'Bearer session-token',
|
||||
contentType: request.type,
|
||||
body: request.body,
|
||||
})));
|
||||
} finally {
|
||||
await close(nodeServer);
|
||||
await close(mediaMtx);
|
||||
}
|
||||
});
|
||||
|
||||
test('rewrites only root-relative or internal absolute MediaMTX locations', () => {
|
||||
assert.equal(rewriteSessionLocation('/camera/whep/id'), '/video/camera/whep/id');
|
||||
assert.equal(rewriteSessionLocation('http://127.0.0.1:8889/camera/whep/id?one=two'), '/video/camera/whep/id?one=two');
|
||||
assert.equal(rewriteSessionLocation('whep/id'), 'whep/id');
|
||||
assert.equal(rewriteSessionLocation('https://example.com/camera/whep/id'), 'https://example.com/camera/whep/id');
|
||||
});
|
||||
@@ -37,7 +37,7 @@ test('passes the resolved data root to MediaMTX runOnReady hooks', () => {
|
||||
|
||||
try {
|
||||
const supervisor = createMediaMtxSupervisor({
|
||||
config: { media: { additionalHosts: ['media.example.test'] } },
|
||||
config: { publicUrl: 'https://public.example.test', media: { additionalHosts: ['media.example.test'] } },
|
||||
serverPort: 8080,
|
||||
logger,
|
||||
mediaMtxBin: '/test/bin/mediamtx',
|
||||
|
||||
Reference in New Issue
Block a user