mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
switched rover <-> server streams to rtsptcp, and moved mediamtx to be a server owned and configured child process!
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
Executable
+21
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user