diff --git a/dist/hornverifier b/dist/hornverifier index b180ee7b..52827032 100755 Binary files a/dist/hornverifier and b/dist/hornverifier differ diff --git a/dist/roverd b/dist/roverd index b5dcd369..090cfac0 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/dist/roverd-debian-laptop b/dist/roverd-debian-laptop index ef4aaf12..851006be 100755 Binary files a/dist/roverd-debian-laptop and b/dist/roverd-debian-laptop differ diff --git a/dist/servoverifier b/dist/servoverifier index 14ae5739..9f495f1f 100755 Binary files a/dist/servoverifier and b/dist/servoverifier differ diff --git a/pi/bin/audio-forward-listener.sh b/pi/bin/audio-forward-listener.sh index 5d5efd70..067d4132 100755 --- a/pi/bin/audio-forward-listener.sh +++ b/pi/bin/audio-forward-listener.sh @@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then exit 1 fi -# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The -# forward URL is data produced by roverd, and treating it as shell code would -# break on normal SRT query-string characters such as '&'. +# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is +# data produced by roverd and must never be interpreted as executable shell code. load_env_file() { local content="" @@ -95,6 +94,9 @@ run_pipeline() { -flags low_delay -analyzeduration 200k -probesize 32k + # The forwarded-audio URL is RTSP. Pinning TCP avoids ffmpeg negotiating the + # separate unreliable RTP/UDP transport that the server intentionally disables. + -rtsp_transport tcp -i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}" -vn ) diff --git a/pi/bin/audio-only-publisher.sh b/pi/bin/audio-only-publisher.sh index 0c0af0e6..74fe5422 100644 --- a/pi/bin/audio-only-publisher.sh +++ b/pi/bin/audio-only-publisher.sh @@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then exit 1 fi -# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs -# contain characters such as '&' and '#!', so sourcing this file would treat a -# data file as code and can split a valid URL into shell control operators. +# Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data; +# sourcing this file would unnecessarily treat server-provided values as shell code. load_env_file() { local content="" @@ -88,6 +87,7 @@ else fi run_pipeline() { + local -a pipeline_statuses=() local ffmpeg_args=( -hide_banner -loglevel warning @@ -123,13 +123,14 @@ run_pipeline() { -frame_duration 20 -compression_level 0 - # Mirror the video publisher's MPEG-TS low-latency settings. Without - # these, ffmpeg is allowed to hold packets for mux timing, which is - # exactly the wrong tradeoff for live rover feedback. + # RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly + # MPEG-TS demux without changing microphone capture or encoding quality. TCP is + # required for the same reliable local-network behavior as the video publisher. -flush_packets 1 -muxdelay 0 -muxpreload 0 - -f mpegts + -f rtsp + -rtsp_transport tcp "${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}" ) @@ -146,6 +147,17 @@ run_pipeline() { # latency compared with the old 65,536-byte buffer. arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \ | "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" + pipeline_statuses=("${PIPESTATUS[@]}") + + # PIPESTATUS belongs to the pipeline that just finished and is replaced by the next shell + # command. Capture it immediately, then return the publisher failure first because that is + # normally the reason arecord receives a secondary broken pipe. + LAST_ARECORD_STATUS="${pipeline_statuses[0]:-unknown}" + LAST_FFMPEG_STATUS="${pipeline_statuses[1]:-unknown}" + if [[ "${LAST_FFMPEG_STATUS}" != "0" ]]; then + return "${LAST_FFMPEG_STATUS}" + fi + return "${LAST_ARECORD_STATUS}" } trap 'kill 0 2>/dev/null' EXIT INT TERM @@ -154,6 +166,6 @@ while true; do if run_pipeline; then exit 0 fi - echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2 + echo "Audio-only publisher exited arecord=${LAST_ARECORD_STATUS:-unknown} ffmpeg=${LAST_FFMPEG_STATUS:-unknown}, restarting in 2s..." >&2 sleep 2 done diff --git a/pi/bin/debian-laptop-video-publisher.sh b/pi/bin/debian-laptop-video-publisher.sh index 3038a010..8a11d9c5 100755 --- a/pi/bin/debian-laptop-video-publisher.sh +++ b/pi/bin/debian-laptop-video-publisher.sh @@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then exit 1 fi -# Load roverd's generated media.env as data instead of sourcing it as shell. -# The SRT publish URL contains normal query-string characters like '&' and '#!', -# so evaluating the file would be both fragile and unnecessary. +# Load roverd's generated media.env as data instead of sourcing it as shell. URLs are +# configuration data, so evaluating the file would be both fragile and unnecessary. load_env_file() { local content="" @@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then fi run_pipeline() { + # Keep laptop rovers on the same transport contract as Pi camera rovers. This changes + # only the encoded stream's carrier; V4L2 capture and H264 encoding remain untouched. "${FFMPEG_BIN_PATH}" \ -hide_banner \ -loglevel warning \ @@ -130,7 +131,8 @@ run_pipeline() { -flush_packets 1 \ -muxdelay 0 \ -muxpreload 0 \ - -f mpegts \ + -f rtsp \ + -rtsp_transport tcp \ "${ROVERD_VIDEO_PUBLISH_URL}" } diff --git a/pi/bin/media-transport.test.sh b/pi/bin/media-transport.test.sh new file mode 100755 index 00000000..a24ab76f --- /dev/null +++ b/pi/bin/media-transport.test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# These publishers contain hardware-facing infinite retry loops, so executing them in a unit +# test would require unsafe process-group traps and fake camera/ALSA devices. Pin the small +# transport boundary directly instead: every publisher must request RTSP/TCP and none may +# reintroduce the high-latency MPEG-TS muxer. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + +assert_rtsp_tcp() { + local file="$1" + if ! grep -q -- '-f rtsp' "$file"; then + echo "Missing RTSP muxer in $file" >&2 + exit 1 + fi + if ! grep -q -- '-rtsp_transport tcp' "$file"; then + echo "Missing RTSP/TCP pin in $file" >&2 + exit 1 + fi + if grep -q -- '-f mpegts' "$file"; then + echo "Unexpected MPEG-TS muxer in $file" >&2 + exit 1 + fi +} + +assert_rtsp_tcp "$SCRIPT_DIR/video-publisher.sh" +assert_rtsp_tcp "$SCRIPT_DIR/debian-laptop-video-publisher.sh" +assert_rtsp_tcp "$SCRIPT_DIR/audio-only-publisher.sh" + +# The speaker path reads rather than publishes, so it has no output muxer. It must still pin +# RTSP/TCP before its input URL to match the server's TCP-only listener. +if ! grep -q -- '-rtsp_transport tcp' "$SCRIPT_DIR/audio-forward-listener.sh"; then + echo "Missing RTSP/TCP input pin in audio-forward-listener.sh" >&2 + exit 1 +fi + +echo "Media publisher transport checks passed" diff --git a/pi/bin/video-publisher.sh b/pi/bin/video-publisher.sh index f9111a66..1dab1774 100644 --- a/pi/bin/video-publisher.sh +++ b/pi/bin/video-publisher.sh @@ -110,6 +110,10 @@ else fi run_pipeline() { + # MPEG-TS added most of the former rover-to-browser latency inside MediaMTX's + # demuxer. RTSP carries the same encoded H264 without changing the camera or codec. + # TCP is explicit because plain RTSP/RTP over UDP has no retransmission and proved + # unreliable even though MediaMTX still reported the incomplete stream as ready. "${LIBCAMERA_BIN_PATH}" \ --inline \ --timeout 0 \ @@ -142,7 +146,8 @@ run_pipeline() { -flush_packets 1 \ -muxdelay 0 \ -muxpreload 0 \ - -f mpegts \ + -f rtsp \ + -rtsp_transport tcp \ "${ROVERD_VIDEO_PUBLISH_URL}" } diff --git a/pi/install/media_env.sh b/pi/install/media_env.sh index 8c56d206..3a636f10 100644 --- a/pi/install/media_env.sh +++ b/pi/install/media_env.sh @@ -13,7 +13,7 @@ write_media_env_placeholder() { # Managed by roverd; placeholder values will be overwritten at runtime. ROVERD_VIDEO_ENABLE=1 ROVERD_VIDEO_PUBLISHER=pi-libcamera -ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 +ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME ROVERD_VIDEO_DEVICE= ROVERD_VIDEO_INPUT_FORMAT= ROVERD_VIDEO_WIDTH=640 @@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000 ROVERD_VIDEO_INVERT=1 ROVERD_VIDEO_SENSOR_MODE=1296:972 ROVERD_AUDIO_CAPTURE_ENABLE=0 -ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 +ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0 ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000 ROVERD_AUDIO_CAPTURE_CHANNELS=2 ROVERD_AUDIO_CAPTURE_BITRATE=510000 ROVERD_AUDIO_PLAYBACK_ENABLE=1 -ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316 +ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd ROVERD_AUDIO_PLAYBACK_DEVICE=forward ROVERD_AUDIO_PLAYBACK_NORMALIZE=1 ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled @@ -43,7 +43,7 @@ ENV # Managed by roverd; placeholder values will be overwritten at runtime. ROVERD_VIDEO_ENABLE=1 ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2 -ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 +ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME ROVERD_VIDEO_DEVICE=/dev/video0 ROVERD_VIDEO_INPUT_FORMAT=mjpeg ROVERD_VIDEO_WIDTH=640 @@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000 ROVERD_VIDEO_INVERT=0 ROVERD_VIDEO_SENSOR_MODE= ROVERD_AUDIO_CAPTURE_ENABLE=1 -ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 +ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio ROVERD_AUDIO_CAPTURE_DEVICE=default ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000 ROVERD_AUDIO_CAPTURE_CHANNELS=2 ROVERD_AUDIO_CAPTURE_BITRATE=510000 ROVERD_AUDIO_PLAYBACK_ENABLE=1 -ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316 +ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd ROVERD_AUDIO_PLAYBACK_DEVICE=forward ROVERD_AUDIO_PLAYBACK_NORMALIZE=1 ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled diff --git a/pi/roverd/config.go b/pi/roverd/config.go index e4fcc0da..bb462931 100644 --- a/pi/roverd/config.go +++ b/pi/roverd/config.go @@ -3,9 +3,11 @@ package roverd import ( "errors" "fmt" + "net" "net/url" "os" "regexp" + "strconv" "strings" "time" @@ -77,10 +79,10 @@ type HornConfig struct { } type MediaConfig struct { - // PublishPort is shared by the derived video, microphone, and forwarded-audio - // SRT URLs. Keeping it at this level prevents each nested block from needing + // RTSPPort is shared by the derived video, microphone, and forwarded-audio + // RTSP URLs. Keeping it at this level prevents each nested block from needing // to repeat the same server port when the common MediaMTX listener is used. - PublishPort int `yaml:"publishPort" json:"-"` + RTSPPort int `yaml:"rtspPort" json:"-"` Manage bool `yaml:"manage" json:"manage"` HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"` HealthInterval Duration `yaml:"healthInterval" json:"-"` @@ -93,10 +95,12 @@ type VideoMediaConfig struct { // Publisher selects the installed publisher script/pipeline family. The // first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added // without changing the server-facing media shape again. - Enabled bool `yaml:"enabled" json:"enabled"` - Service string `yaml:"service" json:"service,omitempty"` - Publisher string `yaml:"publisher" json:"publisher,omitempty"` - PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` + Enabled bool `yaml:"enabled" json:"enabled"` + Service string `yaml:"service" json:"service,omitempty"` + Publisher string `yaml:"publisher" json:"publisher,omitempty"` + // PublishURL is derived during validation. It remains in rover metadata for server-side + // consumers, but is not a second hand-written endpoint in /etc/roverd.yaml. + PublishURL string `yaml:"-" json:"publishUrl,omitempty"` Device string `yaml:"device" json:"device,omitempty"` InputFormat string `yaml:"inputFormat" json:"-"` Width int `yaml:"width" json:"-"` @@ -111,9 +115,10 @@ type AudioCaptureConfig struct { // AudioCapture describes the rover microphone stream that browsers can // subscribe to as "-audio". A disabled capture block still has // normalized defaults so enabling it only requires flipping enabled: true. - Enabled bool `yaml:"enabled" json:"enabled"` - Service string `yaml:"service" json:"service,omitempty"` - PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` + Enabled bool `yaml:"enabled" json:"enabled"` + Service string `yaml:"service" json:"service,omitempty"` + // PublishURL follows the same derived-only contract as the video path. + PublishURL string `yaml:"-" json:"publishUrl,omitempty"` Device string `yaml:"device" json:"device,omitempty"` SampleRate int `yaml:"sampleRate" json:"-"` Channels int `yaml:"channels" json:"-"` @@ -125,9 +130,10 @@ type AudioPlaybackConfig struct { // MediaMTX for playback on the rover speaker. The URL is a request/read URL // for the rover listener, while the server converts it to publish mode when // it needs to inject audio. - Enabled bool `yaml:"enabled" json:"enabled"` - Service string `yaml:"service" json:"service,omitempty"` - ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"` + Enabled bool `yaml:"enabled" json:"enabled"` + Service string `yaml:"service" json:"service,omitempty"` + // ForwardURL is derived because the server and rover must agree on the exact -fwd path. + ForwardURL string `yaml:"-" json:"forwardUrl,omitempty"` Device string `yaml:"device" json:"device,omitempty"` Normalize bool `yaml:"normalize" json:"-"` NormalizeFilter string `yaml:"normalizeFilter" json:"-"` @@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) { }, }, Media: MediaConfig{ - PublishPort: 9000, + RTSPPort: 8554, HealthInterval: Duration{Duration: 30 * time.Second}, Video: VideoMediaConfig{ Enabled: true, @@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) { if cfg.BRC.GPIOChip == "" { cfg.BRC.GPIOChip = "gpiochip0" } - if cfg.Media.PublishPort <= 0 { - cfg.Media.PublishPort = 9000 + if cfg.Media.RTSPPort <= 0 { + cfg.Media.RTSPPort = 8554 } if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil { return nil, fmt.Errorf("media: %w", err) @@ -432,25 +438,25 @@ func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) e file is written. This keeps the Pi behavior stable while making laptop and future publisher variants explicit configuration choices. */ - if cfg.PublishPort <= 0 { - cfg.PublishPort = 9000 + if cfg.RTSPPort <= 0 { + cfg.RTSPPort = 8554 } if cfg.HealthInterval.Duration <= 0 { cfg.HealthInterval = Duration{Duration: 30 * time.Second} } - if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil { + if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.RTSPPort); err != nil { return fmt.Errorf("video: %w", err) } - if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil { + if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.RTSPPort); err != nil { return fmt.Errorf("audioCapture: %w", err) } - if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil { + if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.RTSPPort); err != nil { return fmt.Errorf("audioPlayback: %w", err) } return nil } -func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error { +func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, rtspPort int) error { if cfg.Service == "" { cfg.Service = "video-publisher.service" } @@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" { cfg.SensorMode = "1296:972" } - if cfg.PublishURL == "" { - derived, err := derivePublishURL(serverURL, roverName, publishPort) - if err != nil { - return fmt.Errorf("derive publishUrl: %w", err) - } - cfg.PublishURL = derived + /* + Always derive this endpoint. Older rover configs can contain an explicit SRT publishUrl; + honoring it after a binary update would silently leave that rover on the old transport. + */ + derived, err := derivePublishURL(serverURL, roverName, rtspPort) + if err != nil { + return fmt.Errorf("derive publishUrl: %w", err) } + cfg.PublishURL = derived return nil } -func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error { +func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, rtspPort int) error { if cfg.Service == "" { cfg.Service = "audio-only-publisher.service" } @@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover if cfg.Bitrate <= 0 { cfg.Bitrate = 510000 } - if cfg.PublishURL == "" { - derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort) - if err != nil { - return fmt.Errorf("derive publishUrl: %w", err) - } - cfg.PublishURL = derived + derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort) + if err != nil { + return fmt.Errorf("derive publishUrl: %w", err) } + cfg.PublishURL = derived return nil } -func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error { +func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, rtspPort int) error { if cfg.Service == "" { cfg.Service = "audio-forward-listener.service" } @@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov if cfg.NormalizeFilter == "" { cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled" } - if cfg.ForwardURL == "" { - derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort) - if err != nil { - return fmt.Errorf("derive forwardUrl: %w", err) - } - cfg.ForwardURL = derived + derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort) + if err != nil { + return fmt.Errorf("derive forwardUrl: %w", err) } + cfg.ForwardURL = derived return nil } @@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) { } func derivePublishURL(serverURL, streamName string, port int) (string, error) { - return deriveSRTURL(serverURL, streamName, port, "publish") + return deriveRTSPURL(serverURL, streamName, port) } func deriveReadURL(serverURL, streamName string, port int) (string, error) { - return deriveSRTURL(serverURL, streamName, port, "request") + return deriveRTSPURL(serverURL, streamName, port) } -func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) { +func deriveRTSPURL(serverURL, streamName string, port int) (string, error) { if streamName == "" { return "", errors.New("missing stream name for publishUrl") } - if mode == "" { - mode = "publish" - } parsed, err := url.Parse(serverURL) if err != nil { return "", err @@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, return "", errors.New("serverUrl missing host") } if port <= 0 { - port = 9000 + port = 8554 } + /* + JoinHostPort handles both ordinary hostnames and bracketed IPv6 addresses. The rover name + is a MediaMTX path, so it is escaped independently instead of interpolated into the host. + RTSP distinguishes publishing from reading through protocol methods, which is why both + directions intentionally use the same URL shape. + */ escaped := url.PathEscape(streamName) - return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil + return fmt.Sprintf("rtsp://%s/%s", net.JoinHostPort(host, strconv.Itoa(port)), escaped), nil } var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) diff --git a/pi/roverd/media_url_test.go b/pi/roverd/media_url_test.go new file mode 100644 index 00000000..bae2e394 --- /dev/null +++ b/pi/roverd/media_url_test.go @@ -0,0 +1,80 @@ +package roverd + +// These tests pin the network-agnostic RTSP contract. A rover provides its server URL and name +// once; all three media paths must then resolve to distinct, safely escaped MediaMTX paths. + +import ( + "strings" + "testing" +) + +func TestMediaURLsDeriveFromServerURLAndRoverName(t *testing.T) { + cfg := MediaConfig{ + Video: VideoMediaConfig{Enabled: true}, + AudioCapture: AudioCaptureConfig{Enabled: true}, + AudioPlayback: AudioPlaybackConfig{Enabled: true}, + } + if err := validateMediaConfig(&cfg, "ws://control-server.local:8080/rover", "rover one"); err != nil { + t.Fatalf("validate media config: %v", err) + } + + wants := map[string]string{ + "video": "rtsp://control-server.local:8554/rover%20one", + "mic": "rtsp://control-server.local:8554/rover%20one-audio", + "speaker": "rtsp://control-server.local:8554/rover%20one-fwd", + } + got := map[string]string{ + "video": cfg.Video.PublishURL, + "mic": cfg.AudioCapture.PublishURL, + "speaker": cfg.AudioPlayback.ForwardURL, + } + for name, want := range wants { + if got[name] != want { + t.Errorf("%s URL: got %q, want %q", name, got[name], want) + } + } + if cfg.RTSPPort != 8554 { + t.Fatalf("RTSP port: got %d, want 8554", cfg.RTSPPort) + } +} + +func TestExplicitMediaPortAppliesToEveryRTSPPath(t *testing.T) { + cfg := MediaConfig{ + RTSPPort: 10554, + Video: VideoMediaConfig{Enabled: true}, + AudioCapture: AudioCaptureConfig{Enabled: true}, + AudioPlayback: AudioPlaybackConfig{Enabled: true}, + } + if err := validateMediaConfig(&cfg, "ws://media.example/rover", "r1"); err != nil { + t.Fatalf("validate media config: %v", err) + } + for name, value := range map[string]string{ + "video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL, + } { + if !strings.Contains(value, ":10554/") { + t.Errorf("%s URL did not use configured port: %q", name, value) + } + } +} + +func TestLegacyExplicitSRTURLsCannotKeepAnUpdatedRoverOnTheOldTransport(t *testing.T) { + /* + Deployed rover configs can still contain these former fields. Validation must replace + them unconditionally so updating roverd is sufficient to move the whole media path. + */ + cfg := MediaConfig{ + Video: VideoMediaConfig{Enabled: true, PublishURL: "srt://old/video"}, + AudioCapture: AudioCaptureConfig{Enabled: true, PublishURL: "srt://old/audio"}, + AudioPlayback: AudioPlaybackConfig{Enabled: true, ForwardURL: "srt://old/forward"}, + } + if err := validateMediaConfig(&cfg, "ws://new-server.local:8080/rover", "r1"); err != nil { + t.Fatalf("validate media config: %v", err) + } + for name, value := range map[string]string{ + "video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL, + } { + if !strings.HasPrefix(value, "rtsp://new-server.local:8554/") { + t.Errorf("%s retained an old transport URL: %q", name, value) + } + } +} diff --git a/pi/roverd/roverd.debian-laptop.sample.yaml b/pi/roverd/roverd.debian-laptop.sample.yaml index 8c3ef4ca..77739636 100644 --- a/pi/roverd/roverd.debian-laptop.sample.yaml +++ b/pi/roverd/roverd.debian-laptop.sample.yaml @@ -22,7 +22,8 @@ battery: maxWheelSpeed: 350 media: - publishPort: 9000 + # Media URLs are derived from serverUrl's hostname, this port, and the rover name. + rtspPort: 8554 manage: true healthUrl: "" healthInterval: 30s diff --git a/pi/roverd/roverd.sample.yaml b/pi/roverd/roverd.sample.yaml index 218c12ca..e9294ccc 100644 --- a/pi/roverd/roverd.sample.yaml +++ b/pi/roverd/roverd.sample.yaml @@ -17,7 +17,8 @@ battery: urgent: 1650 maxWheelSpeed: 350 media: - publishPort: 9000 + # Media URLs are derived from serverUrl's hostname, this port, and the rover name. + rtspPort: 8554 manage: true healthUrl: "" healthInterval: 30s @@ -25,7 +26,6 @@ media: enabled: true service: video-publisher.service publisher: pi-libcamera - publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 width: 640 height: 480 fps: 30 @@ -36,7 +36,6 @@ media: audioCapture: enabled: false service: audio-only-publisher.service - publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 device: hw:0,0 sampleRate: 48000 channels: 2 @@ -44,7 +43,6 @@ media: audioPlayback: enabled: true service: audio-forward-listener.service - forwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316 device: forward normalize: true cameraServo: diff --git a/pi/systemd/audio-forward-listener.service b/pi/systemd/audio-forward-listener.service index 44cf488e..b578c062 100644 --- a/pi/systemd/audio-forward-listener.service +++ b/pi/systemd/audio-forward-listener.service @@ -1,5 +1,5 @@ [Unit] -Description=Rover Audio Forward Listener (SRT -> ALSA) +Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA) After=network-online.target roverd.service Wants=network-online.target diff --git a/pi/systemd/audio-only-publisher.service b/pi/systemd/audio-only-publisher.service index 20e0da4d..1af0b999 100644 --- a/pi/systemd/audio-only-publisher.service +++ b/pi/systemd/audio-only-publisher.service @@ -1,5 +1,5 @@ [Unit] -Description=Rover Audio Publisher (ALSA -> SRT) +Description=Rover Audio Publisher (ALSA -> RTSP/TCP) After=network-online.target roverd.service Wants=network-online.target diff --git a/pi/systemd/debian-laptop-video-publisher.service b/pi/systemd/debian-laptop-video-publisher.service index 6a9d61a1..a6d76fd6 100644 --- a/pi/systemd/debian-laptop-video-publisher.service +++ b/pi/systemd/debian-laptop-video-publisher.service @@ -1,5 +1,5 @@ [Unit] -Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT) +Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP) After=network-online.target roverd.service Wants=network-online.target diff --git a/pi/systemd/roverd.service b/pi/systemd/roverd.service index 41250ba2..e027f30b 100644 --- a/pi/systemd/roverd.service +++ b/pi/systemd/roverd.service @@ -1,6 +1,6 @@ [Unit] Description=Multi-Roomba rover control agent -After=network-online.target mediamtx.service +After=network-online.target Wants=network-online.target [Service] diff --git a/pi/systemd/video-publisher.service b/pi/systemd/video-publisher.service index d5ad04e8..42f52c3b 100644 --- a/pi/systemd/video-publisher.service +++ b/pi/systemd/video-publisher.service @@ -1,5 +1,5 @@ [Unit] -Description=Rover Video Publisher (libcamera -> SRT) +Description=Rover Video Publisher (libcamera -> RTSP/TCP) After=network-online.target roverd.service Wants=network-online.target diff --git a/server/config.example.yaml b/server/config.example.yaml index ddb2a6ce..e3b966d4 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -51,8 +51,15 @@ barcodeGames: media: # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request # http:////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. diff --git a/server/index.js b/server/index.js index bb2e6d3e..97c2ecb0 100644 --- a/server/index.js +++ b/server/index.js @@ -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'); diff --git a/server/install_server.sh b/server/install_server.sh index c797a202..01660661 100755 --- a/server/install_server.sh +++ b/server/install_server.sh @@ -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" < "$MULTIROVER_SERVICE" < { 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'); +}); diff --git a/server/src/services/audioForwardService/workerEngine.js b/server/src/services/audioForwardService/workerEngine.js index f72aa8af..99b98183 100644 --- a/server/src/services/audioForwardService/workerEngine.js +++ b/server/src/services/audioForwardService/workerEngine.js @@ -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, ]; } diff --git a/server/src/services/httpServer/index.js b/server/src/services/httpServer/index.js index 15377326..0b1d7ef3 100644 --- a/server/src/services/httpServer/index.js +++ b/server/src/services/httpServer/index.js @@ -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(); }); diff --git a/server/src/services/mediaMtxService/config.js b/server/src/services/mediaMtxService/config.js new file mode 100644 index 00000000..2abbd3ad --- /dev/null +++ b/server/src/services/mediaMtxService/config.js @@ -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, +}; diff --git a/server/src/services/mediaMtxService/config.test.js b/server/src/services/mediaMtxService/config.test.js new file mode 100644 index 00000000..8a7ba620 --- /dev/null +++ b/server/src/services/mediaMtxService/config.test.js @@ -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/); +}); diff --git a/server/src/services/mediaMtxService/index.js b/server/src/services/mediaMtxService/index.js new file mode 100644 index 00000000..47d01695 --- /dev/null +++ b/server/src/services/mediaMtxService/index.js @@ -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 }; diff --git a/server/src/services/mediaMtxService/supervisor.js b/server/src/services/mediaMtxService/supervisor.js new file mode 100644 index 00000000..64cbc8a0 --- /dev/null +++ b/server/src/services/mediaMtxService/supervisor.js @@ -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 }; diff --git a/server/src/services/videoAuthService/httpRoute.js b/server/src/services/videoAuthService/httpRoute.js index 7c78ea35..7b835cd1 100644 --- a/server/src/services/videoAuthService/httpRoute.js +++ b/server/src/services/videoAuthService/httpRoute.js @@ -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); diff --git a/server/src/services/videoAuthService/httpRoute.test.js b/server/src/services/videoAuthService/httpRoute.test.js new file mode 100644 index 00000000..5c340beb --- /dev/null +++ b/server/src/services/videoAuthService/httpRoute.test.js @@ -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); +});