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
+9 -2
View File
@@ -51,8 +51,15 @@ barcodeGames:
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
# Example: http://media-server.local:8889/video
whepBaseUrl: "http://media-server.local:8889/video"
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
# candidates. Include every public and LAN address browsers use to reach this server.
# The server generates MediaMTX's runtime configuration from this list; never edit a
# separate mediamtx.yml for a new installation.
additionalHosts:
- "rover.example.com"
- "media-server.local"
bandwidthSavings:
# Duplicate driver-tab handling for the same browser identity.
+1
View File
@@ -28,6 +28,7 @@ require('./src/services/serverControlService');
require('./src/services/videoSessions');
require('./src/services/ptzCameraService');
require('./src/services/videoAuthService');
require('./src/services/mediaMtxService');
require('./src/services/videoSocketService');
require('./src/services/roomCameraService');
require('./src/services/roverSnapshotService');
+23 -40
View File
@@ -8,8 +8,6 @@ NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/downl
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
NEOLINK_BIN="/usr/local/bin/neolink"
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
MEDIAMTX_CONF_DIR="/etc/mediamtx"
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
@@ -35,7 +33,6 @@ SERVER_DIR="$SCRIPT_DIR"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
@@ -259,53 +256,39 @@ if ! verify_google_tts_helper; then
verify_google_tts_helper
fi
mkdir -p "$MEDIAMTX_CONF_DIR"
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
exit 1
fi
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
exit 1
fi
if [[ -f "$MEDIAMTX_CONFIG" ]]; then
echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
else
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
fi
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
# Validate the new source of truth before disabling a working legacy service. The validator
# performs the same build and YAML serialization as server startup without opening listeners
# or leaving a process behind.
runuser -u "$TARGET_USER" -- env \
SERVER_CONFIG="$CONFIG_PATH" \
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
# MediaMTX used to run as its own systemd service with a hand-maintained config in
# /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two
# processes race for every media listener. Both commands are deliberately idempotent so an
# already-migrated server and a first-time installation follow the same path.
echo " Disabling legacy mediamtx.service"
systemctl disable --now mediamtx.service 2>/dev/null || true
rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
cat > "$MEDIAMTX_SERVICE" <<EOF
[Unit]
Description=mediaMTX WebRTC Server
After=network-online.target
Wants=network-online.target
[Service]
User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$MEDIAMTX_CONF_DIR
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
cat > "$MULTIROVER_SERVICE" <<EOF
[Unit]
Description=Multi-Roomba Rover control server
After=network-online.target mediamtx.service bluetooth.service
After=network-online.target bluetooth.service
Wants=network-online.target bluetooth.service
[Service]
@@ -316,6 +299,9 @@ Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
RuntimeDirectory=multirover
RuntimeDirectoryMode=0750
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
RestartSec=2
@@ -325,20 +311,17 @@ SuccessExitStatus=130 143
WantedBy=multi-user.target
EOF
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
chmod 644 "$MULTIROVER_SERVICE"
echo "[5/6] Enabling services..."
systemctl daemon-reload
systemctl enable --now mediamtx.service
systemctl enable --now multirover.service
systemctl restart mediamtx.service
systemctl restart multirover.service
echo "[6/6] Done."
echo
echo "Services installed:"
echo " mediamtx.service (WebRTC fan-out)"
echo " multirover.service (Node.js control server)"
echo " multirover.service (Node.js control server with MediaMTX child)"
echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Kinect/libfreenect packages and udev permissions were installed."
-47
View File
@@ -1,47 +0,0 @@
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
logLevel: info
api: yes
apiAddress: 0.0.0.0:9997
metrics: yes
metricsAddress: 0.0.0.0:9998
pprof: no
pprofAddress: 127.0.0.1:9999
rtsp: no
rtmp: no
hls: no
webrtc: yes
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
webrtcICEServers2:
# Google public STUN (world-wide, very commonly used)
- 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
# Cloudflare STUN (anycast, global PoPs)
- url: stun:stun.cloudflare.com:3478
srt: yes
srtAddress: :9000
authMethod: http
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
paths:
all:
source: publisher
sourceOnDemand: no
# Rover Snapshot Writer
# Keep rover snapshots continuously updated while a rover video path is live.
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
runOnReadyRestart: yes
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// MediaMTX Configuration Validator
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
const yaml = require('js-yaml');
const { loadConfig } = require('../src/helpers/configLoader');
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
const config = loadConfig();
const generated = buildMediaMtxConfig({
config,
serverPort: process.env.PORT || 8080,
snapshotWriterPath: process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
});
/*
Serializing is part of validation: it catches values that the builder accepted but js-yaml
cannot represent before the installer removes the previous service configuration.
*/
yaml.dump(generated, { noRefs: true, lineWidth: 120 });
process.stdout.write('MediaMTX server configuration is valid\n');
@@ -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);
});