pub mod admin;
pub(crate) mod resource;
pub mod supervisor;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use broadcast_auth::{AuthResult, Verifier};
use metrics_exporter_prometheus::PrometheusHandle;
use tokio::sync::watch;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::timeout::TimeoutLayer;
use crate::output::Output;
use crate::registry::{AuthCtx, InputCtx, OutputCtx, SchemeRegistry};
use crate::route::{HealthState, RouteHandle};
use supervisor::Backoff;
const OUTPUT_AUTH_REALM: &str = "multimux";
#[derive(Debug, Clone, Copy)]
pub struct HttpLimits {
pub request_timeout: Duration,
pub max_concurrent_requests: usize,
pub max_request_body_bytes: usize,
}
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 4096;
pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 16 * 1024;
impl Default for HttpLimits {
fn default() -> Self {
HttpLimits {
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_concurrent_requests: DEFAULT_MAX_CONCURRENT_REQUESTS,
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
}
}
}
impl From<&crate::config::Config> for HttpLimits {
fn from(cfg: &crate::config::Config) -> Self {
HttpLimits {
request_timeout: Duration::from_secs_f64(cfg.request_timeout_secs),
max_concurrent_requests: cfg.max_concurrent_requests,
max_request_body_bytes: cfg.max_request_body_bytes,
}
}
}
const SUPERVISOR_SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
pub type StreamRoute = (Arc<RouteHandle>, Vec<Arc<dyn Output>>);
pub struct AppState {
pub streams: HashMap<String, StreamRoute>,
pub metrics_handle: PrometheusHandle,
limits: HttpLimits,
output_auth: Option<Arc<Verifier>>,
}
impl AppState {
pub fn new(streams: HashMap<String, StreamRoute>) -> Self {
AppState {
streams,
metrics_handle: crate::prometheus::install(),
limits: HttpLimits::default(),
output_auth: None,
}
}
#[must_use]
pub fn with_limits(mut self, limits: HttpLimits) -> Self {
self.limits = limits;
self
}
#[must_use]
pub fn with_output_auth(mut self, verifier: Arc<Verifier>) -> Self {
self.output_auth = Some(verifier);
self
}
}
pub fn router(state: Arc<AppState>) -> Router {
let limits = state.limits;
let mut router = Router::new();
for (name, (store, outputs)) in &state.streams {
let mut stream_router = resource::router(store.clone());
for output in outputs {
stream_router = stream_router.merge(output.manifest_routes(store.clone()));
}
stream_router = stream_router.layer(middleware::from_fn_with_state(
state.clone(),
output_auth_gate,
));
stream_router = stream_router.layer(middleware::from_fn(add_response_headers));
router = router.nest(&format!("/{name}"), stream_router);
}
let root = Router::new()
.route("/metrics", get(metrics_handler))
.route("/healthz", get(healthz))
.route("/readyz", get(readyz))
.with_state(state.clone());
router
.merge(root)
.layer(TimeoutLayer::new(limits.request_timeout))
.layer(ConcurrencyLimitLayer::new(limits.max_concurrent_requests))
.layer(RequestBodyLimitLayer::new(limits.max_request_body_bytes))
.layer(middleware::from_fn_with_state(state, track_http))
}
async fn output_auth_gate(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Response {
let Some(verifier) = &state.output_auth else {
return next.run(req).await;
};
if req.method() == Method::OPTIONS {
return next.run(req).await;
}
let method = req.method().as_str().to_string();
let uri = req
.extensions()
.get::<axum::extract::OriginalUri>()
.map(|o| o.0.clone())
.unwrap_or_else(|| req.uri().clone());
let uri = uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| uri.path().to_string());
let headers: Vec<(&str, &str)> = req
.headers()
.iter()
.filter_map(|(name, value)| value.to_str().ok().map(|v| (name.as_str(), v)))
.collect();
let peer_addr = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0);
let mut ctx = broadcast_auth::RequestContext::new(&method, &uri).with_headers(&headers);
if let Some(peer_addr) = peer_addr {
ctx = ctx.with_peer_addr(peer_addr);
}
if let Some(forwarded_for) = verifier.forwarded_for(&ctx) {
tracing::debug!(%forwarded_for, "output-auth: forwarded-for header");
}
match verifier.verify(&ctx) {
AuthResult::Ok => next.run(req).await,
AuthResult::Unauthorized => {
let mut resp = StatusCode::UNAUTHORIZED.into_response();
if let Ok(value) = HeaderValue::from_str(&verifier.challenge()) {
resp.headers_mut().insert(header::WWW_AUTHENTICATE, value);
}
resp
}
_ => StatusCode::UNAUTHORIZED.into_response(),
}
}
async fn add_response_headers(req: Request, next: Next) -> Response {
let path = req.uri().path();
let is_manifest = path.ends_with(".m3u8") || path.ends_with(".mpd");
let mut resp = next.run(req).await;
let headers = resp.headers_mut();
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
headers.insert(
header::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET, OPTIONS"),
);
headers.insert(
header::ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static("*"),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(if is_manifest {
CACHE_CONTROL_MANIFEST
} else {
CACHE_CONTROL_IMMUTABLE
}),
);
resp
}
const CACHE_CONTROL_MANIFEST: &str = "no-cache";
const CACHE_CONTROL_IMMUTABLE: &str = "max-age=31536000, immutable";
async fn metrics_handler(State(state): State<Arc<AppState>>) -> Response {
let body = state.metrics_handle.render();
([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body).into_response()
}
async fn healthz() -> StatusCode {
StatusCode::OK
}
async fn readyz(State(state): State<Arc<AppState>>) -> Response {
let any_live = state
.streams
.values()
.any(|(store, _)| store.health() == HealthState::Live);
if any_live {
StatusCode::OK.into_response()
} else {
StatusCode::SERVICE_UNAVAILABLE.into_response()
}
}
fn classify_path(state: &AppState, path: &str) -> (String, &'static str) {
match path {
"/metrics" => return ("-".to_string(), "metrics"),
"/healthz" | "/readyz" => return ("-".to_string(), "health"),
_ => {}
}
let mut segments = path.trim_start_matches('/').splitn(2, '/');
let first = segments.next().unwrap_or("");
let rest = segments.next().unwrap_or("");
let route = if state.streams.contains_key(first) {
first.to_string()
} else {
"unknown".to_string()
};
let kind = if rest.ends_with("master.m3u8") || rest.ends_with("media.m3u8") {
"playlist"
} else if rest.starts_with("seg-") {
"segment"
} else if rest.starts_with("part-") {
"part"
} else if rest.starts_with("init-") {
"init"
} else {
"other"
};
(route, kind)
}
async fn track_http(State(state): State<Arc<AppState>>, req: Request, next: Next) -> Response {
let path = req.uri().path().to_string();
let start = std::time::Instant::now();
let resp = next.run(req).await;
let elapsed = start.elapsed();
let (route, kind) = classify_path(&state, &path);
let status = resp.status().as_u16().to_string();
let (parts, body) = resp.into_parts();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
let byte_len = bytes.len() as u64;
metrics::counter!(
crate::prometheus::HTTP_REQUESTS_TOTAL,
"route" => route.clone(),
"path" => kind,
"status" => status,
)
.increment(1);
metrics::histogram!(
crate::prometheus::HTTP_REQUEST_DURATION_SECONDS,
"route" => route.clone(),
"path" => kind,
)
.record(elapsed.as_secs_f64());
metrics::counter!(
crate::prometheus::BYTES_SERVED_TOTAL,
"route" => route,
"path" => kind,
)
.increment(byte_len);
Response::from_parts(parts, Body::from(bytes))
}
pub(crate) fn route_container(route: &crate::config::Route) -> hls_runtime::server::Container {
if route
.outputs
.iter()
.any(|k| matches!(k, crate::output::OutputKind::TsHls))
{
hls_runtime::server::Container::MpegTs
} else {
hls_runtime::server::Container::Fmp4
}
}
fn build_output(
kind: &crate::output::OutputKind,
playlist_name: &str,
registry: &SchemeRegistry,
) -> crate::Result<Arc<dyn Output>> {
match kind {
crate::output::OutputKind::Custom { type_tag, params } => {
let factory =
registry
.output(type_tag)
.ok_or_else(|| crate::MultimuxError::UnknownScheme {
kind: "output",
tag: type_tag.clone(),
})?;
factory(&OutputCtx {
params: params.clone(),
playlist_name,
})
}
builtin => Ok(builtin.build_with_playlist_name(playlist_name)),
}
}
fn resolve_verifier(
spec: &crate::config::OutputAuthSpec,
realm: &str,
registry: &SchemeRegistry,
) -> crate::Result<broadcast_auth::Verifier> {
match spec {
crate::config::OutputAuthSpec::Custom { type_tag, params } => {
let factory =
registry
.auth(type_tag)
.ok_or_else(|| crate::MultimuxError::UnknownScheme {
kind: "auth",
tag: type_tag.clone(),
})?;
factory(&AuthCtx {
params: params.clone(),
realm,
})
}
builtin => Ok(builtin.build_verifier(realm)),
}
}
pub async fn serve(config: crate::config::Config) -> crate::Result<()> {
serve_with_registry(config, SchemeRegistry::new()).await
}
pub async fn serve_config_file_with_registry(
path: impl AsRef<Path>,
registry: SchemeRegistry,
) -> crate::Result<()> {
let path = path.as_ref().to_path_buf();
let config = crate::config::Config::from_json_file(&path)?;
serve_with_registry_impl(config, registry, Some(path)).await
}
pub async fn serve_config_file(path: impl AsRef<Path>) -> crate::Result<()> {
serve_config_file_with_registry(path, SchemeRegistry::new()).await
}
pub async fn serve_with_registry(
config: crate::config::Config,
registry: SchemeRegistry,
) -> crate::Result<()> {
serve_with_registry_impl(config, registry, None).await
}
async fn serve_with_registry_impl(
config: crate::config::Config,
registry: SchemeRegistry,
config_path: Option<PathBuf>,
) -> crate::Result<()> {
config.validate()?;
if config.admin.is_some() {
return admin::serve_with_admin(config, registry, config_path).await;
}
tracing::info!(
bind = %config.bind,
routes = config.routes.len(),
"multimux origin starting"
);
let mut streams: HashMap<String, StreamRoute> = HashMap::new();
let target_duration_secs = config.target_duration_secs;
let part_target_ms = config.part_target_ms;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let cancel = tokio_util::sync::CancellationToken::new();
let mut supervisor_handles: Vec<(String, tokio::task::JoinHandle<()>)> = Vec::new();
for route in &config.routes {
let store = Arc::new(
RouteHandle::new(target_duration_secs, part_target_ms, config.window_segments)
.with_name(route.name.clone())
.with_container(route_container(route)),
);
let outputs: Vec<Arc<dyn Output>> = route
.outputs
.iter()
.filter(|k| !k.is_push())
.map(|k| build_output(k, &config.playlist_name, ®istry))
.collect::<crate::Result<Vec<_>>>()?;
streams.insert(route.name.clone(), (store.clone(), outputs));
let push_handles = spawn_push_outputs(route, Arc::clone(&store), &cancel);
for h in push_handles {
supervisor_handles.push((route.name.clone(), h));
}
let name = route.name.clone();
let shutdown_rx = shutdown_rx.clone();
let handle = spawn_ingest(route, store, &config, ®istry, shutdown_rx)?;
supervisor_handles.push((name, handle));
}
let mut app_state = AppState::new(streams).with_limits(HttpLimits::from(&config));
if let Some(output_auth) = &config.output_auth {
let verifier = resolve_verifier(output_auth, OUTPUT_AUTH_REALM, ®istry)?;
app_state = app_state.with_output_auth(Arc::new(verifier));
}
let state = Arc::new(app_state);
let listener = tokio::net::TcpListener::bind(config.bind.as_str()).await?;
let shutdown_future = async move {
shutdown_signal().await;
tracing::info!("shutdown signal received, draining");
cancel.cancel();
let _ = shutdown_tx.send(true);
};
let serve_result = axum::serve(
listener,
router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_future)
.await;
for (name, handle) in supervisor_handles {
let abort_handle = handle.abort_handle();
if tokio::time::timeout(SUPERVISOR_SHUTDOWN_GRACE, handle)
.await
.is_err()
{
tracing::warn!(
route = %name,
"supervisor task did not exit within the shutdown grace period; aborting"
);
abort_handle.abort();
}
}
serve_result?;
Ok(())
}
fn spawn_push_outputs(
route: &crate::config::Route,
store: Arc<RouteHandle>,
cancel: &tokio_util::sync::CancellationToken,
) -> Vec<tokio::task::JoinHandle<()>> {
let mut handles = Vec::new();
for kind in &route.outputs {
match kind {
crate::output::OutputKind::SrtPush {
url,
format,
reconnect,
} => {
let url = url.clone();
let format = format.unwrap_or(crate::config::PushFormat::Ts);
let reconnect = reconnect.clone().unwrap_or_default();
let store = Arc::clone(&store);
let cancel = cancel.clone();
handles.push(tokio::spawn(async move {
let trunk = store.await_first_trunk().await;
tracing::info!(%url, "SRT push output starting");
let config = crate::push::SrtTransportConfig::default();
crate::push::drive_push::<crate::push::SrtTransport>(
trunk, url, config, format, reconnect, cancel,
)
.await;
}));
}
crate::output::OutputKind::RtmpPush {
url,
format,
reconnect,
} => {
let url = url.clone();
let _format = format.unwrap_or(crate::config::PushFormat::Ts);
let reconnect = reconnect.clone().unwrap_or_default();
let store = Arc::clone(&store);
let cancel = cancel.clone();
handles.push(tokio::spawn(async move {
let trunk = store.await_first_trunk().await;
tracing::info!(%url, "RTMP push output starting");
let (app, stream_key) = rtmp_app_and_stream_key(&url);
let config = crate::push::RtmpTransportConfig { app, stream_key };
crate::push::drive_push::<crate::push::RtmpTransport>(
trunk, url, config, _format, reconnect, cancel,
)
.await;
}));
}
crate::output::OutputKind::RtspPush {
url,
format,
reconnect,
} => {
let url = url.clone();
let format = format.unwrap_or(crate::config::PushFormat::Ts);
let reconnect = reconnect.clone().unwrap_or_default();
let store = Arc::clone(&store);
let cancel = cancel.clone();
handles.push(tokio::spawn(async move {
let trunk = store.await_first_trunk().await;
tracing::info!(%url, "RTSP push output starting");
let config = crate::push::RtspTransportConfig::default();
crate::push::drive_push::<crate::push::RtspTransport>(
trunk, url, config, format, reconnect, cancel,
)
.await;
}));
}
_ => {}
}
}
handles
}
fn rtmp_app_and_stream_key(url: &str) -> (String, String) {
let path = url::Url::parse(url)
.ok()
.map(|u| u.path().to_string())
.unwrap_or_default();
let segments: Vec<&str> = path
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
match segments.len() {
0 => ("live".to_string(), String::new()),
1 => (segments[0].to_string(), String::new()),
_ => {
let (last, rest) = segments.split_last().expect("len >= 2 checked above");
(rest.join("/"), last.to_string())
}
}
}
fn spawn_ingest(
route: &crate::config::Route,
store: Arc<RouteHandle>,
config: &crate::config::Config,
registry: &SchemeRegistry,
shutdown_rx: watch::Receiver<bool>,
) -> crate::Result<tokio::task::JoinHandle<()>> {
let name = route.name.clone();
let target_duration_secs = config.target_duration_secs;
let part_target_ms = config.part_target_ms;
let window_segments = config.window_segments;
let timeouts = crate::source::IngestTimeouts::from(config);
Ok(match &route.input {
crate::config::InputSpec::Rtsp { url, auth } => {
let route_cfg = Arc::new(
crate::source::rtsp::RtspRoute::new(name.clone(), url.clone())
.with_timeouts(timeouts)
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials)),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
Err(crate::source::rtsp::run_rtsp(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await)
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::Rtp {
addr,
sdp,
multicast_group,
} => {
let route_cfg = Arc::new(
crate::source::rtp_udp::RtpUdpRoute::new(
name.clone(),
addr.clone(),
sdp.clone(),
multicast_group.clone(),
)
.with_timeouts(timeouts),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
Err(crate::source::rtp_udp::run_rtp_udp(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await)
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::TsUdp {
addr,
multicast_group,
} => {
let route_cfg = Arc::new(
crate::source::ts_udp::TsUdpRoute::new(
name.clone(),
addr.clone(),
multicast_group.clone(),
)
.with_timeouts(timeouts),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
Err(crate::source::ts_udp::run_ts_udp(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await)
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::TsHttp { url, auth } => {
let route_cfg = Arc::new(
crate::source::ts_http::TsHttpRoute::new(name.clone(), url.clone())
.with_timeouts(timeouts)
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials)),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
crate::source::ts_http::run_ts_http(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::Srt {
listen,
remote,
stream_id,
latency_ms,
} => {
let is_listener = listen.is_some();
let mut srt_route = match (listen, remote) {
(Some(l), None) => {
crate::source::srt::SrtRoute::new_listener(name.clone(), l.clone())
}
(None, Some(r)) => {
crate::source::srt::SrtRoute::new_caller(name.clone(), r.clone())
}
_ => unreachable!("config.validate() enforces exactly one of Srt listen/remote"),
};
srt_route = srt_route
.with_stream_id(stream_id.clone())
.with_latency_ms(*latency_ms)
.with_timeouts(timeouts);
let route_cfg = Arc::new(srt_route);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
if is_listener {
crate::source::srt::run_srt_listener_once(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await
} else {
crate::source::srt::run_srt_caller(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await
}
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::HlsPull { url, auth } => {
let route_cfg = Arc::new(
crate::source::hls_pull::HlsPullRoute::new(name.clone(), url.clone())
.with_timeouts(timeouts)
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials)),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
crate::source::hls_pull::run_hls_pull(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::DashPull { url, auth } => {
let route_cfg = Arc::new(
crate::source::dash_pull::DashPullRoute::new(name.clone(), url.clone())
.with_timeouts(timeouts)
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials)),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
crate::source::dash_pull::run_dash_pull(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::SmoothPull { url, auth } => {
let route_cfg = Arc::new(
crate::source::smooth_pull::SmoothPullRoute::new(name.clone(), url.clone())
.with_timeouts(timeouts)
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials)),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
crate::source::smooth_pull::run_smooth_pull(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::Rtmp {
listen,
app,
stream_key,
} => {
let route_cfg = Arc::new(
crate::source::rtmp::RtmpRoute::new(name.clone(), listen.clone())
.with_app(app.clone())
.with_stream_key(stream_key.clone())
.with_timeouts(timeouts),
);
tokio::spawn(supervisor::supervise_driver(
move |route_handle| {
let route_cfg = Arc::clone(&route_cfg);
async move {
let trunk_config = crate::source::driver_trunk_config(window_segments);
let handshake = crate::source::handshake_policy(timeouts.connect);
Err(crate::source::rtmp::run_rtmp(
&route_cfg,
trunk_config,
handshake,
&route_handle,
)
.await)
}
},
store,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::Custom { type_tag, params } => {
let factory =
registry
.input(type_tag)
.ok_or_else(|| crate::MultimuxError::UnknownScheme {
kind: "input",
tag: type_tag.clone(),
})?;
factory(InputCtx {
name: name.clone(),
params: params.clone(),
store,
target_duration_secs,
part_target_ms,
shutdown_rx,
})?
}
})
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {}
() = terminate => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dvr::DvrConfig;
use crate::output::llhls::LlHlsOutput;
use crate::route::RouteHandle;
use tower::ServiceExt;
fn make_state() -> Arc<AppState> {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.publish_new_program(crate::route::SPTS_PROGRAM_ID);
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
Arc::new(AppState::new(streams))
}
fn get(uri: &str) -> axum::http::Request<axum::body::Body> {
axum::http::Request::builder()
.uri(uri)
.body(axum::body::Body::empty())
.unwrap()
}
#[tokio::test]
async fn router_dispatches_static_routes_over_catch_all() {
let app = router(make_state());
let resp = app.clone().oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8(bytes.to_vec())
.unwrap()
.contains("#EXT-X-STREAM-INF")
);
let resp = app.clone().oneshot(get("/cam1/init-1.mp4")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(bytes.to_vec(), vec![0xAA; 4]);
let resp = app.oneshot(get("/cam1/no-such-file.bin")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn master_playlist_unknown_stream_404() {
let app = router(make_state());
let resp = app.oneshot(get("/nope/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn media_playlist_unknown_stream_404() {
let app = router(make_state());
let resp = app.oneshot(get("/nope/media.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_unknown_stream_404() {
let app = router(make_state());
let resp = app.oneshot(get("/nope/init-1.mp4")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
async fn body_string(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn metrics_endpoint_serves_prometheus_exposition() {
let app = router(make_state());
let warm = app.clone().oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(warm.status(), axum::http::StatusCode::OK);
let resp = app.oneshot(get("/metrics")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap(),
"text/plain; version=0.0.4"
);
let body = body_string(resp).await;
assert!(
body.contains("multimux_"),
"metrics body must contain at least one multimux_ metric: {body}"
);
}
#[tokio::test]
async fn healthz_always_200() {
let app = router(make_state());
let resp = app.oneshot(get("/healthz")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn readyz_503_when_no_route_live() {
let app = router(make_state());
let resp = app.oneshot(get("/readyz")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn readyz_200_when_a_route_is_live() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
store.set_health(HealthState::Live);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
let app = router(Arc::new(AppState::new(streams)));
let resp = app.oneshot(get("/readyz")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
fn metric_value(rendered: &str, metric: &str, must_contain: &[&str]) -> f64 {
rendered
.lines()
.find(|l| l.starts_with(metric) && must_contain.iter().all(|s| l.contains(s)))
.and_then(|l| l.rsplit(' ').next())
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0)
}
#[tokio::test]
async fn http_requests_total_counter_increases_on_requests() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
let mut streams = HashMap::new();
streams.insert(
"metrics-probe".to_string(),
(
store,
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
let state = Arc::new(AppState::new(streams));
let app = router(state.clone());
let labels = [
"route=\"metrics-probe\"",
"path=\"playlist\"",
"status=\"200\"",
];
let before = metric_value(
&state.metrics_handle.render(),
"multimux_http_requests_total",
&labels,
);
const REQUESTS: usize = 3;
for _ in 0..REQUESTS {
let resp = app
.clone()
.oneshot(get("/metrics-probe/master.m3u8"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
let after = metric_value(
&state.metrics_handle.render(),
"multimux_http_requests_total",
&labels,
);
assert_eq!(
after - before,
REQUESTS as f64,
"multimux_http_requests_total must increase by exactly the number of requests made"
);
}
async fn body_bytes(resp: Response) -> Vec<u8> {
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
fn assert_well_formed_xml(xml: &str) {
let mut stack: Vec<String> = Vec::new();
let mut rest = xml;
while let Some(start) = rest.find('<') {
let end = rest[start..]
.find('>')
.unwrap_or_else(|| panic!("unterminated tag starting at {:?}", &rest[start..]))
+ start;
let tag = &rest[start + 1..end];
rest = &rest[end + 1..];
if tag.starts_with('?') || tag.starts_with('!') {
continue;
}
if let Some(name) = tag.strip_prefix('/') {
let name = name.trim();
let opened = stack
.pop()
.unwrap_or_else(|| panic!("closing tag </{name}> with nothing open"));
assert_eq!(
opened, name,
"mismatched closing tag: opened <{opened}>, closed </{name}>"
);
continue;
}
let self_closing = tag.trim_end().ends_with('/');
let name = tag
.trim_end_matches('/')
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
if !self_closing {
stack.push(name);
}
}
assert!(stack.is_empty(), "unclosed tags remain: {stack:?}");
}
#[tokio::test]
async fn both_outputs_serve_from_shared_segments_and_mpd_resolves() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.publish_new_program(crate::route::SPTS_PROGRAM_ID);
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
store.set_track_specs(
crate::route::SPTS_PROGRAM_ID,
vec![transmux::TrackSpec::new(
9,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)],
);
store.add_segment(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::SegmentInfo {
bytes: vec![0x33; 16],
duration: 4.0,
segment_seq: 1,
part_count: 1,
},
);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![
Arc::new(LlHlsOutput::default()) as Arc<dyn Output>,
Arc::new(crate::output::dash::DashOutput) as Arc<dyn Output>,
],
),
);
let app = router(Arc::new(AppState::new(streams)));
let resp = app.clone().oneshot(get("/cam1/media.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let hls_body = body_string(resp).await;
assert!(hls_body.contains("#EXTM3U"));
assert!(hls_body.contains("seg-1-1.m4s"), "hls body: {hls_body}");
let resp = app
.clone()
.oneshot(get("/cam1/manifest.mpd"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap(),
"application/dash+xml"
);
let mpd_body = body_string(resp).await;
assert_well_formed_xml(&mpd_body);
assert!(mpd_body.contains("<MPD"), "{mpd_body}");
assert!(
mpd_body.contains(r#"xmlns="urn:mpeg:dash:schema:mpd:2011""#),
"{mpd_body}"
);
assert!(mpd_body.contains(r#"type="dynamic""#), "{mpd_body}");
assert!(mpd_body.contains("<Period"), "{mpd_body}");
assert!(mpd_body.contains("<AdaptationSet"), "{mpd_body}");
assert!(mpd_body.contains("<Representation"), "{mpd_body}");
assert!(mpd_body.contains("<SegmentTemplate"), "{mpd_body}");
assert!(mpd_body.contains(r#"startNumber="1""#), "{mpd_body}");
assert!(
mpd_body.contains("seg-$RepresentationID$-$Number$.m4s"),
"{mpd_body}"
);
let resolved_uri = "seg-1-1.m4s";
assert!(
hls_body.contains(resolved_uri),
"LL-HLS playlist must reference the same resolved filename: {hls_body}"
);
let resp = app
.oneshot(get(&format!("/cam1/{resolved_uri}")))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_eq!(body_bytes(resp).await, vec![0x33; 16]);
}
#[tokio::test]
async fn dash_only_route_has_no_llhls_routes() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.publish_new_program(crate::route::SPTS_PROGRAM_ID);
store.set_track_specs(
crate::route::SPTS_PROGRAM_ID,
vec![transmux::TrackSpec::new(
1,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)],
);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![Arc::new(crate::output::dash::DashOutput) as Arc<dyn Output>],
),
);
let app = router(Arc::new(AppState::new(streams)));
let resp = app
.clone()
.oneshot(get("/cam1/manifest.mpd"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn ll_dash_output_signals_and_resolves_alongside_dash_and_llhls() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.publish_new_program(crate::route::SPTS_PROGRAM_ID);
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
store.set_track_specs(
crate::route::SPTS_PROGRAM_ID,
vec![transmux::TrackSpec::new(
9,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)],
);
store.add_segment(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::SegmentInfo {
bytes: vec![0x33; 16],
duration: 4.0,
segment_seq: 1,
part_count: 2,
},
);
store.add_part(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::PartInfo {
bytes: vec![0x50; 4],
duration: 0.5,
independent: true,
segment_seq: 2,
part_index: 0,
},
);
store.add_part(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::PartInfo {
bytes: vec![0x51; 4],
duration: 0.5,
independent: false,
segment_seq: 2,
part_index: 1,
},
);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store.clone(),
vec![
Arc::new(LlHlsOutput::default()) as Arc<dyn Output>,
Arc::new(crate::output::dash::DashOutput) as Arc<dyn Output>,
Arc::new(crate::output::ll_dash::LlDashOutput) as Arc<dyn Output>,
],
),
);
let app = router(Arc::new(AppState::new(streams)));
let resp = app
.clone()
.oneshot(get("/cam1/manifest.mpd"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let dash_body = body_string(resp).await;
assert_well_formed_xml(&dash_body);
assert!(dash_body.contains("seg-$RepresentationID$-$Number$.m4s"));
let resp = app.clone().oneshot(get("/cam1/media.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let hls_body = body_string(resp).await;
assert!(hls_body.contains("#EXTM3U"));
let resp = app
.clone()
.oneshot(get("/cam1/manifest-ll.mpd"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap(),
"application/dash+xml"
);
let ll_body = body_string(resp).await;
assert_well_formed_xml(&ll_body);
assert!(ll_body.contains("<MPD"), "{ll_body}");
assert!(ll_body.contains(r#"type="dynamic""#), "{ll_body}");
assert!(
ll_body.contains("availabilityTimeOffset=\"3.5\""),
"{ll_body}"
);
assert!(
ll_body.contains("availabilityTimeComplete=\"false\""),
"{ll_body}"
);
assert!(ll_body.contains("<ServiceDescription"), "{ll_body}");
assert!(ll_body.contains("<Latency target="), "{ll_body}");
assert!(
ll_body.contains("seg-$RepresentationID$-$Number$.m4s"),
"LL-DASH addresses whole segments, exactly like manifest.mpd \
(parts are an internal chunked-transfer delivery detail, never \
addressed by the MPD itself): {ll_body}"
);
assert!(
!ll_body.contains("part-"),
"no part-addressed URI in the MPD: {ll_body}"
);
let store_for_close = store.clone();
let closer = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store_for_close.add_segment(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::SegmentInfo {
bytes: vec![0x99; 8], duration: 1.0,
segment_seq: 2,
part_count: 2,
},
);
});
let resp = app.oneshot(get("/cam1/seg-1-2.m4s")).await.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::OK,
"in-progress whole-segment request must stream, not 404"
);
let bytes = body_bytes(resp).await;
assert_eq!(
bytes,
[vec![0x50; 4], vec![0x51; 4]].concat(),
"streamed body must be the segment's parts concatenated in order"
);
closer.await.unwrap();
}
#[tokio::test]
async fn manifest_and_resource_responses_carry_expected_cache_control_and_cors() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.publish_new_program(crate::route::SPTS_PROGRAM_ID);
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
store.set_track_specs(
crate::route::SPTS_PROGRAM_ID,
vec![transmux::TrackSpec::new(
1,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)],
);
store.add_segment(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::SegmentInfo {
bytes: vec![0x33; 16],
duration: 4.0,
segment_seq: 1,
part_count: 1,
},
);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![
Arc::new(LlHlsOutput::default()) as Arc<dyn Output>,
Arc::new(crate::output::dash::DashOutput) as Arc<dyn Output>,
],
),
);
let app = router(Arc::new(AppState::new(streams)));
for (uri, expected_cache) in [
("/cam1/media.m3u8", CACHE_CONTROL_MANIFEST),
("/cam1/manifest.mpd", CACHE_CONTROL_MANIFEST),
("/cam1/seg-1-1.m4s", CACHE_CONTROL_IMMUTABLE),
] {
let resp = app.clone().oneshot(get(uri)).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK, "{uri}");
assert_eq!(
resp.headers()
.get(axum::http::header::CACHE_CONTROL)
.unwrap(),
expected_cache,
"{uri}"
);
assert_eq!(
resp.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"*",
"{uri}"
);
}
}
fn post_with_body(uri: &str, body: Vec<u8>) -> axum::http::Request<axum::body::Body> {
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header(axum::http::header::CONTENT_LENGTH, body.len().to_string())
.body(axum::body::Body::from(body))
.unwrap()
}
#[tokio::test]
async fn oversized_request_body_is_rejected_413() {
const TINY_LIMIT: usize = 8;
let app = router(Arc::new(AppState::new(make_state_streams()).with_limits(
HttpLimits {
max_request_body_bytes: TINY_LIMIT,
..HttpLimits::default()
},
)));
let resp = app
.oneshot(post_with_body(
"/cam1/master.m3u8",
vec![0u8; TINY_LIMIT + 1],
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn normal_request_still_succeeds_with_limits_applied() {
let app = router(Arc::new(AppState::new(make_state_streams())));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn request_body_within_limit_still_succeeds() {
const TINY_LIMIT: usize = 64;
let app = router(Arc::new(AppState::new(make_state_streams()).with_limits(
HttpLimits {
max_request_body_bytes: TINY_LIMIT,
..HttpLimits::default()
},
)));
let resp = app
.oneshot(post_with_body("/cam1/master.m3u8", vec![0u8; TINY_LIMIT]))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn global_timeout_layer_cuts_off_a_slow_blocking_request() {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.publish_new_program(crate::route::SPTS_PROGRAM_ID);
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
store.add_segment(
crate::route::SPTS_PROGRAM_ID,
transmux::ll_hls::SegmentInfo {
bytes: vec![0x20; 8],
duration: 4.0,
segment_seq: 1,
part_count: 1,
},
);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
let app = router(Arc::new(AppState::new(streams).with_limits(HttpLimits {
request_timeout: std::time::Duration::from_millis(50),
..HttpLimits::default()
})));
let started = std::time::Instant::now();
let resp = app
.oneshot(get("/cam1/media.m3u8?_HLS_msn=2&_HLS_part=0"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::REQUEST_TIMEOUT);
assert!(
started.elapsed() < std::time::Duration::from_secs(3),
"must be cut off by the 50ms configured timeout, not the 5s \
internal LL-HLS blocking-reload cap: {:?}",
started.elapsed()
);
}
fn make_state_streams() -> HashMap<String, StreamRoute> {
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
store.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 4]);
let mut streams = HashMap::new();
streams.insert(
"cam1".to_string(),
(
store,
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
streams
}
use broadcast_auth::{Credentials, RequestContext, respond};
fn get_with_auth(uri: &str, authorization: &str) -> axum::http::Request<axum::body::Body> {
axum::http::Request::builder()
.uri(uri)
.header(axum::http::header::AUTHORIZATION, authorization)
.body(axum::body::Body::empty())
.unwrap()
}
fn get_with_header(
uri: &str,
name: &str,
value: &str,
) -> axum::http::Request<axum::body::Body> {
axum::http::Request::builder()
.uri(uri)
.header(name, value)
.body(axum::body::Body::empty())
.unwrap()
}
fn basic_header(username: &str, password: &str) -> String {
use base64::Engine as _;
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"))
)
}
fn app_with_output_auth(verifier: Verifier) -> Router {
router(Arc::new(
AppState::new(make_state_streams()).with_output_auth(Arc::new(verifier)),
))
}
#[tokio::test]
async fn output_auth_basic_missing_creds_401_with_challenge() {
let app = app_with_output_auth(Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get(axum::http::header::WWW_AUTHENTICATE)
.expect("401 must carry WWW-Authenticate")
.to_str()
.unwrap();
assert!(challenge.starts_with("Basic realm="), "{challenge}");
}
#[tokio::test]
async fn output_auth_basic_correct_creds_200() {
let app = app_with_output_auth(Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
));
let resp = app
.oneshot(get_with_auth(
"/cam1/master.m3u8",
&basic_header("admin", "hunter2"),
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_basic_wrong_creds_401() {
let app = app_with_output_auth(Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
));
let resp = app
.oneshot(get_with_auth(
"/cam1/master.m3u8",
&basic_header("admin", "WRONG"),
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn output_auth_digest_missing_creds_401_with_challenge() {
let app = app_with_output_auth(Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get(axum::http::header::WWW_AUTHENTICATE)
.expect("401 must carry WWW-Authenticate")
.to_str()
.unwrap();
assert!(challenge.starts_with("Digest "), "{challenge}");
assert!(challenge.contains("nonce="), "{challenge}");
assert!(challenge.contains("qop=\"auth\""), "{challenge}");
}
#[tokio::test]
async fn output_auth_digest_correct_creds_200() {
let verifier = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
);
let challenge = verifier.challenge();
let app = router(Arc::new(
AppState::new(make_state_streams()).with_output_auth(Arc::new(verifier)),
));
let authorization = respond(
&challenge,
&RequestContext::new("GET", "/cam1/master.m3u8"),
Credentials::new("admin", "hunter2"),
)
.unwrap();
let resp = app
.oneshot(get_with_auth("/cam1/master.m3u8", &authorization))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_digest_wrong_creds_401() {
let verifier = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
);
let challenge = verifier.challenge();
let app = router(Arc::new(
AppState::new(make_state_streams()).with_output_auth(Arc::new(verifier)),
));
let authorization = respond(
&challenge,
&RequestContext::new("GET", "/cam1/master.m3u8"),
Credentials::new("admin", "WRONG"),
)
.unwrap();
let resp = app
.oneshot(get_with_auth("/cam1/master.m3u8", &authorization))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn output_auth_bearer_missing_creds_401() {
let app = app_with_output_auth(Verifier::new(
Credentials::bearer("secrettoken"),
OUTPUT_AUTH_REALM,
));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn output_auth_bearer_correct_token_200() {
let app = app_with_output_auth(Verifier::new(
Credentials::bearer("secrettoken"),
OUTPUT_AUTH_REALM,
));
let resp = app
.oneshot(get_with_auth("/cam1/master.m3u8", "Bearer secrettoken"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_bearer_wrong_token_401() {
let app = app_with_output_auth(Verifier::new(
Credentials::bearer("secrettoken"),
OUTPUT_AUTH_REALM,
));
let resp = app
.oneshot(get_with_auth("/cam1/master.m3u8", "Bearer WRONG"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn output_auth_configured_healthz_still_open() {
let app = app_with_output_auth(Verifier::new(
Credentials::bearer("secrettoken"),
OUTPUT_AUTH_REALM,
));
let resp = app.oneshot(get("/healthz")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_configured_metrics_still_open() {
let app = app_with_output_auth(Verifier::new(
Credentials::bearer("secrettoken"),
OUTPUT_AUTH_REALM,
));
let resp = app.oneshot(get("/metrics")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_none_stream_route_stays_open() {
let app = router(Arc::new(AppState::new(make_state_streams())));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_forwarded_with_user_header_200() {
let app = app_with_output_auth(Verifier::forwarded(
"X-Forwarded-User",
Some("X-Forwarded-For".to_string()),
));
let resp = app
.oneshot(get_with_header(
"/cam1/master.m3u8",
"X-Forwarded-User",
"alice",
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_forwarded_without_user_header_401() {
let app = app_with_output_auth(Verifier::forwarded(
"X-Forwarded-User",
Some("X-Forwarded-For".to_string()),
));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn output_auth_forwarded_empty_user_header_401() {
let app = app_with_output_auth(Verifier::forwarded(
"X-Forwarded-User",
Some("X-Forwarded-For".to_string()),
));
let resp = app
.oneshot(get_with_header("/cam1/master.m3u8", "X-Forwarded-User", ""))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn output_auth_forwarded_reads_x_forwarded_for() {
let verifier = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
let headers: &[(&str, &str)] = &[
("X-Forwarded-User", "alice"),
("X-Forwarded-For", "203.0.113.7"),
];
let ctx = RequestContext::new("GET", "/cam1/master.m3u8").with_headers(headers);
assert_eq!(verifier.forwarded_for(&ctx), Some("203.0.113.7"));
let app = app_with_output_auth(verifier);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/cam1/master.m3u8")
.header("X-Forwarded-User", "alice")
.header("X-Forwarded-For", "203.0.113.7")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_basic_digest_bearer_unaffected_by_forwarded_addition() {
let app = app_with_output_auth(Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "hunter2".into(),
},
OUTPUT_AUTH_REALM,
));
let resp = app
.oneshot(get_with_auth(
"/cam1/master.m3u8",
&basic_header("admin", "hunter2"),
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn output_auth_401_response_still_carries_cors_header() {
let app = app_with_output_auth(Verifier::new(
Credentials::bearer("secrettoken"),
OUTPUT_AUTH_REALM,
));
let resp = app.oneshot(get("/cam1/master.m3u8")).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"*"
);
}
#[tokio::test]
async fn serve_with_registry_unregistered_custom_input_tag_errors_not_panics() {
let cfg = crate::config::Config {
routes: vec![crate::config::Route {
name: "cam1".into(),
input: crate::config::InputSpec::Custom {
type_tag: "nope".into(),
params: serde_json::Value::Null,
},
outputs: vec![crate::output::OutputKind::LlHls],
dvr: DvrConfig::default(),
}],
bind: "127.0.0.1:0".into(),
..crate::config::Config::default()
};
let err = serve_with_registry(cfg, SchemeRegistry::new())
.await
.expect_err("an unregistered custom input tag must error, not silently succeed");
match err {
crate::MultimuxError::UnknownScheme { kind, tag } => {
assert_eq!(kind, "input");
assert_eq!(tag, "nope");
}
other => panic!("expected MultimuxError::UnknownScheme, got {other:?}"),
}
}
#[tokio::test]
async fn serve_with_registry_unregistered_custom_output_tag_errors_not_panics() {
let cfg = crate::config::Config {
routes: vec![crate::config::Route {
name: "cam1".into(),
input: crate::config::InputSpec::Rtsp {
url: "rtsp://host/stream".into(),
auth: None,
},
outputs: vec![crate::output::OutputKind::Custom {
type_tag: "webrtc".into(),
params: serde_json::Value::Null,
}],
dvr: DvrConfig::default(),
}],
bind: "127.0.0.1:0".into(),
..crate::config::Config::default()
};
let err = serve_with_registry(cfg, SchemeRegistry::new())
.await
.expect_err("an unregistered custom output tag must error, not silently succeed");
match err {
crate::MultimuxError::UnknownScheme { kind, tag } => {
assert_eq!(kind, "output");
assert_eq!(tag, "webrtc");
}
other => panic!("expected MultimuxError::UnknownScheme, got {other:?}"),
}
}
#[tokio::test]
async fn serve_with_registry_unregistered_custom_auth_tag_errors_not_panics() {
let cfg = crate::config::Config {
routes: vec![crate::config::Route {
name: "cam1".into(),
input: crate::config::InputSpec::Rtsp {
url: "rtsp://host/stream".into(),
auth: None,
},
outputs: vec![crate::output::OutputKind::LlHls],
dvr: DvrConfig::default(),
}],
bind: "127.0.0.1:0".into(),
output_auth: Some(crate::config::OutputAuthSpec::Custom {
type_tag: "hmac".into(),
params: serde_json::Value::Null,
}),
..crate::config::Config::default()
};
let err = serve_with_registry(cfg, SchemeRegistry::new())
.await
.expect_err("an unregistered custom auth tag must error, not silently succeed");
match err {
crate::MultimuxError::UnknownScheme { kind, tag } => {
assert_eq!(kind, "auth");
assert_eq!(tag, "hmac");
}
other => panic!("expected MultimuxError::UnknownScheme, got {other:?}"),
}
}
#[tokio::test]
async fn registered_custom_input_factory_runs_against_a_real_input_ctx() {
let mut registry = SchemeRegistry::new();
registry.register_input(
"silence",
Arc::new(|ctx: crate::registry::InputCtx| {
assert_eq!(
ctx.params.get("marker").and_then(|v| v.as_str()),
Some("ok")
);
ctx.store.set_health(HealthState::Live);
Ok(tokio::spawn(async move {
let mut rx = ctx.shutdown_rx;
let _ = rx.changed().await;
}))
}),
);
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
let factory = registry.input("silence").expect("factory registered above");
let handle = factory(crate::registry::InputCtx {
name: "cam1".into(),
params: serde_json::json!({"marker": "ok"}),
store: store.clone(),
target_duration_secs: 4.0,
part_target_ms: 500,
shutdown_rx,
})
.expect("factory must succeed");
let became_live = tokio::time::timeout(Duration::from_secs(60), async {
loop {
if store.health() == HealthState::Live {
break;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.is_ok();
assert!(became_live, "factory-spawned task must reach the store");
handle.abort();
}
#[tokio::test]
async fn ts_udp_input_ingests_and_becomes_resolvable_through_the_registry() {
let reserved = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("reserve port");
let addr = reserved.local_addr().expect("local addr");
drop(reserved);
let cfg = crate::config::Config {
routes: vec![crate::config::Route {
name: "cam1".into(),
input: crate::config::InputSpec::TsUdp {
addr: addr.to_string(),
multicast_group: None,
},
outputs: vec![crate::output::OutputKind::LlHls],
dvr: DvrConfig::default(),
}],
bind: "127.0.0.1:0".into(),
..crate::config::Config::default()
};
let store = Arc::new(RouteHandle::new(4.0, 500, 4));
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = spawn_ingest(
&cfg.routes[0],
store.clone(),
&cfg,
&SchemeRegistry::new(),
shutdown_rx,
)
.expect("spawn_ingest must accept a TsUdp route");
let ts_bytes = crate::source::ts_program::test_support::build_ts_bytes(1, 0xAB, 60);
let sender = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind sender");
let send_task = tokio::spawn(async move {
loop {
for chunk in ts_bytes.chunks(7 * 188) {
let _ = sender.send_to(chunk, addr).await;
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
});
let resolved = tokio::time::timeout(Duration::from_secs(60), async {
loop {
if matches!(
store.resolve_program(crate::route::SPTS_PROGRAM_ID),
crate::route::ProgramResolution::Found(_)
) {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.is_ok();
assert!(
resolved,
"a TsUdp route must actually ingest and publish its program into the registry"
);
send_task.abort();
handle.abort();
}
fn minimal_rtp_sdp() -> String {
"v=0\r\n\
o=- 0 0 IN IP4 127.0.0.1\r\n\
s=-\r\n\
t=0 0\r\n\
m=video 0 RTP/AVP 96\r\n\
a=rtpmap:96 H264/90000\r\n\
a=control:streamid=0\r\n"
.to_string()
}
fn every_input_spec_variant_is_named_here(spec: &crate::config::InputSpec) -> bool {
match spec {
crate::config::InputSpec::Rtsp { .. } => true,
crate::config::InputSpec::Rtp { .. } => true,
crate::config::InputSpec::TsUdp { .. } => true,
crate::config::InputSpec::TsHttp { .. } => true,
crate::config::InputSpec::Srt { .. } => true,
crate::config::InputSpec::HlsPull { .. } => true,
crate::config::InputSpec::DashPull { .. } => true,
crate::config::InputSpec::SmoothPull { .. } => true,
crate::config::InputSpec::Rtmp { .. } => true,
crate::config::InputSpec::Custom { .. } => true,
}
}
#[tokio::test]
async fn every_input_spec_variant_dispatches_to_real_ingest_not_a_stub() {
let config = crate::config::Config {
ingest_connect_timeout_secs: 0.2,
ingest_read_timeout_secs: 0.2,
..crate::config::Config::default()
};
let refused = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve tcp port");
let refused_addr = refused.local_addr().expect("local addr");
drop(refused);
let reserve_udp = || {
let s = std::net::UdpSocket::bind("127.0.0.1:0").expect("reserve udp port");
let addr = s.local_addr().expect("local addr");
drop(s);
addr
};
let quiet_rtp_addr = reserve_udp();
let quiet_ts_udp_addr = reserve_udp();
let quiet_srt_addr = reserve_udp();
let rtmp_thief =
std::net::TcpListener::bind("127.0.0.1:0").expect("steal an rtmp listen port");
let rtmp_addr = rtmp_thief.local_addr().expect("local addr");
let variants: Vec<crate::config::InputSpec> = vec![
crate::config::InputSpec::Rtsp {
url: format!("rtsp://{refused_addr}/x"),
auth: None,
},
crate::config::InputSpec::Rtp {
addr: quiet_rtp_addr.to_string(),
sdp: minimal_rtp_sdp(),
multicast_group: None,
},
crate::config::InputSpec::TsUdp {
addr: quiet_ts_udp_addr.to_string(),
multicast_group: None,
},
crate::config::InputSpec::TsHttp {
url: format!("http://{refused_addr}/x"),
auth: None,
},
crate::config::InputSpec::Srt {
listen: None,
remote: Some(quiet_srt_addr.to_string()),
stream_id: None,
latency_ms: None,
},
crate::config::InputSpec::HlsPull {
url: format!("http://{refused_addr}/x"),
auth: None,
},
crate::config::InputSpec::DashPull {
url: format!("http://{refused_addr}/x"),
auth: None,
},
crate::config::InputSpec::SmoothPull {
url: format!("http://{refused_addr}/x"),
auth: None,
},
crate::config::InputSpec::Rtmp {
listen: rtmp_addr.to_string(),
app: None,
stream_key: None,
},
];
assert_eq!(
variants.len(),
9,
"every non-Custom InputSpec kind must be represented exactly once"
);
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
let registry = SchemeRegistry::new();
for spec in variants {
assert!(
every_input_spec_variant_is_named_here(&spec),
"compile-time net: every match arm returns true"
);
let route = crate::config::Route {
name: "cam".into(),
input: spec.clone(),
outputs: vec![crate::output::OutputKind::LlHls],
dvr: DvrConfig::default(),
};
let store = Arc::new(RouteHandle::new(
config.target_duration_secs,
config.part_target_ms,
config.window_segments,
));
let handle = spawn_ingest(
&route,
store.clone(),
&config,
®istry,
shutdown_rx.clone(),
)
.unwrap_or_else(|e| panic!("spawn_ingest must accept {spec:?}, got {e}"));
assert_eq!(
store.health(),
HealthState::Connecting,
"variant {spec:?} must start Connecting"
);
let reached_reconnecting = tokio::time::timeout(Duration::from_secs(60), async {
loop {
if store.health() == HealthState::Reconnecting {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.is_ok();
assert!(
reached_reconnecting,
"variant {spec:?} never left HealthState::Connecting within the hang guard \
(still {:?}) -- looks like a stubbed dispatch arm",
store.health()
);
assert_eq!(
store.health(),
HealthState::Reconnecting,
"variant {spec:?} must be Reconnecting after its first failed attempt"
);
handle.abort();
}
drop(rtmp_thief);
}
#[test]
fn rtmp_url_splits_app_and_stream_key() {
assert_eq!(
rtmp_app_and_stream_key("rtmp://host/app/streamkey"),
("app".to_string(), "streamkey".to_string()),
"two path segments: app + stream key"
);
assert_eq!(
rtmp_app_and_stream_key("rtmp://host:1935/live/mystream"),
("live".to_string(), "mystream".to_string()),
"with an explicit port"
);
assert_eq!(
rtmp_app_and_stream_key("rtmp://host/app/instance/streamkey"),
("app/instance".to_string(), "streamkey".to_string()),
"multi-segment app: everything but the last segment"
);
assert_eq!(
rtmp_app_and_stream_key("rtmp://host/app"),
("app".to_string(), String::new()),
"one segment only: no stream key given"
);
assert_eq!(
rtmp_app_and_stream_key("rtmp://host"),
("live".to_string(), String::new()),
"no path at all: falls back to the pre-existing \"live\" default"
);
assert_eq!(
rtmp_app_and_stream_key("not a url"),
("live".to_string(), String::new()),
"unparseable URL: same fallback, never panics"
);
}
}