pub(crate) mod resource;
pub mod supervisor;
use std::collections::HashMap;
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::store::{HealthState, MediaStore};
use supervisor::{Backoff, supervise};
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<MediaStore>, 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))
}
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)),
}
}
pub async fn serve(config: crate::config::Config) -> crate::Result<()> {
serve_with_registry(config, SchemeRegistry::new()).await
}
pub async fn serve_with_registry(
config: crate::config::Config,
registry: SchemeRegistry,
) -> crate::Result<()> {
config.validate()?;
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 mut supervisor_handles: Vec<(String, tokio::task::JoinHandle<()>)> = Vec::new();
for route in &config.routes {
let store = Arc::new(MediaStore::new(
target_duration_secs,
part_target_ms,
config.window_segments,
));
let outputs: Vec<Arc<dyn Output>> = route
.outputs
.iter()
.map(|k| build_output(k, &config.playlist_name, ®istry))
.collect::<crate::Result<Vec<_>>>()?;
streams.insert(route.name.clone(), (store.clone(), outputs));
let name = route.name.clone();
let shutdown_rx = shutdown_rx.clone();
let handle = match &route.input {
crate::config::InputSpec::Rtsp { url, auth } => {
let connector = crate::source::rtsp::RtspSource::new(name.clone(), url.clone())
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials));
tokio::spawn(supervise(
connector,
store,
target_duration_secs,
part_target_ms,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::Rtp {
addr,
sdp,
multicast_group,
} => {
let connector = crate::source::rtp_udp::RtpUdpSource::new(
name.clone(),
addr.clone(),
sdp.clone(),
multicast_group.clone(),
);
tokio::spawn(supervise(
connector,
store,
target_duration_secs,
part_target_ms,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::TsUdp {
addr,
multicast_group,
} => {
let connector = crate::source::ts_udp::TsUdpSource::new(
name.clone(),
addr.clone(),
multicast_group.clone(),
);
tokio::spawn(supervise(
connector,
store,
target_duration_secs,
part_target_ms,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::TsHttp { url, auth } => {
let connector =
crate::source::ts_http::TsHttpSource::new(name.clone(), url.clone())
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials));
tokio::spawn(supervise(
connector,
store,
target_duration_secs,
part_target_ms,
Backoff::production_default(),
name.clone(),
shutdown_rx,
))
}
crate::config::InputSpec::HlsPull { url, auth } => {
let connector =
crate::source::hls_pull::HlsPullSource::new(name.clone(), url.clone())
.with_auth(auth.as_ref().map(crate::config::AuthSpec::to_credentials));
tokio::spawn(supervise(
connector,
store,
target_duration_secs,
part_target_ms,
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,
})?
}
};
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 = match output_auth {
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: OUTPUT_AUTH_REALM,
})?
}
builtin => builtin.build_verifier(OUTPUT_AUTH_REALM),
};
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");
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(())
}
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::output::llhls::LlHlsOutput;
use crate::store::MediaStore;
use tower::ServiceExt;
fn make_state() -> Arc<AppState> {
let store = Arc::new(MediaStore::new(4.0, 500, 4));
store.set_init(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(MediaStore::new(4.0, 500, 4));
store.set_init(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(MediaStore::new(4.0, 500, 4));
store.set_init(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(MediaStore::new(4.0, 500, 4));
store.set_init(vec![0xAA; 4]);
store.set_track_specs(vec![transmux::TrackSpec::new(
9,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)]);
store.add_segment(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(MediaStore::new(4.0, 500, 4));
store.set_track_specs(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(MediaStore::new(4.0, 500, 4));
store.set_init(vec![0xAA; 4]);
store.set_track_specs(vec![transmux::TrackSpec::new(
9,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)]);
store.add_segment(transmux::ll_hls::SegmentInfo {
bytes: vec![0x33; 16],
duration: 4.0,
segment_seq: 1,
part_count: 2,
});
store.add_part(transmux::ll_hls::PartInfo {
bytes: vec![0x50; 4],
duration: 0.5,
independent: true,
segment_seq: 2,
part_index: 0,
});
store.add_part(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(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(MediaStore::new(4.0, 500, 4));
store.set_init(vec![0xAA; 4]);
store.set_track_specs(vec![transmux::TrackSpec::new(
1,
90_000,
transmux::CodecConfig::Vp8 {
width: 640,
height: 480,
},
)]);
store.add_segment(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(MediaStore::new(4.0, 500, 4));
store.set_init(vec![0xAA; 4]);
store.add_segment(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(1),
"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(MediaStore::new(4.0, 500, 4));
store.set_init(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],
}],
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,
}],
}],
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],
}],
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(MediaStore::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(1), 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();
}
}