switched rover <-> server streams to rtsptcp, and moved mediamtx to be a server owned and configured child process!

This commit is contained in:
legop3
2026-08-04 01:47:03 -04:00
parent 28fcbad902
commit c7c52eb39c
26 changed files with 641 additions and 186 deletions
@@ -30,25 +30,13 @@ function createAudioForwardPolicy(deps) {
}
}
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
// Rovers listen to the playback stream with a request/read URL. The VIP
// upload path needs to publish into that same stream, so the configured
// nested playback URL is converted to publish mode below.
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
/*
The server publishes to its own MediaMTX child, so loopback is the stable and correct
route regardless of which hostname a rover uses to reach this machine. RTSP uses the
same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
*/
return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
}
function resolveForwardPathId(roverId) {
@@ -25,3 +25,8 @@ test('preserves normal audio forwarding for an unmuted verified driver', () => {
const policy = createPolicy();
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
});
test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
const policy = createPolicy();
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
});
@@ -86,7 +86,7 @@ function createAudioForwardWorkerEngine(deps) {
exited = true;
};
// ChildProcess.killed only means Node successfully sent a signal, not that
// ffmpeg actually exited. Track the real exit event so FIFO/SRT hangs still
// ffmpeg actually exited. Track the real exit event so FIFO/publisher hangs still
// get escalated to SIGKILL instead of making systemd wait for its timeout.
proc.once('exit', markExited);
try {
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
'-muxpreload',
'0',
'-f',
'mpegts',
'rtsp',
/*
The MediaMTX listener accepts RTSP over TCP only. Pinning it here makes the server's
own publisher follow the same reliable transport contract as every rover publisher.
*/
'-rtsp_transport',
'tcp',
outputUrl,
];
}
+7
View File
@@ -4,7 +4,14 @@
const { httpServer } = require('../../globals/http');
const config = require('../../globals/config');
const logger = require('../../globals/logger').child('httpServer');
const { startMediaMtx } = require('../mediaMtxService');
httpServer.listen(config.port, () => {
logger.info(`Server listening on :${config.port}`);
/*
MediaMTX immediately calls the server's HTTP authorization route when clients connect.
Starting it from the listen callback guarantees that endpoint is reachable before the
first publisher attempts to authenticate.
*/
startMediaMtx();
});
@@ -0,0 +1,100 @@
// MediaMTX Config Builder
// Purpose: Converts the rover server's media settings into the complete MediaMTX runtime configuration.
// Scope: Keeps deployment-specific hosts in config.yaml while keeping protocol policy owned by the application.
const path = require('path');
function normalizeAdditionalHosts(rawHosts) {
if (rawHosts == null) return [];
if (!Array.isArray(rawHosts)) {
throw new Error('media.additionalHosts must be a list');
}
/*
MediaMTX accepts both IP addresses and DNS names here. Preserve that flexibility because
an installation can need a public candidate and a LAN candidate at the same time. Empty
entries and duplicates are removed so a harmless config typo does not create redundant
ICE candidates, while the values themselves remain entirely instance-owned.
*/
return [...new Set(rawHosts.map((value) => String(value || '').trim()).filter(Boolean))];
}
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 authPort = Number(serverPort) || 8080;
return {
logLevel: 'info',
api: true,
apiAddress: '127.0.0.1:9997',
metrics: true,
metricsAddress: '127.0.0.1:9998',
pprof: false,
pprofAddress: '127.0.0.1:9999',
/*
Rover publishers and readers always request TCP explicitly. Declaring only TCP here
also prevents MediaMTX from opening the separate RTP/RTCP UDP listeners, which are not
useful for this local-network deployment and performed poorly in the measured tests.
*/
rtsp: true,
rtspAddress: ':8554',
rtspTransports: ['tcp'],
rtmp: false,
hls: false,
webrtc: true,
webrtcLocalUDPAddress: ':8189',
webrtcLocalTCPAddress: ':8189',
webrtcAdditionalHosts: additionalHosts,
webrtcICEServers2: [
{ url: 'stun:stun.l.google.com:19302' },
{ url: 'stun:stun1.l.google.com:19302' },
{ url: 'stun:stun2.l.google.com:19302' },
{ url: 'stun:stun3.l.google.com:19302' },
{ url: 'stun:stun4.l.google.com:19302' },
{ url: 'stun:stun.cloudflare.com:3478' },
],
/*
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
writer. Rover media moves to RTSP, but removing this listener would break those independent
consumers, so both listeners remain deliberately enabled.
*/
srt: true,
srtAddress: ':9000',
authMethod: 'http',
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
authHTTPExclude: [
{ action: 'api' },
{ action: 'metrics' },
{ action: 'pprof' },
],
paths: {
all: {
source: 'publisher',
sourceOnDemand: false,
runOnReady: path.resolve(snapshotWriterPath),
runOnReadyRestart: true,
},
},
};
}
module.exports = {
buildMediaMtxConfig,
normalizeAdditionalHosts,
};
@@ -0,0 +1,45 @@
// MediaMTX Config Builder Tests
// Purpose: Pins the generated protocol policy and instance-specific ICE host handling.
// Scope: Tests pure configuration output without starting listeners or leaving a child process running.
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
const generated = buildMediaMtxConfig({
config: {
media: {
whepBaseUrl: 'http://media.internal:8889/video',
additionalHosts: ['public.example.com', '10.20.30.40'],
},
},
serverPort: 8123,
snapshotWriterPath: '/opt/multirover/rover-snapshot-writer.sh',
});
assert.equal(generated.rtsp, true);
assert.equal(generated.rtspAddress, ':8554');
assert.deepEqual(generated.rtspTransports, ['tcp']);
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.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
});
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
const generated = buildMediaMtxConfig({
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
serverPort: 8080,
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
});
assert.deepEqual(generated.webrtcAdditionalHosts, ['second-server.example']);
});
test('normalizes duplicate and empty additional hosts', () => {
assert.deepEqual(
normalizeAdditionalHosts([' media.local ', '', 'media.local', null, 'public.example']),
['media.local', 'public.example'],
);
assert.throws(() => normalizeAdditionalHosts('media.local'), /must be a list/);
});
@@ -0,0 +1,46 @@
// MediaMTX Service
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
const { loadConfig } = require('../../helpers/configLoader');
const globalConfig = require('../../globals/config');
const logger = require('../../globals/logger').child('mediamtx');
const { createMediaMtxSupervisor } = require('./supervisor');
const supervisor = createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
function startMediaMtx() {
return supervisor.start();
}
/*
Other services already use process signal hooks for their own workers. This hook performs
only synchronous signal delivery; systemd's default control-group cleanup remains the final
guarantee if the parent is killed before the child finishes exiting.
*/
process.once('exit', () => supervisor.stop());
function stopForSignal(signal) {
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
process.exit(signal === 'SIGINT' ? 130 : 143);
};
supervisor.stop(finish);
/*
A wedged child must not make systemd wait indefinitely. This timer is deliberately unref'd
so it never keeps an otherwise-finished process alive; it is only a bound on graceful exit.
*/
const forceExitTimer = setTimeout(finish, 5000);
forceExitTimer.unref?.();
}
process.once('SIGINT', () => stopForSignal('SIGINT'));
process.once('SIGTERM', () => stopForSignal('SIGTERM'));
module.exports = { startMediaMtx };
@@ -0,0 +1,103 @@
// MediaMTX Child Supervisor
// Purpose: Writes the generated runtime configuration and owns the MediaMTX child process lifecycle.
// Scope: Starts exactly one child, forwards its logs, and lets systemd restart the coherent server/media pair.
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const yaml = require('js-yaml');
const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) {
const {
config,
serverPort,
logger,
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover',
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
spawnProcess = spawn,
} = deps;
let child = null;
let stopping = false;
let stoppedCallback = null;
function forwardLines(stream, level) {
let pending = '';
stream.setEncoding('utf8');
stream.on('data', (chunk) => {
pending += chunk;
const lines = pending.split(/\r?\n/);
pending = lines.pop() || '';
lines.filter(Boolean).forEach((line) => logger[level](line));
});
stream.on('end', () => {
if (pending) logger[level](pending);
});
}
function start() {
if (child) return child;
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
const configPath = path.join(runtimeDir, 'mediamtx.yml');
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 });
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
logger.info(`Starting MediaMTX with generated config ${configPath}`);
child = spawnProcess(mediaMtxBin, [configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
forwardLines(child.stdout, 'info');
forwardLines(child.stderr, 'warn');
child.once('error', (err) => {
logger.error('Unable to start MediaMTX', err);
if (!stopping) {
/*
A spawn failure does not reliably emit the normal exit event on every platform.
Fail the parent here as well so the server can never stay nominally online without
its required media child and systemd gets the opportunity to repair the launch.
*/
process.exit(1);
}
});
child.once('exit', (code, signal) => {
child = null;
if (stopping) {
stoppedCallback?.();
stoppedCallback = null;
return;
}
/*
MediaMTX is required for every live media path. Exiting the parent is intentionally
simpler and safer than maintaining a second retry policy inside Node: systemd already
restarts multirover.service, producing one clean server/MediaMTX lifecycle.
*/
logger.error(`MediaMTX exited unexpectedly (code=${code ?? 'none'} signal=${signal || 'none'})`);
process.exit(1);
});
return child;
}
function stop(onStopped) {
stopping = true;
stoppedCallback = typeof onStopped === 'function' ? onStopped : null;
if (!child) {
stoppedCallback?.();
stoppedCallback = null;
return;
}
try {
child.kill('SIGTERM');
} catch (err) {
logger.warn('Unable to stop MediaMTX cleanly', err);
}
}
return { start, stop };
}
module.exports = { createMediaMtxSupervisor };
@@ -33,6 +33,7 @@ function registerVideoAuthRoute(deps) {
}
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
const isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp');
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
return res.status(200).end();
@@ -40,6 +41,15 @@ function registerVideoAuthRoute(deps) {
if (action === 'publish' && isSrtLikeProtocol) {
return res.status(200).end();
}
/*
Rover and server publishers reach MediaMTX only on the local network and intentionally
do not carry browser session credentials. MediaMTX still invokes its global HTTP auth
callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads
under the existing session and role checks below.
*/
if (action === 'publish' && isRtspProtocol) {
return res.status(200).end();
}
if (!sessionId || !streamInfo?.id) {
logger.warn('auth missing session or stream (session=%s path=%s)', sessionId, path);
@@ -0,0 +1,48 @@
// Video Auth HTTP Route Tests
// Purpose: Verifies credential-free LAN RTSP publishing without weakening browser read authorization.
// Scope: Invokes the registered route with lightweight request/response doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { registerVideoAuthRoute } = require('./httpRoute');
function createHarness() {
let handler;
const app = { post: (_path, fn) => { handler = fn; } };
registerVideoAuthRoute({
app,
io: { sockets: { sockets: new Map() } },
logger: { info() {}, warn() {} },
videoSessions: { getSession: () => null, revokeSession() {} },
getRequestIp: () => '127.0.0.1',
logAdminEvent() {},
extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }),
canAccessStream: () => false,
});
function request(body) {
const result = { statusCode: null };
const response = {
status(code) {
result.statusCode = code;
return response;
},
end() {
return response;
},
};
handler({ body }, response);
return result.statusCode;
}
return { request };
}
test('allows an RTSP rover publisher without a browser session', () => {
const { request } = createHarness();
assert.equal(request({ protocol: 'rtsp', action: 'publish', path: 'rover-one' }), 200);
});
test('continues rejecting an unauthenticated WebRTC read', () => {
const { request } = createHarness();
assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401);
});