use std::collections::HashMap;
use std::future::Future;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::Router;
use axum::extract::{ConnectInfo, Path as AxumPath, Request, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, serve::IncomingStream};
use broadcast_auth::{AuthResult, Verifier};
use tokio::sync::watch;
use tower::Service;
use crate::config::{Config, Route};
use crate::output::{Output, OutputKind};
use crate::registry::SchemeRegistry;
use crate::route::RouteHandle;
use super::{AppState, HttpLimits, SUPERVISOR_SHUTDOWN_GRACE, StreamRoute, build_output, router};
const ADMIN_AUTH_REALM: &str = "multimux-admin";
struct RouteRuntime {
route: Route,
store: Arc<RouteHandle>,
outputs: Vec<Arc<dyn Output>>,
shutdown_tx: watch::Sender<bool>,
handle: tokio::task::JoinHandle<()>,
push_cancel: tokio_util::sync::CancellationToken,
push_handles: Vec<tokio::task::JoinHandle<()>>,
}
struct RegistryContext {
base_config: Config,
scheme_registry: SchemeRegistry,
http_limits: HttpLimits,
output_auth: Option<Arc<Verifier>>,
}
pub(crate) struct RouteRegistry {
ctx: RegistryContext,
inner: std::sync::RwLock<HashMap<String, RouteRuntime>>,
router_slot: std::sync::RwLock<Router>,
config_path: Option<PathBuf>,
}
impl RouteRegistry {
fn new(ctx: RegistryContext, config_path: Option<PathBuf>) -> Arc<Self> {
let registry = Arc::new(RouteRegistry {
ctx,
inner: std::sync::RwLock::new(HashMap::new()),
router_slot: std::sync::RwLock::new(Router::new()),
config_path,
});
registry.rebuild_router();
registry
}
fn streams_snapshot(&self) -> HashMap<String, StreamRoute> {
self.inner
.read()
.expect("RouteRegistry::inner lock poisoned")
.iter()
.map(|(name, rt)| (name.clone(), (Arc::clone(&rt.store), rt.outputs.clone())))
.collect()
}
fn rebuild_router(&self) {
let streams = self.streams_snapshot();
let mut app_state = AppState::new(streams).with_limits(self.ctx.http_limits);
if let Some(verifier) = &self.ctx.output_auth {
app_state = app_state.with_output_auth(Arc::clone(verifier));
}
let new_router = router(Arc::new(app_state));
*self
.router_slot
.write()
.expect("RouteRegistry::router_slot lock poisoned") = new_router;
}
pub(crate) fn current_router(&self) -> Router {
self.router_slot
.read()
.expect("RouteRegistry::router_slot lock poisoned")
.clone()
}
fn spawn_route(&self, route: &Route) -> crate::Result<RouteRuntime> {
let outputs: Vec<Arc<dyn Output>> = route
.outputs
.iter()
.filter(|k| !k.is_push() && !k.is_whep())
.map(|k| {
build_output(
k,
&self.ctx.base_config.playlist_name,
&self.ctx.scheme_registry,
)
})
.collect::<crate::Result<Vec<_>>>()?;
let store = Arc::new(
RouteHandle::new(
self.ctx.base_config.target_duration_secs,
self.ctx.base_config.part_target_ms,
self.ctx.base_config.window_segments,
)
.with_name(route.name.clone())
.with_container(super::route_container(route))
.with_dvr(route.dvr.clone()),
);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let push_cancel = tokio_util::sync::CancellationToken::new();
let push_handles = super::spawn_push_outputs(route, Arc::clone(&store), &push_cancel);
let handle = super::spawn_ingest(
route,
Arc::clone(&store),
&self.ctx.base_config,
&self.ctx.scheme_registry,
shutdown_rx,
)?;
Ok(RouteRuntime {
route: route.clone(),
store,
outputs,
shutdown_tx,
handle,
push_cancel,
push_handles,
})
}
pub(crate) fn add_route(&self, route: Route) -> crate::Result<RouteStatus> {
route.validate_standalone()?;
let mut guard = self
.inner
.write()
.expect("RouteRegistry::inner lock poisoned");
if guard.contains_key(&route.name) {
return Err(crate::MultimuxError::RouteExists { name: route.name });
}
let runtime = self.spawn_route(&route)?;
let status = RouteStatus::from_runtime(&runtime);
guard.insert(route.name.clone(), runtime);
drop(guard);
self.rebuild_router();
Ok(status)
}
pub(crate) async fn remove_route(&self, name: &str) -> crate::Result<()> {
let removed = {
let mut guard = self
.inner
.write()
.expect("RouteRegistry::inner lock poisoned");
guard.remove(name)
};
let Some(runtime) = removed else {
return Err(crate::MultimuxError::RouteNotFound {
name: name.to_string(),
});
};
self.rebuild_router();
drain_route(runtime).await;
Ok(())
}
pub(crate) fn list_routes(&self) -> Vec<RouteStatus> {
self.inner
.read()
.expect("RouteRegistry::inner lock poisoned")
.values()
.map(RouteStatus::from_runtime)
.collect()
}
pub(crate) fn get_route(&self, name: &str) -> Option<RouteStatus> {
self.inner
.read()
.expect("RouteRegistry::inner lock poisoned")
.get(name)
.map(RouteStatus::from_runtime)
}
pub(crate) async fn reload(&self) -> crate::Result<ReloadSummary> {
let Some(path) = self.config_path.clone() else {
return Err(crate::MultimuxError::ConfigInvalid {
field: "admin.reload",
reason: "no config file path known for this process (config was not loaded via \
a `serve_config_file*` entry point)"
.into(),
});
};
let new_config = Config::from_json_file(&path)?;
let current: HashMap<String, Route> = {
self.inner
.read()
.expect("RouteRegistry::inner lock poisoned")
.iter()
.map(|(name, rt)| (name.clone(), rt.route.clone()))
.collect()
};
let mut added_routes: Vec<Route> = Vec::new();
let mut changed_routes: Vec<Route> = Vec::new();
let mut removed_names: Vec<String> = Vec::new();
let mut unchanged_names: Vec<String> = Vec::new();
let new_by_name: HashMap<&str, &Route> = new_config
.routes
.iter()
.map(|r| (r.name.as_str(), r))
.collect();
for name in current.keys() {
if !new_by_name.contains_key(name.as_str()) {
removed_names.push(name.clone());
}
}
for route in &new_config.routes {
match current.get(&route.name) {
None => added_routes.push(route.clone()),
Some(existing) if existing == route => unchanged_names.push(route.name.clone()),
Some(_) => changed_routes.push(route.clone()),
}
}
for route in added_routes.iter().chain(changed_routes.iter()) {
route.validate_standalone()?;
}
let mut prepared: Vec<(Route, RouteRuntime)> = Vec::new();
for route in added_routes.iter().chain(changed_routes.iter()) {
match self.spawn_route(route) {
Ok(runtime) => prepared.push((route.clone(), runtime)),
Err(e) => {
for (_, runtime) in prepared {
let _ = runtime.shutdown_tx.send(true);
runtime.handle.abort();
}
return Err(e);
}
}
}
let removed_runtimes: Vec<RouteRuntime> = {
let mut guard = self
.inner
.write()
.expect("RouteRegistry::inner lock poisoned");
let mut removed_runtimes = Vec::new();
for name in removed_names
.iter()
.chain(changed_routes.iter().map(|r| &r.name))
{
if let Some(rt) = guard.remove(name) {
removed_runtimes.push(rt);
}
}
for (route, runtime) in prepared {
guard.insert(route.name.clone(), runtime);
}
removed_runtimes
};
self.rebuild_router();
futures_util::future::join_all(removed_runtimes.into_iter().map(drain_route)).await;
Ok(ReloadSummary {
added: added_routes.into_iter().map(|r| r.name).collect(),
removed: removed_names,
changed: changed_routes.into_iter().map(|r| r.name).collect(),
unchanged: unchanged_names,
})
}
async fn shutdown_all(&self) {
let all: Vec<RouteRuntime> = {
let mut guard = self
.inner
.write()
.expect("RouteRegistry::inner lock poisoned");
guard.drain().map(|(_, rt)| rt).collect()
};
futures_util::future::join_all(all.into_iter().map(drain_route)).await;
}
}
async fn drain_route(runtime: RouteRuntime) {
runtime.push_cancel.cancel();
let _ = runtime.shutdown_tx.send(true);
let abort_handle = runtime.handle.abort_handle();
if tokio::time::timeout(SUPERVISOR_SHUTDOWN_GRACE, runtime.handle)
.await
.is_err()
{
tracing::warn!(
"admin: route supervisor task did not exit within the shutdown grace period; \
aborting"
);
abort_handle.abort();
}
for h in runtime.push_handles {
h.abort();
}
}
#[derive(Debug, serde::Serialize)]
pub struct RouteStatus {
pub name: String,
pub input_kind: &'static str,
pub outputs: Vec<OutputKind>,
pub health: String,
pub created_at_unix_nanos: u128,
}
impl RouteStatus {
fn from_runtime(rt: &RouteRuntime) -> Self {
RouteStatus {
name: rt.route.name.clone(),
input_kind: input_kind_name(&rt.route.input),
outputs: rt.route.outputs.clone(),
health: rt.store.health().name().to_string(),
created_at_unix_nanos: rt
.store
.created_at()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
}
}
}
fn input_kind_name(input: &crate::config::InputSpec) -> &'static str {
use crate::config::InputSpec::*;
match input {
Rtsp { .. } => "rtsp",
Rtp { .. } => "rtp",
TsUdp { .. } => "ts_udp",
TsHttp { .. } => "ts_http",
HlsPull { .. } => "hls_pull",
DashPull { .. } => "dash_pull",
SmoothPull { .. } => "smooth_pull",
Rtmp { .. } => "rtmp",
#[cfg(feature = "whip")]
Whip { .. } => "whip",
Srt { .. } => "srt",
Custom { .. } => "custom",
File { .. } => "file",
}
}
#[derive(Debug, serde::Serialize)]
pub struct ReloadSummary {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<String>,
pub unchanged: Vec<String>,
}
#[derive(Debug, serde::Serialize)]
struct ErrorBody {
error: String,
}
fn error_response(status: StatusCode, message: String) -> Response {
(status, Json(ErrorBody { error: message })).into_response()
}
fn status_for_error(e: &crate::MultimuxError) -> StatusCode {
match e {
crate::MultimuxError::RouteExists { .. } => StatusCode::CONFLICT,
crate::MultimuxError::RouteNotFound { .. } => StatusCode::NOT_FOUND,
crate::MultimuxError::ConfigInvalid { .. } | crate::MultimuxError::UnknownScheme { .. } => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[derive(Clone)]
struct AdminState {
registry: Arc<RouteRegistry>,
}
async fn list_routes_handler(State(state): State<AdminState>) -> Response {
Json(state.registry.list_routes()).into_response()
}
async fn get_route_handler(
State(state): State<AdminState>,
AxumPath(name): AxumPath<String>,
) -> Response {
match state.registry.get_route(&name) {
Some(status) => Json(status).into_response(),
None => error_response(StatusCode::NOT_FOUND, format!("no such route {name:?}")),
}
}
async fn add_route_handler(
State(state): State<AdminState>,
body: Result<Json<Route>, axum::extract::rejection::JsonRejection>,
) -> Response {
let route = match body {
Ok(Json(route)) => route,
Err(rejection) => return error_response(StatusCode::BAD_REQUEST, rejection.body_text()),
};
match state.registry.add_route(route) {
Ok(status) => (StatusCode::CREATED, Json(status)).into_response(),
Err(e) => error_response(status_for_error(&e), e.to_string()),
}
}
async fn delete_route_handler(
State(state): State<AdminState>,
AxumPath(name): AxumPath<String>,
) -> Response {
match state.registry.remove_route(&name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(status_for_error(&e), e.to_string()),
}
}
async fn reload_handler(State(state): State<AdminState>) -> Response {
match state.registry.reload().await {
Ok(summary) => Json(summary).into_response(),
Err(e) => error_response(status_for_error(&e), e.to_string()),
}
}
async fn admin_auth_gate(
State(verifier): State<Arc<Verifier>>,
req: Request,
next: Next,
) -> Response {
let method = req.method().as_str().to_string();
let uri = req
.uri()
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| req.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::<ConnectInfo<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);
}
match verifier.verify(&ctx) {
AuthResult::Ok => next.run(req).await,
_ => {
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
}
}
}
pub(crate) fn admin_router(registry: Arc<RouteRegistry>, verifier: Arc<Verifier>) -> Router {
Router::new()
.route(
"/admin/routes",
get(list_routes_handler).post(add_route_handler),
)
.route(
"/admin/routes/:name",
get(get_route_handler).delete(delete_route_handler),
)
.route("/admin/reload", post(reload_handler))
.with_state(AdminState { registry })
.layer(middleware::from_fn_with_state(verifier, admin_auth_gate))
}
#[derive(Clone)]
struct DynamicMediaService {
registry: Arc<RouteRegistry>,
remote_addr: SocketAddr,
}
impl Service<Request> for DynamicMediaService {
type Response = Response;
type Error = std::convert::Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Response, std::convert::Infallible>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, mut req: Request) -> Self::Future {
req.extensions_mut().insert(ConnectInfo(self.remote_addr));
let mut router = self.registry.current_router();
Box::pin(async move { router.call(req).await })
}
}
#[derive(Clone)]
struct MediaMakeService {
registry: Arc<RouteRegistry>,
}
impl<'a> Service<IncomingStream<'a>> for MediaMakeService {
type Response = DynamicMediaService;
type Error = std::convert::Infallible;
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, stream: IncomingStream<'a>) -> Self::Future {
std::future::ready(Ok(DynamicMediaService {
registry: Arc::clone(&self.registry),
remote_addr: stream.remote_addr(),
}))
}
}
pub(crate) async fn serve_with_admin(
config: Config,
scheme_registry: SchemeRegistry,
config_path: Option<PathBuf>,
) -> crate::Result<()> {
let admin_spec = config
.admin
.clone()
.expect("serve_with_admin is only ever called when config.admin.is_some()");
tracing::info!(
bind = %config.bind,
admin_bind = %admin_spec.bind,
routes = config.routes.len(),
"multimux origin starting (runtime admin API enabled)"
);
let admin_verifier = Arc::new(super::resolve_verifier(
&admin_spec.auth,
ADMIN_AUTH_REALM,
&scheme_registry,
)?);
let output_auth = match &config.output_auth {
Some(spec) => Some(Arc::new(super::resolve_verifier(
spec,
super::OUTPUT_AUTH_REALM,
&scheme_registry,
)?)),
None => None,
};
let http_limits = HttpLimits::from(&config);
let base_config = Config {
routes: Vec::new(),
admin: None,
..config.clone()
};
let ctx = RegistryContext {
base_config,
scheme_registry: scheme_registry.clone(),
http_limits,
output_auth,
};
let registry = RouteRegistry::new(ctx, config_path);
for route in &config.routes {
registry.add_route(route.clone())?;
}
let media_listener = tokio::net::TcpListener::bind(config.bind.as_str()).await?;
let admin_listener = tokio::net::TcpListener::bind(admin_spec.bind.as_str()).await?;
let (external_shutdown_tx, external_shutdown_rx) = watch::channel(false);
let shutdown_task = tokio::spawn(async move {
super::shutdown_signal().await;
tracing::info!("shutdown signal received, draining");
let _ = external_shutdown_tx.send(true);
});
let mut media_rx = external_shutdown_rx.clone();
let media_shutdown = async move {
let _ = media_rx.changed().await;
};
let mut admin_rx = external_shutdown_rx.clone();
let admin_shutdown = async move {
let _ = admin_rx.changed().await;
};
let admin_built = admin_router(Arc::clone(®istry), admin_verifier);
let admin_task: tokio::task::JoinHandle<std::io::Result<()>> = tokio::spawn(async move {
axum::serve(
admin_listener,
admin_built.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(admin_shutdown)
.await
});
let media_make_service = MediaMakeService {
registry: Arc::clone(®istry),
};
let media_result = axum::serve(media_listener, media_make_service)
.with_graceful_shutdown(media_shutdown)
.await;
shutdown_task.abort();
let admin_abort_handle = admin_task.abort_handle();
match tokio::time::timeout(SUPERVISOR_SHUTDOWN_GRACE, admin_task).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(e))) => tracing::warn!(error = %e, "admin HTTP server exited with an error"),
Ok(Err(e)) => tracing::warn!(error = %e, "admin HTTP server task panicked"),
Err(_) => {
tracing::warn!(
"admin HTTP server did not exit within the shutdown grace period; aborting"
);
admin_abort_handle.abort();
}
}
registry.shutdown_all().await;
media_result?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::InputSpec;
use crate::dvr::DvrConfig;
fn ctx_for_tests() -> RegistryContext {
RegistryContext {
base_config: Config::default(),
scheme_registry: SchemeRegistry::new(),
http_limits: HttpLimits::default(),
output_auth: None,
}
}
#[cfg(feature = "whep")]
#[tokio::test]
async fn adding_a_route_with_a_whep_output_does_not_poison_the_registry() {
let registry = RouteRegistry::new(ctx_for_tests(), None);
let mut route = rtsp_route("cam", "rtsp://127.0.0.1:554/s");
route.outputs.push(OutputKind::Whep {
listen: "127.0.0.1:0".to_string(),
});
let _ = registry.add_route(route);
let _routes = registry.list_routes();
}
fn rtsp_route(name: &str, url: &str) -> Route {
Route {
name: name.to_string(),
input: InputSpec::Rtsp {
url: url.to_string(),
auth: None,
},
outputs: vec![OutputKind::LlHls],
dvr: DvrConfig::default(),
}
}
#[tokio::test]
async fn add_duplicate_name_is_conflict_and_original_is_untouched() {
let registry = RouteRegistry::new(ctx_for_tests(), None);
let first = registry
.add_route(rtsp_route("cam1", "rtsp://host/a"))
.expect("first add succeeds");
let err = registry
.add_route(rtsp_route("cam1", "rtsp://host/b"))
.expect_err("duplicate name must be rejected");
assert!(matches!(err, crate::MultimuxError::RouteExists { name } if name == "cam1"));
let still_there = registry.get_route("cam1").expect("route still registered");
assert_eq!(
still_there.created_at_unix_nanos, first.created_at_unix_nanos,
"the original route's RouteHandle must not have been rebuilt"
);
}
#[tokio::test]
async fn remove_unknown_route_is_not_found() {
let registry = RouteRegistry::new(ctx_for_tests(), None);
let err = registry
.remove_route("nope")
.await
.expect_err("unknown route must 404-map");
assert!(matches!(err, crate::MultimuxError::RouteNotFound { name } if name == "nope"));
}
#[test]
fn add_route_rejects_invalid_route_before_mutating() {
let registry = RouteRegistry::new(ctx_for_tests(), None);
let bad = Route {
name: "cam1".to_string(),
input: InputSpec::Rtsp {
url: "rtsp://host/a".to_string(),
auth: None,
},
outputs: Vec::new(),
dvr: DvrConfig::default(),
};
let err = registry
.add_route(bad)
.expect_err("empty outputs must be rejected");
assert!(matches!(err, crate::MultimuxError::ConfigInvalid { .. }));
assert!(
registry.list_routes().is_empty(),
"a rejected add must leave the registry empty"
);
}
}