use serde::Deserialize;
use super::PreflightCheck;
use super::exec::{DeployExecutor, DeployOp, FileContents, RemoteCommand};
pub const MEDIAMTX_RTMP_PORT: u16 = 1935;
pub const MEDIAMTX_HLS_PORT: u16 = 8888;
pub const MEDIAMTX_WEBRTC_PORT: u16 = 8889;
pub const MEDIAMTX_PLAYBACK_PORT: u16 = 9996;
pub const MEDIAMTX_API_PORT: u16 = 9997;
pub const MEDIAMTX_WEBRTC_LOCAL_UDP_PORT: u16 = 8189;
const HLS_VARIANT: &str = "lowLatency";
const HLS_PART_DURATION: &str = "200ms";
const HLS_SEGMENT_DURATION: &str = "2s";
const HLS_SEGMENT_COUNT: u32 = 60;
fn default_recordings_dir() -> String {
"/recordings".to_owned()
}
fn default_record_delete_after() -> String {
"72h".to_owned()
}
fn default_config_path() -> String {
"/etc/mediamtx/mediamtx.yml".to_owned()
}
fn default_binary_path() -> String {
"/usr/local/bin/mediamtx".to_owned()
}
fn default_unit_name() -> String {
"mediamtx".to_owned()
}
const fn default_api_port() -> u16 {
MEDIAMTX_API_PORT
}
const fn default_rtmp_port() -> u16 {
MEDIAMTX_RTMP_PORT
}
const fn default_hls_port() -> u16 {
MEDIAMTX_HLS_PORT
}
const fn default_webrtc_port() -> u16 {
MEDIAMTX_WEBRTC_PORT
}
const fn default_playback_port() -> u16 {
MEDIAMTX_PLAYBACK_PORT
}
const fn default_webrtc_local_udp() -> u16 {
MEDIAMTX_WEBRTC_LOCAL_UDP_PORT
}
const DEFAULT_API_BASE: &str = "http://127.0.0.1:9997";
const DEFAULT_RTMP_BASE: &str = "rtmp://127.0.0.1:1935/live";
const DEFAULT_HLS_BASE: &str = "http://127.0.0.1:8888";
const DEFAULT_WEBRTC_BASE: &str = "http://127.0.0.1:8889";
const DEFAULT_PLAYBACK_BASE: &str = "http://127.0.0.1:9996";
pub const DEFAULT_FFMPEG_BIN: &str = "/usr/bin/ffmpeg";
fn default_ffmpeg_bin() -> String {
DEFAULT_FFMPEG_BIN.to_owned()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MediaMtxHostConfig {
pub enabled: bool,
pub api_port: u16,
pub rtmp_port: u16,
pub hls_port: u16,
pub webrtc_port: u16,
pub playback_port: u16,
pub webrtc_local_udp: u16,
pub recordings_dir: String,
pub record_delete_after: String,
pub config_path: String,
pub binary_path: String,
pub install_binary: bool,
pub unit_name: String,
pub webrtc_additional_hosts: Vec<String>,
pub api_base: Option<String>,
pub rtmp_base: Option<String>,
pub hls_base: Option<String>,
pub hls_probe_base: Option<String>,
pub webrtc_base: Option<String>,
pub playback_base: Option<String>,
pub playback_probe_base: Option<String>,
}
impl Default for MediaMtxHostConfig {
fn default() -> Self {
Self {
enabled: false,
api_port: default_api_port(),
rtmp_port: default_rtmp_port(),
hls_port: default_hls_port(),
webrtc_port: default_webrtc_port(),
playback_port: default_playback_port(),
webrtc_local_udp: default_webrtc_local_udp(),
recordings_dir: default_recordings_dir(),
record_delete_after: default_record_delete_after(),
config_path: default_config_path(),
binary_path: default_binary_path(),
install_binary: true,
unit_name: default_unit_name(),
webrtc_additional_hosts: Vec::new(),
api_base: None,
rtmp_base: None,
hls_base: None,
hls_probe_base: None,
webrtc_base: None,
playback_base: None,
playback_probe_base: None,
}
}
}
pub const MEDIA_INSTALL_BINARY_LABEL: &str = "media-install-binary";
pub const MEDIAMTX_KNOWN_GOOD_VERSION: &str = "1.19.3";
pub const MEDIAMTX_KNOWN_GOOD_DIGEST: &str =
"sha256:7797ed3df88df21e8c04ecd0aff08ce49a5232d1db453e51f5480ef36bc80865";
#[derive(Debug, Default, Deserialize)]
struct MediaTomlRoot {
#[serde(default)]
media: MediaSection,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct MediaSection {
mediamtx: MediaMtxHostConfig,
ffmpeg: FfmpegSection,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
struct FfmpegSection {
bin: String,
}
impl Default for FfmpegSection {
fn default() -> Self {
Self {
bin: default_ffmpeg_bin(),
}
}
}
impl MediaMtxHostConfig {
#[must_use]
pub fn required_csp_origins(&self) -> Vec<String> {
vec![
format!("http://127.0.0.1:{}", self.webrtc_port),
format!("http://127.0.0.1:{}", self.hls_port),
format!("http://127.0.0.1:{}", self.playback_port),
]
}
}
pub fn media_host_config_from_value(
root: toml::Value,
) -> Result<(MediaMtxHostConfig, String), toml::de::Error> {
let parsed: MediaTomlRoot = root.try_into()?;
Ok((parsed.media.mediamtx, parsed.media.ffmpeg.bin))
}
#[must_use]
pub fn render_mediamtx_yml(cfg: &MediaMtxHostConfig) -> String {
let webrtc_hosts = if cfg.webrtc_additional_hosts.is_empty() {
"[]".to_owned()
} else {
let joined = cfg
.webrtc_additional_hosts
.iter()
.map(|h| yaml_single_quote(h))
.collect::<Vec<_>>()
.join(", ");
format!("[{joined}]")
};
let record_path = yaml_single_quote(&format!(
"{}/%path/%Y-%m-%d_%H-%M-%S-%f",
cfg.recordings_dir
));
let record_delete = yaml_single_quote(&cfg.record_delete_after);
format!(
"logLevel: info\n\
\n\
# Only the protocols autumn-media manages are enabled below (RTMP ingest,\n\
# HLS, WebRTC/WHEP playback, the control API, and the recording-playback\n\
# server). Every other protocol MediaMTX turns on by default is disabled\n\
# explicitly, so the systemd unit only ever opens the ports this deploy\n\
# plan declares — an occupied default RTSP (:8554), SRT (:8890), or MoQ\n\
# (:8892) port cannot make `systemctl restart mediamtx` fail after\n\
# preflight passed. (`rtsp: no` also disables RTSPS; `moq: no` closes the\n\
# default-on MoQ HTTP/2+HTTP/3 listeners — autumn-media does not use MoQ;\n\
# `metrics`/`pprof` default off but are pinned off for the same 'declared\n\
# ports only' contract.)\n\
rtsp: no\n\
srt: no\n\
moq: no\n\
metrics: no\n\
pprof: no\n\
\n\
# The control API is the server-side control plane: the co-located app\n\
# reaches it at 127.0.0.1:{api} and it is intentionally excluded from the\n\
# browser-facing CSP origins, so bind it to loopback only rather than\n\
# every interface. The viewer/ingest listeners below (RTMP, HLS,\n\
# WebRTC/WHEP, playback) stay bound on all interfaces so encoders and\n\
# browsers can reach them.\n\
api: true\n\
apiAddress: 127.0.0.1:{api}\n\
\n\
playback: true\n\
playbackAddress: :{playback}\n\
\n\
rtmp: true\n\
rtmpAddress: :{rtmp}\n\
\n\
hls: true\n\
hlsAddress: :{hls}\n\
hlsAllowOrigins: ['*']\n\
hlsVariant: {hls_variant}\n\
hlsPartDuration: {hls_part}\n\
# DVR-on-live window: retained window = hlsSegmentCount x hlsSegmentDuration\n\
# = {seg_count} x {seg_dur}. Surfaced to the player so the UI never promises\n\
# more rewind than MediaMTX actually keeps.\n\
hlsSegmentCount: {seg_count}\n\
hlsSegmentDuration: {seg_dur}\n\
\n\
webrtc: true\n\
webrtcAddress: :{webrtc}\n\
webrtcAllowOrigins: ['*']\n\
webrtcLocalUDPAddress: :{webrtc_udp}\n\
webrtcAdditionalHosts: {webrtc_hosts}\n\
\n\
pathDefaults:\n\
\x20\x20source: publisher\n\
\x20\x20record: true\n\
\x20\x20recordPath: {rec_path}\n\
\x20\x20recordFormat: fmp4\n\
\x20\x20recordPartDuration: 1s\n\
\x20\x20recordSegmentDuration: 5m\n\
\x20\x20recordDeleteAfter: {rec_delete}\n\
\n\
paths:\n\
# Broadcast ingest (single-publisher channels), as in arroyo.\n\
\x20\x20~^live/.+$:\n\
\x20\x20\x20\x20source: publisher\n\
# autumn-media Rooms (Slice 6): room/{{namespace?}}/{{room_id}}/{{participant}}.\n\
# arroyo is broadcast-only and has no room matcher; this is the addition.\n\
\x20\x20~^room/.+$:\n\
\x20\x20\x20\x20source: publisher\n",
api = cfg.api_port,
playback = cfg.playback_port,
rtmp = cfg.rtmp_port,
hls = cfg.hls_port,
webrtc = cfg.webrtc_port,
webrtc_udp = cfg.webrtc_local_udp,
hls_variant = HLS_VARIANT,
hls_part = HLS_PART_DURATION,
seg_count = HLS_SEGMENT_COUNT,
seg_dur = HLS_SEGMENT_DURATION,
webrtc_hosts = webrtc_hosts,
rec_path = record_path,
rec_delete = record_delete,
)
}
#[must_use]
fn yaml_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[must_use]
fn systemd_quote(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
#[must_use]
pub fn render_mediamtx_unit(cfg: &MediaMtxHostConfig) -> String {
format!(
"[Unit]\n\
Description=MediaMTX (autumn-media host daemon)\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={bin} {config}\n\
Restart=always\n\
RestartSec=2\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
bin = systemd_quote(&cfg.binary_path),
config = systemd_quote(&cfg.config_path),
)
}
#[must_use]
fn parent_dir(path: &str) -> Option<&str> {
match path.rsplit_once('/') {
Some((parent, _)) if !parent.is_empty() => Some(parent),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct MediaMtxController {
cfg: MediaMtxHostConfig,
}
impl MediaMtxController {
#[must_use]
pub const fn new(cfg: MediaMtxHostConfig) -> Self {
Self { cfg }
}
#[must_use]
pub fn unit_path(&self) -> String {
format!("/etc/systemd/system/{}.service", self.cfg.unit_name)
}
#[must_use]
pub fn binary_install_ops(&self) -> Vec<DeployOp> {
if !self.cfg.enabled || !self.cfg.install_binary {
return Vec::new();
}
vec![DeployOp::Run(RemoteCommand::new(
MEDIA_INSTALL_BINARY_LABEL,
self.binary_install_shell(),
))]
}
fn binary_install_shell(&self) -> String {
let quote = super::exec::shell_quote;
let path = &self.cfg.binary_path;
let bin = quote(path);
let staged = quote(&format!("{path}.autumn-new"));
let image = quote(&format!(
"bluenviron/mediamtx:{MEDIAMTX_KNOWN_GOOD_VERSION}@{MEDIAMTX_KNOWN_GOOD_DIGEST}"
));
let want = quote(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
let mkdir = parent_dir(path)
.map(|parent| format!("mkdir -p {} || exit 1; ", quote(parent)))
.unwrap_or_default();
format!(
"( if [ -f {bin} ] && [ -x {bin} ]; then exit 0; fi; \
if [ -e {bin} ] || [ -L {bin} ]; then \
echo 'refusing to replace '{bin}': it is not an executable file' >&2; exit 2; fi; \
if ! command -v docker >/dev/null 2>&1; then \
export DEBIAN_FRONTEND=noninteractive; \
apt-get -o DPkg::Lock::Timeout=300 update -qq || exit 1; \
apt-get -o DPkg::Lock::Timeout=300 install -y -qq --no-install-recommends docker.io \
|| exit 1; fi; \
systemctl start docker >/dev/null 2>&1 || true; \
{mkdir}\
rm -rf {staged} || exit 1; \
cid=$(docker create {image}) || exit 1; \
rc=0; docker cp \"$cid:/mediamtx\" {staged} || rc=1; \
docker rm -f \"$cid\" >/dev/null 2>&1 || true; \
if [ \"$rc\" -ne 0 ]; then rm -rf {staged}; exit 1; fi; \
chmod 755 {staged} || exit 1; \
if [ \"$({staged} --version 2>/dev/null)\" != {want} ]; then \
rm -f {staged}; \
echo 'the copied MediaMTX is not '{want}' or does not run on this host' >&2; exit 2; fi; \
mv -f {staged} {bin} || exit 1 \
) || {{ rc=$?; \
if [ \"$rc\" -ne 2 ]; then \
echo 'autumn deploy could not install MediaMTX {MEDIAMTX_KNOWN_GOOD_VERSION}. It needs an \
apt-based (Debian/Ubuntu) host, outbound HTTPS to the distro mirrors and to Docker Hub, \
and an SSH user that can install packages.' >&2; fi; \
echo 'Install MediaMTX at '{bin}' yourself and set \
`[media.mediamtx] install_binary = false` in autumn.toml.' >&2; \
exit 1; }}"
)
}
#[must_use]
pub fn ensure_installed_ops(&self) -> Vec<DeployOp> {
if !self.cfg.enabled {
return Vec::new();
}
let unit = self.cfg.unit_name.clone();
let config_tmp = format!("{}.autumn-new", self.cfg.config_path);
let unit_path = self.unit_path();
let unit_tmp = format!("{unit_path}.autumn-new");
let mut ops = Vec::new();
let config_parent = parent_dir(&self.cfg.config_path);
let recordings = Some(self.cfg.recordings_dir.as_str())
.filter(|dir| unusable_recordings_dir(dir).is_none() && !dir.contains("${"));
let mut mkdirs: Vec<String> = Vec::new();
if let Some(parent) = config_parent.filter(|parent| Some(*parent) != recordings) {
mkdirs.push(format!("mkdir -p {}", super::exec::shell_quote(parent)));
}
if let Some(dir) = recordings {
mkdirs.push(format!(
"mkdir -p -m 0750 {}",
super::exec::shell_quote(dir)
));
}
if !mkdirs.is_empty() {
ops.push(DeployOp::Run(RemoteCommand::new(
"media-prepare-dirs",
mkdirs.join("; "),
)));
}
ops.push(DeployOp::WriteFile {
label: "media-write-config",
contents: FileContents::Plain(render_mediamtx_yml(&self.cfg)),
remote_path: config_tmp.clone(),
mode: Some(0o644),
});
ops.push(DeployOp::WriteFile {
label: "media-write-unit",
contents: FileContents::Plain(render_mediamtx_unit(&self.cfg)),
remote_path: unit_tmp.clone(),
mode: Some(0o644),
});
let cfg_live = super::exec::shell_quote(&self.cfg.config_path);
let cfg_new = super::exec::shell_quote(&config_tmp);
let unit_live = super::exec::shell_quote(&unit_path);
let unit_new = super::exec::shell_quote(&unit_tmp);
let unit_service = super::exec::shell_quote(&format!("{unit}.service"));
let install = format!(
"set -e; \
changed=0; \
if ! cmp -s {cfg_new} {cfg_live}; then cp {cfg_new} {cfg_live}; chmod 0644 {cfg_live}; changed=1; fi; \
if ! cmp -s {unit_new} {unit_live}; then cp {unit_new} {unit_live}; chmod 0644 {unit_live}; changed=1; fi; \
rm -f {cfg_new} {unit_new}; \
systemctl daemon-reload; \
systemctl enable {unit_service}; \
if [ \"$changed\" -ne 0 ] || ! systemctl is-active --quiet {unit_service}; then \
systemctl restart {unit_service}; \
fi",
);
ops.push(DeployOp::Run(RemoteCommand::new("media-install", install)));
ops
}
}
pub const CHECK_FFMPEG_PREFLIGHT: &str = "media_ffmpeg_preflight";
pub const CHECK_RECORDINGS_DIR_WRITABLE: &str = "media_recordings_dir_writable";
pub const CHECK_MEDIAMTX_PORTS_AVAILABLE: &str = "media_mediamtx_ports_available";
pub const CHECK_MEDIAMTX_BINARY: &str = "media_mediamtx_binary";
pub const CHECK_MEDIAMTX_PORTS_DISTINCT: &str = "media_mediamtx_ports_distinct";
const FFMPEG_HINT: &str =
"Install FFmpeg on the host (apt package) and set `[media.ffmpeg] bin` to its path";
const RECORDINGS_HINT: &str =
"Create `[media.mediamtx] recordings_dir` on the host and make it writable by the media user";
const PORTS_HINT: &str =
"Free the conflicting MediaMTX ports on the host (or stop the service already bound to them)";
const PORTS_DISTINCT_HINT: &str = "Give each MediaMTX TCP listener (`rtmp_port`/`hls_port`/`webrtc_port`/`playback_port`/`api_port`) \
a distinct port in `[media.mediamtx]`";
const MEDIAMTX_BINARY_HINT: &str = "Set `[media.mediamtx] install_binary = true` to let `deploy up` install the pinned \
MediaMTX, or put an executable MediaMTX at `binary_path` yourself";
const MEDIAMTX_BINARY_OCCUPIED_HINT: &str =
"Remove the file at `binary_path`, or replace it with an executable MediaMTX";
const FFMPEG_UNRESOLVED_HINT: &str = "Set `[media.ffmpeg] bin` to a concrete path to verify it here, or ensure \
AUTUMN_MEDIA__FFMPEG__BIN / the referenced ${VAR} is set in the SERVICE \
environment (the deploy side defers env-indirected paths to runtime)";
#[must_use]
pub fn ffmpeg_preflight(exec: &impl DeployExecutor, ffmpeg_bin: &str) -> PreflightCheck {
if ffmpeg_bin.trim().is_empty() || ffmpeg_bin.contains("${") {
return PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
scope: None,
passed: false,
deferred: true,
detail: format!(
"FFmpeg path uses env/interpolation indirection (`{ffmpeg_bin}`) resolved in \
the service environment; deferring verification to runtime"
),
hint: Some(FFMPEG_UNRESOLVED_HINT),
};
}
let cmd = RemoteCommand::new(
"media-ffmpeg-preflight",
format!("{} -version", super::exec::shell_quote(ffmpeg_bin)),
);
match exec.run(&cmd) {
Ok(out) if out.stdout.contains("ffmpeg version") => PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
scope: None,
passed: true,
deferred: false,
detail: format!("FFmpeg resolves at `{ffmpeg_bin}` and reports a version banner"),
hint: None,
},
Ok(_) => PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
scope: None,
passed: false,
deferred: false,
detail: format!(
"`{ffmpeg_bin} -version` ran but did not report an `ffmpeg version` banner — \
the binary is not FFmpeg"
),
hint: Some(FFMPEG_HINT),
},
Err(err) => PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
scope: None,
passed: false,
deferred: false,
detail: format!("could not run `{ffmpeg_bin} -version` on the host: {err}"),
hint: Some(FFMPEG_HINT),
},
}
}
fn unusable_recordings_dir(dir: &str) -> Option<String> {
if dir.trim().is_empty() {
return Some("`[media.mediamtx] recordings_dir` is empty".to_owned());
}
if !dir.starts_with('/') {
return Some(format!(
"`[media.mediamtx] recordings_dir` must be an absolute path, got `{dir}`"
));
}
None
}
#[must_use]
pub fn recordings_dir_writable(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
) -> PreflightCheck {
let dir = &cfg.recordings_dir;
let check = |passed: bool, deferred: bool, detail: String| PreflightCheck {
name: CHECK_RECORDINGS_DIR_WRITABLE,
scope: None,
passed,
deferred,
detail,
hint: (!passed && !deferred).then_some(RECORDINGS_HINT),
};
if dir.contains("${") {
return check(
false,
true,
format!(
"recordings directory `{dir}` uses env/interpolation indirection resolved in \
the service environment; deferring verification to runtime"
),
);
}
if let Some(reason) = unusable_recordings_dir(dir) {
return check(false, false, reason);
}
let quoted = super::exec::shell_quote(dir);
let cmd = RemoteCommand::new(
"media-recordings-dir",
format!(
"d={quoted}; \
if [ -d \"$d\" ]; then if [ -w \"$d\" ]; then printf present; \
else printf unwritable; fi; \
else while [ ! -e \"$d\" ] && [ ! -L \"$d\" ]; do \
case \"$d\" in */*) d=${{d%/*}}; [ -n \"$d\" ] || d=/ ;; *) d=. ;; esac; done; \
if [ -d \"$d\" ] && [ -w \"$d\" ]; then printf creatable; \
else printf blocked; fi; fi"
),
);
match exec.run(&cmd) {
Ok(output) => match output.stdout.trim() {
"present" => check(
true,
false,
format!("recordings directory `{dir}` exists and is writable"),
),
"creatable" => check(
true,
false,
format!(
"recordings directory `{dir}` does not exist yet and will be created by \
provisioning (its nearest existing parent is writable)"
),
),
"unwritable" => check(
false,
false,
format!("recordings directory `{dir}` exists but is not writable"),
),
"blocked" => check(
false,
false,
format!(
"recordings directory `{dir}` cannot be created: its nearest existing \
ancestor is not a writable directory"
),
),
other => check(
false,
false,
format!(
"recordings directory `{dir}` could not be verified (probe output: \
`{other}`)"
),
),
},
Err(err) => check(
false,
false,
format!("recordings directory `{dir}` could not be probed: {err}"),
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Protocol {
Tcp,
Udp,
}
#[must_use]
fn mediamtx_required_ports(cfg: &MediaMtxHostConfig) -> Vec<(u16, Protocol)> {
vec![
(cfg.api_port, Protocol::Tcp),
(cfg.rtmp_port, Protocol::Tcp),
(cfg.hls_port, Protocol::Tcp),
(cfg.webrtc_port, Protocol::Tcp),
(cfg.playback_port, Protocol::Tcp),
(cfg.webrtc_local_udp, Protocol::Udp),
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ListeningSocket {
port: u16,
protocol: Protocol,
owner: Option<String>,
}
#[must_use]
fn parse_listening_sockets(ss_output: &str) -> Vec<ListeningSocket> {
ss_output
.lines()
.filter_map(|line| {
let mut cols = line.split_whitespace();
let protocol = match cols.next()? {
"tcp" => Protocol::Tcp,
"udp" => Protocol::Udp,
_ => return None,
};
let local = cols.nth(3)?;
let port = local.rsplit(':').next()?.parse::<u16>().ok()?;
Some(ListeningSocket {
port,
protocol,
owner: process_name_from_ss_line(line),
})
})
.collect()
}
#[must_use]
fn process_name_from_ss_line(line: &str) -> Option<String> {
let after_quote = line.split_once("users:((\"")?.1;
let name = after_quote.split_once('"')?.0;
(!name.is_empty()).then(|| name.to_owned())
}
#[must_use]
fn managed_process_name(cfg: &MediaMtxHostConfig) -> &str {
cfg.binary_path
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or(cfg.binary_path.as_str())
}
#[must_use]
fn unit_is_active(exec: &impl DeployExecutor, unit_name: &str) -> bool {
let unit_service = super::exec::shell_quote(&format!("{unit_name}.service"));
let cmd = RemoteCommand::new(
"media-unit-active",
format!("systemctl is-active --quiet {unit_service}"),
);
exec.run(&cmd).is_ok()
}
#[must_use]
pub fn mediamtx_ports_available(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
) -> PreflightCheck {
let cmd = RemoteCommand::new("media-ports", "ss -H -tulnp".to_owned());
let output = match exec.run(&cmd) {
Ok(out) => out.stdout,
Err(err) => {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
scope: None,
passed: false,
deferred: false,
detail: format!("could not verify MediaMTX port availability (`ss` failed): {err}"),
hint: Some(PORTS_HINT),
};
}
};
let sockets = parse_listening_sockets(&output);
if sockets.is_empty() && !output.trim().is_empty() {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
scope: None,
passed: false,
deferred: false,
detail: "could not verify MediaMTX port availability: `ss` output did not parse into \
any listening ports"
.to_owned(),
hint: Some(PORTS_HINT),
};
}
let ours = managed_process_name(cfg);
let mut foreign: Vec<u16> = Vec::new();
let mut ours_ports: Vec<u16> = Vec::new();
for (port, protocol) in mediamtx_required_ports(cfg) {
let mut matching = sockets
.iter()
.filter(|s| s.port == port && s.protocol == protocol)
.peekable();
if matching.peek().is_none() {
continue; }
if matching.all(|s| s.owner.as_deref() == Some(ours)) {
ours_ports.push(port);
} else {
foreign.push(port);
}
}
let mut held_by_us: Vec<u16> = Vec::new();
let mut unmanaged: Vec<u16> = Vec::new();
if !ours_ports.is_empty() {
if unit_is_active(exec, &cfg.unit_name) {
held_by_us = ours_ports;
} else {
unmanaged = ours_ports;
}
}
let mut conflict_msgs: Vec<String> = Vec::new();
foreign.sort_unstable();
foreign.dedup();
if !foreign.is_empty() {
conflict_msgs.push(format!(
"MediaMTX port(s) already in use by another service on the host: {}",
join_ports(&foreign)
));
}
unmanaged.sort_unstable();
unmanaged.dedup();
if !unmanaged.is_empty() {
conflict_msgs.push(format!(
"MediaMTX port(s) {} are held by a `{ours}` process, but the configured \
`{}` unit is not active — an unmanaged/manual MediaMTX (or a crashed unit) still \
owns them, so starting our unit would launch a second instance over them",
join_ports(&unmanaged),
cfg.unit_name,
));
}
if !conflict_msgs.is_empty() {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
scope: None,
passed: false,
deferred: false,
detail: conflict_msgs.join("; "),
hint: Some(PORTS_HINT),
};
}
let detail = if held_by_us.is_empty() {
"all MediaMTX ports are free on the host".to_owned()
} else {
format!(
"MediaMTX ports are available; port(s) {} are already held by our own managed \
MediaMTX (same-port redeploy)",
join_ports(&held_by_us),
)
};
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
scope: None,
passed: true,
deferred: false,
detail,
hint: None,
}
}
#[must_use]
fn join_ports(ports: &[u16]) -> String {
ports
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn mediamtx_binary_preflight(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
) -> PreflightCheck {
let bin = &cfg.binary_path;
if !bin.starts_with('/') {
return PreflightCheck {
name: CHECK_MEDIAMTX_BINARY,
scope: None,
passed: false,
deferred: false,
detail: format!(
"`[media.mediamtx] binary_path` {bin:?} must be an absolute path — systemd \
cannot start a relative `ExecStart`"
),
hint: Some("Set `[media.mediamtx] binary_path` to an absolute path"),
};
}
let quoted = super::exec::shell_quote(bin);
let cmd = RemoteCommand::new(
"media-mediamtx-binary",
format!(
"if [ -f {quoted} ] && [ -x {quoted} ]; then echo present; \
elif [ -e {quoted} ] || [ -L {quoted} ]; then echo occupied; \
else echo absent; fi"
),
);
let fail = |detail: String, hint: &'static str| PreflightCheck {
name: CHECK_MEDIAMTX_BINARY,
scope: None,
passed: false,
deferred: false,
detail,
hint: Some(hint),
};
let pass = |detail: String| PreflightCheck {
name: CHECK_MEDIAMTX_BINARY,
scope: None,
passed: true,
deferred: false,
detail,
hint: None,
};
let output = match exec.run(&cmd) {
Ok(output) => output,
Err(err) => {
return fail(
format!("could not check the MediaMTX binary `{bin}`: {err}"),
MEDIAMTX_BINARY_HINT,
);
}
};
match output.stdout.trim() {
"present" => pass(format!("MediaMTX binary `{bin}` exists and is executable")),
"absent" if cfg.install_binary => pass(format!(
"MediaMTX binary `{bin}` is absent; `deploy up` installs MediaMTX \
{MEDIAMTX_KNOWN_GOOD_VERSION} there before cutover"
)),
"absent" => fail(
format!(
"MediaMTX binary `{bin}` is absent and `install_binary = false` — MediaMTX \
cannot start, so the post-cutover `systemctl restart` would fail"
),
MEDIAMTX_BINARY_HINT,
),
"occupied" => fail(
format!(
"`{bin}` exists but is not an executable file — deploy does not replace it, \
and the post-cutover `systemctl restart` would fail"
),
MEDIAMTX_BINARY_OCCUPIED_HINT,
),
other => fail(
format!(
"could not check the MediaMTX binary `{bin}`: unexpected probe output {other:?}"
),
MEDIAMTX_BINARY_HINT,
),
}
}
#[must_use]
pub fn mediamtx_ports_distinct(cfg: &MediaMtxHostConfig) -> PreflightCheck {
let tcp: [(u16, &str); 5] = [
(cfg.rtmp_port, "rtmp_port"),
(cfg.hls_port, "hls_port"),
(cfg.webrtc_port, "webrtc_port"),
(cfg.playback_port, "playback_port"),
(cfg.api_port, "api_port"),
];
let mut by_port: std::collections::BTreeMap<u16, Vec<&str>> = std::collections::BTreeMap::new();
for (port, field) in tcp {
by_port.entry(port).or_default().push(field);
}
let conflicts: Vec<String> = by_port
.iter()
.filter(|(_, fields)| fields.len() > 1)
.map(|(port, fields)| format!("TCP port {port} is bound by {}", fields.join(", ")))
.collect();
if conflicts.is_empty() {
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_DISTINCT,
scope: None,
passed: true,
deferred: false,
detail: "all MediaMTX TCP listener ports are distinct".to_owned(),
hint: None,
}
} else {
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_DISTINCT,
scope: None,
passed: false,
deferred: false,
detail: format!(
"MediaMTX listener ports must be unique per protocol, but {} — two servers \
cannot bind the same TCP port, so `systemctl restart mediamtx` would fail after \
cutover",
conflicts.join("; ")
),
hint: Some(PORTS_DISTINCT_HINT),
}
}
}
pub const CHECK_MEDIAMTX_PORTS_MATCH_BASES: &str = "media_mediamtx_ports_match_bases";
const PORTS_MATCH_BASES_HINT: &str = "Update the app-side `[media.mediamtx] *_base` URL to the port the daemon is provisioned on \
(or drop the explicit port from the base URL if a reverse proxy fronts it)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BasePort {
Implicit,
Explicit(u16),
Malformed,
}
fn explicit_port(url: &str) -> BasePort {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let Some(authority) = after_scheme.split(['/', '?', '#']).next() else {
return BasePort::Implicit;
};
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let port = match host_port.rsplit_once(']') {
Some((_, rest)) => match rest.strip_prefix(':') {
Some(port) => port,
None if rest.is_empty() => return BasePort::Implicit,
None => return BasePort::Malformed,
},
None => match host_port.split_once(':') {
Some((_, port)) => port,
None => return BasePort::Implicit,
},
};
port.parse().map_or(BasePort::Malformed, BasePort::Explicit)
}
fn addresses_listener_directly(url: &str) -> bool {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let host = match host_port.rsplit_once(']') {
Some((host, _)) => host.trim_start_matches('['),
None => host_port.split(':').next().unwrap_or_default(),
};
matches!(
host.trim().to_ascii_lowercase().as_str(),
"127.0.0.1" | "localhost" | "::1"
)
}
#[must_use]
pub fn mediamtx_ports_match_bases(cfg: &MediaMtxHostConfig) -> PreflightCheck {
let mut mismatches: Vec<String> = Vec::new();
let mut deferred: Vec<String> = Vec::new();
for (port, port_field, base_field, base, set_in_toml) in port_base_pairs(cfg) {
let Some(base) = base else { continue };
if base.contains("${") {
deferred.push(format!("`{base_field}` uses env/interpolation indirection"));
continue;
}
let base_port = match explicit_port(base) {
BasePort::Explicit(port) => port,
BasePort::Implicit => continue,
BasePort::Malformed => {
mismatches.push(format!("`{base_field}` names no valid port (`{base}`)"));
continue;
}
};
if base_port == port {
continue;
}
if !set_in_toml {
deferred.push(format!(
"`{port_field}` provisions port {port} while the default `{base_field}` \
(`{base}`) calls port {base_port}; set `{base_field}` here, or in the \
service environment"
));
continue;
}
if !addresses_listener_directly(base) {
continue;
}
mismatches.push(format!(
"`{port_field}` provisions port {port} but `{base_field}` calls port \
{base_port} (`{base}`)"
));
}
if !mismatches.is_empty() {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_MATCH_BASES,
scope: None,
passed: false,
deferred: false,
detail: format!(
"MediaMTX listener ports and the app's base URLs disagree, so the deployed app \
would call an origin the daemon does not bind: {}",
mismatches.join("; ")
),
hint: Some(PORTS_MATCH_BASES_HINT),
};
}
if !deferred.is_empty() {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_MATCH_BASES,
scope: None,
passed: false,
deferred: true,
detail: format!(
"every comparable MediaMTX base URL matches its listener port, but some are \
resolved in the service environment, so verification is deferred to runtime: {}",
deferred.join("; ")
),
hint: None,
};
}
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_MATCH_BASES,
scope: None,
passed: true,
deferred: false,
detail: "every MediaMTX base URL matches its provisioned listener port".to_owned(),
hint: None,
}
}
fn port_base_pairs(
cfg: &MediaMtxHostConfig,
) -> [(u16, &'static str, &'static str, Option<&str>, bool); 7] {
const fn public<'a>(configured: Option<&'a str>, default: &'a str) -> (Option<&'a str>, bool) {
match configured {
Some(base) => (Some(base), true),
None => (Some(default), false),
}
}
let (api, api_set) = public(cfg.api_base.as_deref(), DEFAULT_API_BASE);
let (rtmp, rtmp_set) = public(cfg.rtmp_base.as_deref(), DEFAULT_RTMP_BASE);
let (hls, hls_set) = public(cfg.hls_base.as_deref(), DEFAULT_HLS_BASE);
let (webrtc, webrtc_set) = public(cfg.webrtc_base.as_deref(), DEFAULT_WEBRTC_BASE);
let (playback, playback_set) = public(cfg.playback_base.as_deref(), DEFAULT_PLAYBACK_BASE);
[
(cfg.api_port, "api_port", "api_base", api, api_set),
(cfg.rtmp_port, "rtmp_port", "rtmp_base", rtmp, rtmp_set),
(cfg.hls_port, "hls_port", "hls_base", hls, hls_set),
(
cfg.webrtc_port,
"webrtc_port",
"webrtc_base",
webrtc,
webrtc_set,
),
(
cfg.playback_port,
"playback_port",
"playback_base",
playback,
playback_set,
),
(
cfg.hls_port,
"hls_port",
"hls_probe_base",
cfg.hls_probe_base.as_deref(),
true,
),
(
cfg.playback_port,
"playback_port",
"playback_probe_base",
cfg.playback_probe_base.as_deref(),
true,
),
]
}
#[must_use]
pub fn collect_media_doctor_checks(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
ffmpeg_bin: &str,
) -> Vec<PreflightCheck> {
vec![
mediamtx_ports_distinct(cfg),
mediamtx_ports_match_bases(cfg),
ffmpeg_preflight(exec, ffmpeg_bin),
mediamtx_binary_preflight(exec, cfg),
recordings_dir_writable(exec, cfg),
mediamtx_ports_available(exec, cfg),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::path::Path;
use super::super::exec::{CommandOutput, DeployExecError};
#[derive(Default)]
struct RecordingExecutor {
run_labels: RefCell<Vec<&'static str>>,
run_shells: RefCell<Vec<String>>,
fail_labels: Vec<&'static str>,
stdout_by_label: Vec<(&'static str, String)>,
}
impl RecordingExecutor {
fn new() -> Self {
Self::default()
}
fn failing_on(label: &'static str) -> Self {
Self {
fail_labels: vec![label],
..Self::default()
}
}
fn with_stdout(mut self, label: &'static str, stdout: impl Into<String>) -> Self {
self.stdout_by_label.push((label, stdout.into()));
self
}
fn failing(mut self, label: &'static str) -> Self {
self.fail_labels.push(label);
self
}
fn labels(&self) -> Vec<&'static str> {
self.run_labels.borrow().clone()
}
fn shell_for(&self, label: &str) -> String {
let index = self
.run_labels
.borrow()
.iter()
.position(|recorded| *recorded == label)
.unwrap_or_else(|| panic!("no command ran with label `{label}`"));
self.run_shells.borrow()[index].clone()
}
}
impl DeployExecutor for RecordingExecutor {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError> {
self.run_labels.borrow_mut().push(cmd.label);
self.run_shells.borrow_mut().push(cmd.shell.clone());
if self.fail_labels.contains(&cmd.label) {
return Err(DeployExecError::CommandFailed {
label: cmd.label,
message: "scripted failure".to_owned(),
});
}
let stdout = self
.stdout_by_label
.iter()
.find(|(l, _)| *l == cmd.label)
.map(|(_, out)| out.clone())
.unwrap_or_default();
Ok(CommandOutput {
stdout,
stderr: String::new(),
})
}
fn upload(
&self,
_local: &Path,
_remote_path: &str,
_mode: Option<u32>,
) -> Result<(), DeployExecError> {
Ok(())
}
}
#[test]
fn config_defaults_are_the_localhost_disabled_shape() {
let cfg = MediaMtxHostConfig::default();
assert!(!cfg.enabled, "controller is disabled by default");
assert_eq!(cfg.api_port, MEDIAMTX_API_PORT);
assert_eq!(cfg.rtmp_port, MEDIAMTX_RTMP_PORT);
assert_eq!(cfg.hls_port, MEDIAMTX_HLS_PORT);
assert_eq!(cfg.webrtc_port, MEDIAMTX_WEBRTC_PORT);
assert_eq!(cfg.playback_port, MEDIAMTX_PLAYBACK_PORT);
assert_eq!(cfg.webrtc_local_udp, MEDIAMTX_WEBRTC_LOCAL_UDP_PORT);
assert_eq!(cfg.recordings_dir, "/recordings");
assert_eq!(cfg.record_delete_after, "72h");
assert_eq!(cfg.config_path, "/etc/mediamtx/mediamtx.yml");
assert_eq!(cfg.binary_path, "/usr/local/bin/mediamtx");
assert_eq!(cfg.unit_name, "mediamtx");
assert!(cfg.webrtc_additional_hosts.is_empty());
}
fn host_cfg_from_toml_str(s: &str) -> Result<(MediaMtxHostConfig, String), toml::de::Error> {
media_host_config_from_value(toml::from_str(s)?)
}
#[test]
fn empty_toml_yields_disabled_defaults() {
let (cfg, bin) = host_cfg_from_toml_str("").expect("empty toml parses");
assert!(!cfg.enabled);
assert_eq!(cfg.api_port, MEDIAMTX_API_PORT);
assert_eq!(bin, DEFAULT_FFMPEG_BIN);
}
#[test]
fn toml_reads_the_media_mediamtx_section() {
let toml = "\
[media.mediamtx]
enabled = true
recordings_dir = \"/srv/recordings\"
record_delete_after = \"48h\"
webrtc_additional_hosts = [\"stream.example.com\", \"203.0.113.7\"]
unit_name = \"mediamtx-prod\"
";
let (cfg, _) = host_cfg_from_toml_str(toml).expect("parses");
assert!(cfg.enabled);
assert_eq!(cfg.recordings_dir, "/srv/recordings");
assert_eq!(cfg.record_delete_after, "48h");
assert_eq!(
cfg.webrtc_additional_hosts,
vec!["stream.example.com".to_owned(), "203.0.113.7".to_owned()]
);
assert_eq!(cfg.unit_name, "mediamtx-prod");
assert_eq!(cfg.hls_port, MEDIAMTX_HLS_PORT);
}
#[test]
fn toml_without_media_table_is_default() {
let (cfg, _) = host_cfg_from_toml_str("[deploy]\nhost = \"h\"\n").expect("parses");
assert!(!cfg.enabled);
}
#[test]
fn ffmpeg_bin_defaults_and_reads_from_media_ffmpeg() {
assert_eq!(
host_cfg_from_toml_str("").expect("empty parses").1,
DEFAULT_FFMPEG_BIN
);
let (_, bin) =
host_cfg_from_toml_str("[media.ffmpeg]\nbin = \"/opt/ffmpeg\"\n").expect("parses");
assert_eq!(bin, "/opt/ffmpeg");
}
#[test]
fn media_config_fails_closed_on_malformed_mediamtx_section() {
assert!(host_cfg_from_toml_str("[media.mediamtx]\nenabled = \"yes\"\n").is_err());
assert!(host_cfg_from_toml_str("[media.mediamtx]\napi_port = \"nope\"\n").is_err());
}
#[test]
fn media_config_fails_closed_on_malformed_ffmpeg_section() {
let toml = "[media.ffmpeg]\nbin = 123\n";
assert!(host_cfg_from_toml_str(toml).is_err());
}
#[test]
fn rendered_yml_contains_every_port() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(
yml.contains("apiAddress: 127.0.0.1:9997"),
"api port: {yml}"
);
assert!(yml.contains("playbackAddress: :9996"), "playback: {yml}");
assert!(yml.contains("rtmpAddress: :1935"), "rtmp: {yml}");
assert!(yml.contains("hlsAddress: :8888"), "hls: {yml}");
assert!(yml.contains("webrtcAddress: :8889"), "webrtc: {yml}");
assert!(yml.contains("webrtcLocalUDPAddress: :8189"), "udp: {yml}");
}
#[test]
fn rendered_yml_binds_api_to_loopback_but_leaves_public_listeners_open() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(
yml.contains("apiAddress: 127.0.0.1:9997"),
"control API must bind loopback only: {yml}"
);
assert!(
!yml.contains("apiAddress: :9997"),
"control API must NOT bind all interfaces: {yml}"
);
for public in [
"rtmpAddress: :1935",
"hlsAddress: :8888",
"webrtcAddress: :8889",
"playbackAddress: :9996",
] {
assert!(yml.contains(public), "public listener {public}: {yml}");
}
assert!(
!yml.contains("rtmpAddress: 127.0.0.1")
&& !yml.contains("hlsAddress: 127.0.0.1")
&& !yml.contains("webrtcAddress: 127.0.0.1")
&& !yml.contains("playbackAddress: 127.0.0.1"),
"no public listener may be loopback-bound: {yml}"
);
}
#[test]
fn rendered_yml_has_ll_hls_recording_and_webrtc_config() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(yml.contains("hlsVariant: lowLatency"));
assert!(yml.contains("hlsPartDuration: 200ms"));
assert!(yml.contains("hlsSegmentDuration: 2s"));
assert!(yml.contains("hlsSegmentCount: 60"));
assert!(yml.contains("record: true"));
assert!(yml.contains("recordPath: '/recordings/%path/%Y-%m-%d_%H-%M-%S-%f'"));
assert!(yml.contains("recordFormat: fmp4"));
assert!(yml.contains("recordDeleteAfter: '72h'"));
assert!(yml.contains("webrtc: true"));
assert!(yml.contains("webrtcAdditionalHosts: []"));
}
#[test]
fn rendered_yml_uses_current_mediamtx_cors_schema() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(
yml.contains("hlsAllowOrigins: ['*']"),
"HLS CORS must use the current plural array key: {yml}"
);
assert!(
!yml.contains("hlsAllowOrigin:"),
"the deprecated singular hlsAllowOrigin key must not be rendered: {yml}"
);
assert!(
yml.contains("webrtcAllowOrigins: ['*']"),
"WebRTC CORS must use the plural array key: {yml}"
);
}
#[test]
fn rendered_yml_disables_unmanaged_protocols_but_keeps_managed_ones() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(yml.contains("rtsp: no"), "RTSP must be disabled: {yml}");
assert!(yml.contains("srt: no"), "SRT must be disabled: {yml}");
assert!(yml.contains("moq: no"), "MoQ must be disabled: {yml}");
assert!(yml.contains("metrics: no"), "metrics must be off: {yml}");
assert!(yml.contains("pprof: no"), "pprof must be off: {yml}");
assert!(yml.contains("rtmp: true"), "rtmp managed: {yml}");
assert!(yml.contains("hls: true"), "hls managed: {yml}");
assert!(yml.contains("webrtc: true"), "webrtc managed: {yml}");
assert!(yml.contains("api: true"), "api managed: {yml}");
assert!(yml.contains("playback: true"), "playback managed: {yml}");
}
#[test]
fn rendered_yml_has_both_live_and_room_path_matchers() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(yml.contains("~^live/.+$:"), "broadcast matcher: {yml}");
assert!(yml.contains("~^room/.+$:"), "room matcher: {yml}");
}
#[test]
fn rendered_yml_honors_custom_ports_dirs_and_hosts() {
let cfg = MediaMtxHostConfig {
api_port: 19997,
hls_port: 18888,
recordings_dir: "/data/rec".to_owned(),
record_delete_after: "24h".to_owned(),
webrtc_additional_hosts: vec!["a.example.com".to_owned(), "b.example.com".to_owned()],
..MediaMtxHostConfig::default()
};
let yml = render_mediamtx_yml(&cfg);
assert!(yml.contains("apiAddress: 127.0.0.1:19997"));
assert!(yml.contains("hlsAddress: :18888"));
assert!(yml.contains("recordPath: '/data/rec/%path/%Y-%m-%d_%H-%M-%S-%f'"));
assert!(yml.contains("recordDeleteAfter: '24h'"));
assert!(yml.contains("webrtcAdditionalHosts: ['a.example.com', 'b.example.com']"));
}
#[test]
fn rendered_unit_uses_binary_and_config_paths_and_restarts_always() {
let unit = render_mediamtx_unit(&MediaMtxHostConfig::default());
assert!(
unit.contains("ExecStart=\"/usr/local/bin/mediamtx\" \"/etc/mediamtx/mediamtx.yml\"\n"),
"ExecStart: {unit}"
);
assert!(unit.contains("Restart=always\n"), "restart: {unit}");
assert!(unit.contains("After=network-online.target\n"));
assert!(unit.contains("WantedBy=multi-user.target\n"));
}
#[test]
fn rendered_unit_quotes_whitespace_paths_in_execstart() {
let cfg = MediaMtxHostConfig {
binary_path: "/opt/media mtx/mediamtx".to_owned(),
config_path: "/etc/media mtx/mediamtx.yml".to_owned(),
..MediaMtxHostConfig::default()
};
let unit = render_mediamtx_unit(&cfg);
assert!(
unit.contains(
"ExecStart=\"/opt/media mtx/mediamtx\" \"/etc/media mtx/mediamtx.yml\"\n"
),
"ExecStart must double-quote whitespace paths: {unit}"
);
assert!(
!unit.contains("ExecStart=/opt/media mtx/mediamtx"),
"ExecStart must not interpolate the path unquoted: {unit}"
);
}
#[test]
fn systemd_quote_escapes_backslash_and_double_quote() {
assert_eq!(systemd_quote("/usr/bin/mediamtx"), "\"/usr/bin/mediamtx\"");
assert_eq!(systemd_quote("/opt/a b/mtx"), "\"/opt/a b/mtx\"");
assert_eq!(systemd_quote(r#"/a"b"#), "\"/a\\\"b\"");
assert_eq!(systemd_quote(r"/a\b"), "\"/a\\\\b\"");
}
#[test]
fn rendered_yml_quotes_whitespace_and_special_config_values() {
let cfg = MediaMtxHostConfig {
recordings_dir: "/mnt/rec ords".to_owned(),
record_delete_after: "72h".to_owned(),
..MediaMtxHostConfig::default()
};
let yml = render_mediamtx_yml(&cfg);
assert!(
yml.contains("recordPath: '/mnt/rec ords/%path/%Y-%m-%d_%H-%M-%S-%f'"),
"recordPath must be single-quoted: {yml}"
);
let hashy = MediaMtxHostConfig {
recordings_dir: "/mnt/rec#1".to_owned(),
..MediaMtxHostConfig::default()
};
let yml = render_mediamtx_yml(&hashy);
assert!(
yml.contains("recordPath: '/mnt/rec#1/%path/%Y-%m-%d_%H-%M-%S-%f'"),
"a `#` path must be quoted, not truncated by a YAML comment: {yml}"
);
}
#[test]
fn install_op_quotes_whitespace_unit_name() {
let cfg = MediaMtxHostConfig {
enabled: true,
unit_name: "media mtx".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let DeployOp::Run(cmd) = &ops[3] else {
panic!("op 3 must be the install Run op");
};
assert!(
cmd.shell.contains("systemctl enable 'media mtx.service'"),
"unit name must be shell-quoted: {}",
cmd.shell
);
assert!(
cmd.shell
.contains("systemctl is-active --quiet 'media mtx.service'"),
"{}",
cmd.shell
);
assert!(
cmd.shell.contains("systemctl restart 'media mtx.service'"),
"{}",
cmd.shell
);
}
#[test]
fn controller_noops_when_disabled() {
let controller = MediaMtxController::new(MediaMtxHostConfig::default());
assert!(
controller.ensure_installed_ops().is_empty(),
"a disabled controller emits no ops"
);
}
#[test]
fn controller_emits_write_write_install_when_enabled() {
let cfg = MediaMtxHostConfig {
enabled: true,
..MediaMtxHostConfig::default()
};
let controller = MediaMtxController::new(cfg);
let ops = controller.ensure_installed_ops();
assert_eq!(ops.len(), 4);
let DeployOp::Run(mkdir) = &ops[0] else {
panic!("op 0 must be the mkdir Run op, got {:?}", ops[0]);
};
assert_eq!(mkdir.label, "media-prepare-dirs");
assert_eq!(
mkdir.shell,
"mkdir -p '/etc/mediamtx'; mkdir -p -m 0750 '/recordings'"
);
match &ops[1] {
DeployOp::WriteFile {
label,
contents,
remote_path,
mode,
} => {
assert_eq!(*label, "media-write-config");
assert_eq!(remote_path, "/etc/mediamtx/mediamtx.yml.autumn-new");
assert_eq!(*mode, Some(0o644));
let FileContents::Plain(yml) = contents else {
panic!("config must be plain text");
};
assert!(yml.contains("~^room/.+$:"));
}
other => panic!("op 1 must write the config, got {other:?}"),
}
match &ops[2] {
DeployOp::WriteFile {
label,
contents,
remote_path,
mode,
} => {
assert_eq!(*label, "media-write-unit");
assert_eq!(
remote_path,
"/etc/systemd/system/mediamtx.service.autumn-new"
);
assert_eq!(*mode, Some(0o644));
let FileContents::Plain(unit) = contents else {
panic!("unit must be plain text");
};
assert!(unit.contains("ExecStart=\"/usr/local/bin/mediamtx\""));
}
other => panic!("op 2 must write the unit, got {other:?}"),
}
assert!(matches!(&ops[3], DeployOp::Run(c) if c.label == "media-install"));
}
#[test]
fn controller_install_op_is_conditional_restart() {
let cfg = MediaMtxHostConfig {
enabled: true,
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let DeployOp::Run(cmd) = &ops[3] else {
panic!("op 3 must be the install Run op");
};
assert_eq!(cmd.label, "media-install");
let shell = &cmd.shell;
assert!(
shell.contains(
"cmp -s '/etc/mediamtx/mediamtx.yml.autumn-new' '/etc/mediamtx/mediamtx.yml'"
),
"must cmp the staged config against the live config: {shell}"
);
assert!(
shell.contains(
"cmp -s '/etc/systemd/system/mediamtx.service.autumn-new' \
'/etc/systemd/system/mediamtx.service'"
),
"must cmp the staged unit against the live unit: {shell}"
);
assert!(
shell.contains("changed=1"),
"must track a changed flag: {shell}"
);
assert!(
shell.contains(
"cp '/etc/mediamtx/mediamtx.yml.autumn-new' '/etc/mediamtx/mediamtx.yml'"
),
"must copy the staged config into place on change: {shell}"
);
assert!(
shell.contains(
"rm -f '/etc/mediamtx/mediamtx.yml.autumn-new' \
'/etc/systemd/system/mediamtx.service.autumn-new'"
),
"must clean up the staged temp files: {shell}"
);
assert!(shell.contains("systemctl daemon-reload"), "{shell}");
assert!(
shell.contains("systemctl enable 'mediamtx.service'"),
"{shell}"
);
assert!(
!shell.contains("enable --now"),
"must not `enable --now` (that would start unconditionally): {shell}"
);
assert!(
shell.contains(
"if [ \"$changed\" -ne 0 ] || ! systemctl is-active --quiet 'mediamtx.service'; then"
),
"restart must be gated on change or an inactive unit: {shell}"
);
assert!(
shell.contains("systemctl restart 'mediamtx.service'"),
"{shell}"
);
}
#[test]
fn controller_honors_custom_unit_name_in_paths_and_command() {
let cfg = MediaMtxHostConfig {
enabled: true,
unit_name: "mediamtx-prod".to_owned(),
..MediaMtxHostConfig::default()
};
let controller = MediaMtxController::new(cfg);
assert_eq!(
controller.unit_path(),
"/etc/systemd/system/mediamtx-prod.service"
);
let ops = controller.ensure_installed_ops();
let DeployOp::Run(cmd) = &ops[3] else {
panic!("op 3 must be the install Run op");
};
assert!(
cmd.shell
.contains("systemctl enable 'mediamtx-prod.service'")
);
assert!(
cmd.shell
.contains("is-active --quiet 'mediamtx-prod.service'")
);
assert!(cmd.shell.contains("restart 'mediamtx-prod.service'"));
assert!(
cmd.shell
.contains("/etc/systemd/system/mediamtx-prod.service.autumn-new")
);
}
#[test]
fn controller_mkdirs_config_parent_before_writing_config() {
let cfg = MediaMtxHostConfig {
enabled: true,
config_path: "/opt/media/etc/mediamtx.yml".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let mkdir_idx = ops
.iter()
.position(|op| op.label() == "media-prepare-dirs")
.expect("a media-prepare-dirs op must be emitted");
let write_idx = ops
.iter()
.position(|op| op.label() == "media-write-config")
.expect("a media-write-config op must be emitted");
assert!(
mkdir_idx < write_idx,
"mkdir must precede the config write (mkdir at {mkdir_idx}, write at {write_idx})"
);
let DeployOp::Run(mkdir) = &ops[mkdir_idx] else {
panic!("media-prepare-dirs must be a Run op");
};
assert_eq!(
mkdir.shell,
"mkdir -p '/opt/media/etc'; mkdir -p -m 0750 '/recordings'"
);
}
#[test]
fn controller_creates_the_recordings_dir_it_preflights() {
let cfg = MediaMtxHostConfig {
enabled: true,
recordings_dir: "/srv/media recordings".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let mkdir_idx = ops
.iter()
.position(|op| op.label() == "media-prepare-dirs")
.expect("a media-prepare-dirs op must be emitted");
let DeployOp::Run(mkdir) = &ops[mkdir_idx] else {
panic!("media-prepare-dirs must be a Run op");
};
assert_eq!(
mkdir.shell,
"mkdir -p '/etc/mediamtx'; mkdir -p -m 0750 '/srv/media recordings'"
);
let write_idx = ops
.iter()
.position(|op| op.label() == "media-write-config")
.expect("a media-write-config op must be emitted");
assert!(mkdir_idx < write_idx);
}
#[test]
fn controller_mkdirs_the_recordings_dir_even_with_a_parentless_config_path() {
let cfg = MediaMtxHostConfig {
enabled: true,
config_path: "mediamtx.yml".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let DeployOp::Run(mkdir) = &ops[0] else {
panic!("op 0 must be the mkdir Run op, got {:?}", ops[0]);
};
assert_eq!(mkdir.shell, "mkdir -p -m 0750 '/recordings'");
}
#[test]
fn controller_emits_one_mkdir_when_recordings_dir_is_the_config_parent() {
let cfg = MediaMtxHostConfig {
enabled: true,
config_path: "/recordings/mediamtx.yml".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let DeployOp::Run(mkdir) = &ops[0] else {
panic!("op 0 must be the mkdir Run op, got {:?}", ops[0]);
};
assert_eq!(mkdir.shell, "mkdir -p -m 0750 '/recordings'");
}
#[test]
fn parent_dir_extracts_parent_or_none() {
assert_eq!(
parent_dir("/etc/mediamtx/mediamtx.yml"),
Some("/etc/mediamtx")
);
assert_eq!(parent_dir("/opt/a/b/c.yml"), Some("/opt/a/b"));
assert_eq!(parent_dir("/mediamtx.yml"), None);
assert_eq!(parent_dir("mediamtx.yml"), None);
}
#[test]
fn ffmpeg_preflight_passes_on_version_banner() {
let exec = RecordingExecutor::new().with_stdout(
"media-ffmpeg-preflight",
"ffmpeg version 6.1.1 Copyright (c)",
);
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_FFMPEG_PREFLIGHT);
assert_eq!(exec.labels(), vec!["media-ffmpeg-preflight"]);
}
#[test]
fn ffmpeg_preflight_fails_without_banner() {
let exec =
RecordingExecutor::new().with_stdout("media-ffmpeg-preflight", "not-ffmpeg output");
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(!check.passed);
assert!(!check.deferred);
assert!(check.blocking());
assert!(check.hint.is_some());
}
#[test]
fn ffmpeg_preflight_fails_when_binary_missing() {
let exec = RecordingExecutor::failing_on("media-ffmpeg-preflight");
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(!check.passed);
assert!(!check.deferred);
assert!(check.blocking());
assert!(check.detail.contains("could not run"));
}
#[test]
fn ffmpeg_preflight_defers_env_indirected_paths_without_probing() {
for configured in [
"${FF_BIN_UNSET_XYZ}",
"/opt/${VER}/ffmpeg",
"${AUTUMN_MEDIA__FFMPEG__BIN}",
"",
] {
let exec = RecordingExecutor::new();
let check = ffmpeg_preflight(&exec, configured);
assert!(!check.passed, "must be non-passing for `{configured}`");
assert!(check.deferred, "must be deferred for `{configured}`");
assert!(
!check.blocking(),
"deferred check must be non-blocking for `{configured}`"
);
assert!(
check.detail.contains("indirection") && check.detail.contains("deferring"),
"detail must name the deferral for `{configured}`: {}",
check.detail
);
assert!(check.hint.is_some());
assert!(
exec.labels().is_empty(),
"no probe must run for `{configured}`, ran {:?}",
exec.labels()
);
}
}
#[test]
fn ffmpeg_preflight_probes_a_concrete_literal_path() {
let exec = RecordingExecutor::new()
.with_stdout("media-ffmpeg-preflight", "ffmpeg version 6.1.1 Copyright");
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(exec.labels(), vec!["media-ffmpeg-preflight"]);
}
fn binary_probe(stdout: &str) -> RecordingExecutor {
RecordingExecutor::new().with_stdout("media-mediamtx-binary", stdout)
}
#[test]
fn mediamtx_binary_preflight_passes_when_present() {
let exec = binary_probe("present\n");
let check = mediamtx_binary_preflight(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_MEDIAMTX_BINARY);
assert_eq!(exec.labels(), vec!["media-mediamtx-binary"]);
assert!(check.hint.is_none());
}
#[test]
fn mediamtx_binary_preflight_passes_when_absent_and_deploy_installs_it() {
let check =
mediamtx_binary_preflight(&binary_probe("absent\n"), &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert!(!check.deferred);
assert!(
check.detail.contains(MEDIAMTX_KNOWN_GOOD_VERSION) && check.detail.contains("install"),
"detail must say deploy installs the pinned release: {}",
check.detail
);
}
#[test]
fn mediamtx_binary_preflight_fails_when_absent_and_install_is_off() {
let cfg = MediaMtxHostConfig {
install_binary: false,
..MediaMtxHostConfig::default()
};
let check = mediamtx_binary_preflight(&binary_probe("absent\n"), &cfg);
assert!(!check.passed);
assert!(check.blocking());
assert!(check.detail.contains(&cfg.binary_path), "{}", check.detail);
assert!(
check.detail.contains("systemctl restart"),
"detail must state the post-cutover restart would fail: {}",
check.detail
);
assert!(
check
.hint
.is_some_and(|hint| hint.contains("install_binary"))
);
}
#[test]
fn mediamtx_binary_preflight_fails_when_another_file_is_there() {
let check =
mediamtx_binary_preflight(&binary_probe("occupied\n"), &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.blocking());
assert!(
check.detail.contains("not an executable file"),
"{}",
check.detail
);
}
#[test]
fn mediamtx_binary_preflight_fails_on_a_relative_or_empty_path() {
for path in ["", "mediamtx", "bin/mediamtx"] {
let exec = binary_probe("absent\n");
let cfg = MediaMtxHostConfig {
binary_path: path.to_owned(),
..MediaMtxHostConfig::default()
};
let check = mediamtx_binary_preflight(&exec, &cfg);
assert!(check.blocking(), "{path:?}: {}", check.detail);
assert!(check.detail.contains("absolute"), "{}", check.detail);
assert!(exec.labels().is_empty(), "{path:?} must not be probed");
}
}
#[test]
fn mediamtx_binary_preflight_hint_for_an_occupied_path_says_remove() {
let check =
mediamtx_binary_preflight(&binary_probe("occupied\n"), &MediaMtxHostConfig::default());
assert!(
check.hint.is_some_and(|hint| hint.contains("Remove")),
"{:?}",
check.hint
);
}
#[test]
fn mediamtx_binary_preflight_fails_closed_when_it_cannot_tell() {
for exec in [
RecordingExecutor::failing_on("media-mediamtx-binary"),
binary_probe("something else\n"),
binary_probe(""),
] {
let check = mediamtx_binary_preflight(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.blocking());
}
}
#[test]
fn mediamtx_binary_preflight_probe_is_read_only_and_quotes_the_path() {
let exec = binary_probe("present\n");
let cfg = MediaMtxHostConfig {
binary_path: "/opt/media mtx/mediamtx".to_owned(),
..MediaMtxHostConfig::default()
};
let _ = mediamtx_binary_preflight(&exec, &cfg);
let cmd = exec.shell_for("media-mediamtx-binary");
assert!(
cmd.contains("'/opt/media mtx/mediamtx'"),
"path must be quoted: {cmd}"
);
for mutating in ["mkdir", "mv ", "docker", "chmod", "rm "] {
assert!(
!cmd.contains(mutating),
"probe must not run `{mutating}`: {cmd}"
);
}
}
#[test]
fn install_binary_defaults_on_and_parses_off() {
assert!(MediaMtxHostConfig::default().install_binary);
let root: toml::Value =
toml::from_str("[media.mediamtx]\nenabled = true\ninstall_binary = false\n").unwrap();
let (cfg, _) = media_host_config_from_value(root).unwrap();
assert!(!cfg.install_binary);
}
#[test]
fn binary_install_ops_are_empty_when_disabled_or_opted_out() {
let disabled = MediaMtxController::new(MediaMtxHostConfig::default());
assert!(disabled.binary_install_ops().is_empty());
let opted_out = MediaMtxController::new(MediaMtxHostConfig {
enabled: true,
install_binary: false,
..MediaMtxHostConfig::default()
});
assert!(opted_out.binary_install_ops().is_empty());
}
fn install_shell_for(binary_path: &str) -> String {
let ops = MediaMtxController::new(MediaMtxHostConfig {
enabled: true,
binary_path: binary_path.to_owned(),
..MediaMtxHostConfig::default()
})
.binary_install_ops();
let [DeployOp::Run(cmd)] = ops.as_slice() else {
panic!("expected one Run op, got {ops:?}");
};
assert_eq!(cmd.label, "media-install-binary");
cmd.shell.clone()
}
#[test]
fn binary_install_op_pulls_the_pinned_digest() {
let shell = install_shell_for("/opt/media mtx/mediamtx");
let image = format!(
"bluenviron/mediamtx:{MEDIAMTX_KNOWN_GOOD_VERSION}@{MEDIAMTX_KNOWN_GOOD_DIGEST}"
);
assert!(shell.contains(&image), "must pull tag@digest: {shell}");
assert!(shell.contains("\"$cid:/mediamtx\""), "{shell}");
assert!(
shell.contains("'/opt/media mtx/mediamtx'"),
"path must be quoted: {shell}"
);
assert!(
shell.contains("--version"),
"must check the version: {shell}"
);
}
#[test]
fn pinned_digest_is_a_sha256() {
let hex = MEDIAMTX_KNOWN_GOOD_DIGEST
.strip_prefix("sha256:")
.expect("sha256: prefix");
assert_eq!(hex.len(), 64);
assert!(
hex.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
);
}
#[cfg(unix)]
struct InstallRun {
dir: tempfile::TempDir,
bin: std::path::PathBuf,
}
#[cfg(unix)]
impl InstallRun {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::TempDir::new().expect("tempdir");
let fake = dir.path().join("fakebin");
std::fs::create_dir(&fake).unwrap();
let docker = "#!/bin/sh\n\
echo \"$*\" >> \"$DOCKER_LOG\"\n\
case \"$1\" in\n\
create) echo fakecid ;;\n\
cp) printf '#!/bin/sh\\necho %s\\n' \"$FAKE_VERSION\" > \"$3\" ;;\n\
esac\n";
for (name, body) in [("docker", docker), ("systemctl", "#!/bin/sh\nexit 0\n")] {
let path = fake.join(name);
std::fs::write(&path, body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let bin = dir.path().join("usr local/bin/mediamtx");
Self { dir, bin }
}
fn run(&self, fake_version: &str) -> std::process::Output {
let path = format!(
"{}:{}",
self.dir.path().join("fakebin").display(),
std::env::var("PATH").unwrap_or_default()
);
std::process::Command::new("sh")
.arg("-c")
.arg(install_shell_for(self.bin.to_str().unwrap()))
.env("PATH", path)
.env("DOCKER_LOG", self.dir.path().join("docker.log"))
.env("FAKE_VERSION", fake_version)
.output()
.expect("run sh")
}
fn docker_log(&self) -> String {
std::fs::read_to_string(self.dir.path().join("docker.log")).unwrap_or_default()
}
fn staged(&self) -> std::path::PathBuf {
let mut staged = self.bin.clone().into_os_string();
staged.push(".autumn-new");
staged.into()
}
}
#[cfg(unix)]
#[test]
fn install_shell_installs_the_pinned_binary_when_absent() {
let run = InstallRun::new();
let out = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let version = std::process::Command::new(&run.bin)
.arg("--version")
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&version.stdout).trim(),
format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}")
);
assert!(run.docker_log().contains(&format!(
"create bluenviron/mediamtx:{MEDIAMTX_KNOWN_GOOD_VERSION}@{MEDIAMTX_KNOWN_GOOD_DIGEST}"
)));
assert!(
run.docker_log().contains("rm -f fakecid"),
"scratch container removed"
);
assert!(!run.staged().exists(), "no staged file left");
}
#[cfg(unix)]
#[test]
fn install_shell_keeps_an_executable_that_is_already_there() {
use std::os::unix::fs::PermissionsExt as _;
let run = InstallRun::new();
std::fs::create_dir_all(run.bin.parent().unwrap()).unwrap();
std::fs::write(&run.bin, "#!/bin/sh\necho operator-build\n").unwrap();
std::fs::set_permissions(&run.bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let out = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read_to_string(&run.bin).unwrap(),
"#!/bin/sh\necho operator-build\n"
);
assert!(run.docker_log().is_empty(), "docker must not run");
}
#[cfg(unix)]
#[test]
fn install_shell_refuses_to_replace_any_other_file() {
let run = InstallRun::new();
std::fs::create_dir_all(run.bin.parent().unwrap()).unwrap();
std::fs::write(&run.bin, "not a binary").unwrap();
let out = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
assert!(!out.status.success());
assert_eq!(std::fs::read_to_string(&run.bin).unwrap(), "not a binary");
std::fs::remove_file(&run.bin).unwrap();
std::os::unix::fs::symlink(run.dir.path().join("gone"), &run.bin).unwrap();
let out = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
assert!(!out.status.success());
assert!(
std::fs::symlink_metadata(&run.bin)
.unwrap()
.file_type()
.is_symlink()
);
assert!(run.docker_log().is_empty(), "docker must not run");
}
#[test]
fn install_shell_waits_for_the_apt_lock() {
let shell = install_shell_for("/usr/local/bin/mediamtx");
assert_eq!(
shell.matches("-o DPkg::Lock::Timeout=300").count(),
2,
"{shell}"
);
}
#[cfg(unix)]
#[test]
fn install_shell_removes_a_leftover_staged_path_without_following_it() {
let run = InstallRun::new();
std::fs::create_dir_all(run.bin.parent().unwrap()).unwrap();
let victim = run.dir.path().join("victim");
std::fs::write(&victim, "keep me").unwrap();
std::os::unix::fs::symlink(&victim, run.staged()).unwrap();
let out = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep me");
assert!(
!std::fs::symlink_metadata(&run.bin)
.unwrap()
.file_type()
.is_symlink()
);
let run = InstallRun::new();
std::fs::create_dir_all(run.staged()).unwrap();
let out = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!run.staged().exists());
}
#[cfg(unix)]
#[test]
fn install_shell_names_the_real_cause_of_a_refusal() {
let run = InstallRun::new();
std::fs::create_dir_all(run.bin.parent().unwrap()).unwrap();
std::fs::write(&run.bin, "not a binary").unwrap();
let refused = run.run(&format!("v{MEDIAMTX_KNOWN_GOOD_VERSION}"));
let stderr = String::from_utf8_lossy(&refused.stderr);
assert!(stderr.contains("refusing to replace"), "{stderr}");
assert!(
!stderr.contains("apt-based"),
"no apt hint for a refusal: {stderr}"
);
let wrong = InstallRun::new().run("v1.18.0");
let stderr = String::from_utf8_lossy(&wrong.stderr);
assert!(stderr.contains("v1.19.3"), "{stderr}");
assert!(
!stderr.contains("apt-based"),
"no apt hint for a version mismatch: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn install_shell_installs_nothing_when_the_version_is_wrong() {
let run = InstallRun::new();
let out = run.run("v1.18.0");
assert!(!out.status.success());
assert!(!run.bin.exists(), "a wrong build must not be installed");
assert!(!run.staged().exists(), "no staged file left");
}
#[test]
fn recordings_dir_passes_when_present_and_writable() {
let exec = RecordingExecutor::new().with_stdout("media-recordings-dir", "present\n");
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_RECORDINGS_DIR_WRITABLE);
assert!(check.detail.contains("exists and is writable"));
assert_eq!(exec.labels(), vec!["media-recordings-dir"]);
}
#[test]
fn recordings_dir_passes_when_absent_but_creatable() {
let exec = RecordingExecutor::new().with_stdout("media-recordings-dir", "creatable\n");
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert!(
check.detail.contains("will be created"),
"detail must say the dir is created by provisioning: {}",
check.detail
);
}
#[test]
fn recordings_dir_fails_when_not_writable() {
let exec = RecordingExecutor::failing_on("media-recordings-dir");
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.detail.contains("/recordings"));
assert!(check.hint.is_some());
}
#[test]
fn recordings_dir_fails_when_output_proves_nothing() {
for stdout in ["", "yes", "presentish"] {
let exec = RecordingExecutor::new().with_stdout("media-recordings-dir", stdout);
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "stdout {stdout:?} must not pass");
assert!(check.hint.is_some());
}
}
#[test]
fn recordings_dir_reports_the_two_host_failures_apart() {
for (marker, needle) in [
("unwritable", "exists but is not writable"),
("blocked", "nearest existing ancestor"),
] {
let exec = RecordingExecutor::new().with_stdout("media-recordings-dir", marker);
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "{marker} must not pass");
assert!(!check.deferred);
assert!(check.detail.contains(needle), "detail: {}", check.detail);
}
}
#[test]
fn recordings_dir_probe_is_quoted_and_walks_without_dirname() {
let exec = RecordingExecutor::new().with_stdout("media-recordings-dir", "present");
let cfg = MediaMtxHostConfig {
recordings_dir: "/srv/media recordings".to_owned(),
..MediaMtxHostConfig::default()
};
let check = recordings_dir_writable(&exec, &cfg);
assert!(check.passed, "detail: {}", check.detail);
let shell = exec.shell_for("media-recordings-dir");
assert!(
shell.contains("d='/srv/media recordings';"),
"path must be shell-quoted: {shell}"
);
assert!(
!shell.contains("dirname"),
"walk must not shell out: {shell}"
);
assert!(
shell.contains(r#"[ ! -L "$d" ]"#),
"walk must stop at a symlink: {shell}"
);
for marker in [
"printf present",
"printf unwritable",
"printf creatable",
"printf blocked",
] {
assert!(shell.contains(marker), "missing `{marker}`: {shell}");
}
}
#[test]
fn recordings_dir_fails_closed_on_a_path_that_names_no_host_directory() {
for (dir, needle) in [("", "is empty"), ("recordings", "must be an absolute path")] {
let exec = RecordingExecutor::new().with_stdout("media-recordings-dir", "creatable");
let cfg = MediaMtxHostConfig {
recordings_dir: dir.to_owned(),
..MediaMtxHostConfig::default()
};
let check = recordings_dir_writable(&exec, &cfg);
assert!(!check.passed, "`{dir}` must fail closed");
assert!(!check.deferred, "`{dir}` is a config error, not a deferral");
assert!(check.detail.contains(needle), "detail: {}", check.detail);
assert!(exec.labels().is_empty(), "no probe may run for `{dir}`");
}
}
#[test]
fn recordings_dir_defers_an_env_indirected_path() {
let exec = RecordingExecutor::new();
let cfg = MediaMtxHostConfig {
recordings_dir: "${MEDIA_REC_ROOT}/mediamtx".to_owned(),
..MediaMtxHostConfig::default()
};
let check = recordings_dir_writable(&exec, &cfg);
assert!(!check.passed);
assert!(check.deferred);
assert!(!check.blocking(), "a deferred path must not abort a deploy");
assert!(exec.labels().is_empty(), "no probe may run");
}
#[test]
fn parse_listening_sockets_extracts_port_protocol_and_owner() {
let ss = "\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=5))
tcp LISTEN 0 128 [::]:22 [::]:* users:((\"sshd\",pid=5,fd=3))
udp UNCONN 0 0 *:8189 *:*
sctp LISTEN 0 128 0.0.0.0:9999 0.0.0.0:*
";
let sockets = parse_listening_sockets(ss);
assert!(sockets.iter().any(|s| s.port == 8888
&& s.protocol == Protocol::Tcp
&& s.owner.as_deref() == Some("mediamtx")));
assert!(sockets.iter().any(|s| s.port == 22
&& s.protocol == Protocol::Tcp
&& s.owner.as_deref() == Some("sshd")));
assert!(
sockets
.iter()
.any(|s| s.port == 8189 && s.protocol == Protocol::Udp && s.owner.is_none())
);
assert_eq!(sockets.len(), 3);
}
#[test]
fn ports_available_passes_when_ports_free() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_MEDIAMTX_PORTS_AVAILABLE);
assert!(check.detail.contains("free"), "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_when_foreign_process_holds_port() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(check.detail.contains("another service"));
assert!(check.hint.is_some());
}
#[test]
fn ports_available_passes_when_our_mediamtx_owns_all_ports() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:9997 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:1935 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=4))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=5))\n\
tcp LISTEN 0 128 0.0.0.0:8889 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=6))\n\
tcp LISTEN 0 128 0.0.0.0:9996 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=7))\n\
udp UNCONN 0 0 0.0.0.0:8189 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=8))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert!(
check.detail.contains("our own managed"),
"detail: {}",
check.detail
);
assert_eq!(exec.labels(), vec!["media-ports", "media-unit-active"]);
}
#[test]
fn ports_available_fails_when_changed_port_held_by_foreign_service() {
let cfg = MediaMtxHostConfig {
hls_port: 8080,
..MediaMtxHostConfig::default()
};
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:9997 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:1935 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=4))\n\
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &cfg);
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8080"), "detail: {}", check.detail);
}
#[test]
fn ports_available_ignores_same_port_different_protocol_listener() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8189 0.0.0.0:* users:((\"someapp\",pid=77,fd=9))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("free"), "detail: {}", check.detail);
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"udp UNCONN 0 0 0.0.0.0:8888 0.0.0.0:* users:((\"someapp\",pid=88,fd=4))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_on_same_protocol_foreign_listener() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(check.detail.contains("another service"));
}
#[test]
fn ports_available_fails_on_udp_foreign_listener_on_udp_target() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"udp UNCONN 0 0 0.0.0.0:8189 0.0.0.0:* users:((\"coturn\",pid=42,fd=8))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8189"), "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_closed_when_mediamtx_owns_ports_but_unit_inactive() {
let exec = RecordingExecutor::new()
.with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:9997 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=5))\n",
)
.failing("media-unit-active");
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(
check.detail.contains("not active"),
"detail: {}",
check.detail
);
assert_eq!(exec.labels(), vec!["media-ports", "media-unit-active"]);
}
#[test]
fn ports_available_does_not_query_is_active_when_no_mediamtx_port_held() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert_eq!(exec.labels(), vec!["media-ports"]);
}
#[test]
fn ports_available_fails_closed_on_unattributable_listener() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:*\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_closed_when_ss_errors() {
let exec = RecordingExecutor::failing_on("media-ports");
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.detail.contains("could not verify"));
}
#[test]
fn ports_available_fails_closed_on_unparseable_output() {
let exec =
RecordingExecutor::new().with_stdout("media-ports", "garbage that has no ports\n");
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.detail.contains("could not verify"));
}
#[test]
fn collect_runs_all_six_checks_in_order() {
let exec = RecordingExecutor::new()
.with_stdout("media-ffmpeg-preflight", "ffmpeg version 6.1")
.with_stdout("media-mediamtx-binary", "present")
.with_stdout("media-recordings-dir", "present")
.with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
);
let checks =
collect_media_doctor_checks(&exec, &MediaMtxHostConfig::default(), "/usr/bin/ffmpeg");
assert_eq!(checks.len(), 6);
assert!(
checks.iter().all(|c| c.passed),
"all should pass: {:?}",
checks.iter().filter(|c| !c.passed).collect::<Vec<_>>()
);
assert_eq!(checks[0].name, CHECK_MEDIAMTX_PORTS_DISTINCT);
assert_eq!(
exec.labels(),
vec![
"media-ffmpeg-preflight",
"media-mediamtx-binary",
"media-recordings-dir",
"media-ports"
]
);
}
#[test]
fn collect_with_env_indirected_ffmpeg_has_no_blocking_failures() {
let exec = RecordingExecutor::new()
.with_stdout("media-mediamtx-binary", "present")
.with_stdout("media-recordings-dir", "present")
.with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
);
let checks = collect_media_doctor_checks(
&exec,
&MediaMtxHostConfig::default(),
"${AUTUMN_MEDIA__FFMPEG__BIN}",
);
let ffmpeg = checks
.iter()
.find(|c| c.name == CHECK_FFMPEG_PREFLIGHT)
.expect("ffmpeg check present");
assert!(ffmpeg.deferred && !ffmpeg.passed);
assert!(!exec.labels().contains(&"media-ffmpeg-preflight"));
assert_eq!(
checks.iter().filter(|c| c.blocking()).count(),
0,
"deferred ffmpeg must not block the deploy"
);
}
#[test]
fn ports_match_bases_passes_for_default_config() {
let check = mediamtx_ports_match_bases(&MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_MEDIAMTX_PORTS_MATCH_BASES);
assert!(!check.deferred);
}
#[test]
fn ports_match_bases_fails_when_a_custom_port_leaves_the_base_behind() {
let cfg = MediaMtxHostConfig {
rtmp_port: 1936,
rtmp_base: Some("rtmp://127.0.0.1:1935/live".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(check.blocking(), "a real mismatch must block the deploy");
assert!(check.detail.contains("rtmp_port"), "{}", check.detail);
assert!(check.detail.contains("rtmp_base"), "{}", check.detail);
assert!(check.detail.contains("1936"), "{}", check.detail);
assert!(check.detail.contains("1935"), "{}", check.detail);
assert!(check.hint.is_some());
}
#[test]
fn ports_match_bases_passes_when_the_base_is_updated_to_match() {
let cfg = MediaMtxHostConfig {
rtmp_port: 1936,
webrtc_port: 8899,
rtmp_base: Some("rtmp://media.example.com:1936/live".to_owned()),
webrtc_base: Some("https://media.example.com:8899".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(check.passed, "detail: {}", check.detail);
}
#[test]
fn ports_match_bases_ignores_a_base_with_no_explicit_port() {
let cfg = MediaMtxHostConfig {
hls_port: 9999,
hls_base: Some("https://media.example.com/hls".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(check.passed, "detail: {}", check.detail);
}
#[test]
fn ports_match_bases_defers_an_env_indirected_base() {
let cfg = MediaMtxHostConfig {
hls_port: 9999,
hls_base: Some("${MEDIA_HLS_BASE}".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(check.deferred);
assert!(!check.blocking(), "a deferred base must not abort a deploy");
assert!(check.detail.contains("hls_base"), "{}", check.detail);
}
#[test]
fn ports_match_bases_reports_a_mismatch_even_when_another_base_is_indirected() {
let cfg = MediaMtxHostConfig {
rtmp_port: 1936,
rtmp_base: Some("rtmp://127.0.0.1:1935/live".to_owned()),
hls_base: Some("${MEDIA_HLS_BASE}".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(!check.deferred);
assert!(check.blocking());
assert!(check.detail.contains("rtmp_base"), "{}", check.detail);
}
#[test]
fn ports_match_bases_checks_the_probe_bases_too() {
let cfg = MediaMtxHostConfig {
hls_port: 8888,
hls_probe_base: Some("http://127.0.0.1:8890".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(check.detail.contains("hls_probe_base"), "{}", check.detail);
}
#[test]
fn ports_match_bases_reports_every_mismatched_pair() {
let cfg = MediaMtxHostConfig {
api_port: 1,
playback_port: 2,
api_base: Some("http://127.0.0.1:9997".to_owned()),
playback_base: Some("http://127.0.0.1:9996".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(check.detail.contains("api_base"), "{}", check.detail);
assert!(check.detail.contains("playback_base"), "{}", check.detail);
}
#[test]
fn explicit_port_separates_a_named_port_from_a_portless_and_a_malformed_one() {
use BasePort::{Explicit, Implicit, Malformed};
assert_eq!(explicit_port("http://127.0.0.1:9997"), Explicit(9997));
assert_eq!(explicit_port("rtmp://host:1935/live"), Explicit(1935));
assert_eq!(explicit_port("https://user:pw@host:8443/x"), Explicit(8443));
assert_eq!(explicit_port("http://[::1]:8888/hls"), Explicit(8888));
assert_eq!(explicit_port("https://media.example.com/hls"), Implicit);
assert_eq!(explicit_port("http://[::1]/hls"), Implicit);
assert_eq!(explicit_port("host-only"), Implicit);
assert_eq!(explicit_port("https://user:pw@host/x"), Implicit);
assert_eq!(explicit_port(""), Implicit);
assert_eq!(explicit_port("http://host:notaport/x"), Malformed);
assert_eq!(explicit_port("http://host:99999/x"), Malformed);
assert_eq!(explicit_port("http://host: 8888/x"), Malformed);
assert_eq!(explicit_port("http://[::1]junk/x"), Malformed);
}
#[test]
fn addresses_listener_directly_recognizes_only_loopback() {
for base in [
"http://127.0.0.1:8888",
"http://localhost:8888/hls",
"http://[::1]:8888",
"rtmp://LOCALHOST:1935/live",
"http://user:pw@127.0.0.1:8888",
] {
assert!(addresses_listener_directly(base), "{base} is loopback");
}
for base in [
"https://media.example.com:8443/hls",
"https://cdn.example.net",
"http://10.0.0.4:8888",
"http://[2001:db8::1]:8888",
] {
assert!(!addresses_listener_directly(base), "{base} is fronted");
}
}
#[test]
fn ports_match_bases_skips_a_proxied_base_on_an_explicit_port() {
let cfg = MediaMtxHostConfig {
hls_port: 8888,
hls_base: Some("https://media.example.com:8443/hls".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(check.passed, "detail: {}", check.detail);
}
#[test]
fn ports_match_bases_defers_rather_than_blocks_when_the_base_is_unset() {
let cfg = MediaMtxHostConfig {
rtmp_port: 1936,
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(check.deferred);
assert!(!check.blocking(), "an unset base must not abort a deploy");
assert!(check.detail.contains("rtmp_base"), "{}", check.detail);
assert!(check.detail.contains("1936"), "{}", check.detail);
assert!(check.detail.contains("1935"), "{}", check.detail);
}
#[test]
fn ports_match_bases_reports_a_base_whose_port_is_malformed() {
let cfg = MediaMtxHostConfig {
hls_base: Some("http://127.0.0.1:88888".to_owned()),
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_match_bases(&cfg);
assert!(!check.passed);
assert!(check.blocking());
assert!(check.detail.contains("no valid port"), "{}", check.detail);
}
#[test]
fn ports_distinct_passes_for_default_config() {
let check = mediamtx_ports_distinct(&MediaMtxHostConfig::default());
assert!(
check.passed,
"default ports are all distinct: {}",
check.detail
);
assert_eq!(check.name, CHECK_MEDIAMTX_PORTS_DISTINCT);
}
#[test]
fn ports_distinct_fails_when_two_tcp_listeners_share_a_port() {
let cfg = MediaMtxHostConfig {
hls_port: 8888,
playback_port: 8888,
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_distinct(&cfg);
assert!(!check.passed);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(
check.detail.contains("hls_port"),
"detail: {}",
check.detail
);
assert!(
check.detail.contains("playback_port"),
"detail: {}",
check.detail
);
assert!(check.hint.is_some());
}
#[test]
fn ports_distinct_allows_tcp_port_equal_to_udp_port() {
let cfg = MediaMtxHostConfig {
rtmp_port: MEDIAMTX_WEBRTC_LOCAL_UDP_PORT,
..MediaMtxHostConfig::default()
};
assert_eq!(cfg.rtmp_port, cfg.webrtc_local_udp);
let check = mediamtx_ports_distinct(&cfg);
assert!(
check.passed,
"a TCP port equal to the UDP port is not a same-protocol conflict: {}",
check.detail
);
}
#[test]
fn media_host_config_from_value_reads_merged_media_subtree() {
let root: toml::Value = toml::from_str(
"[media.mediamtx]\nenabled = true\napi_port = 19997\n[media.ffmpeg]\nbin = \"/opt/ff\"\n",
)
.expect("parses");
let (cfg, bin) = media_host_config_from_value(root).expect("deserializes");
assert!(cfg.enabled);
assert_eq!(cfg.api_port, 19997);
assert_eq!(bin, "/opt/ff");
}
#[test]
fn media_host_config_from_value_absent_media_is_disabled_default() {
let root: toml::Value = toml::from_str("[deploy]\nhost = \"h\"\n").expect("parses");
let (cfg, bin) = media_host_config_from_value(root).expect("deserializes");
assert!(!cfg.enabled);
assert_eq!(bin, DEFAULT_FFMPEG_BIN);
}
#[test]
fn media_host_config_from_value_fails_closed_on_ill_typed_value() {
let root: toml::Value =
toml::from_str("[media.mediamtx]\nenabled = \"yes\"\n").expect("parses");
assert!(media_host_config_from_value(root).is_err());
}
#[test]
fn required_csp_origins_lists_browser_facing_ports() {
let origins = MediaMtxHostConfig::default().required_csp_origins();
assert!(
origins.contains(&"http://127.0.0.1:8889".to_owned()),
"webrtc"
);
assert!(origins.contains(&"http://127.0.0.1:8888".to_owned()), "hls");
assert!(
origins.contains(&"http://127.0.0.1:9996".to_owned()),
"playback"
);
assert!(!origins.iter().any(|o| o.contains("9997")));
}
}