Skip to main content

moq_srt/
listen.rs

1//! SRT listener configuration and the unauthenticated `run` convenience.
2//!
3//! SRT is a thin reliability/encryption layer over a UDP datagram stream whose
4//! payload, by overwhelming convention, is MPEG-TS. [`run`] drives a [`Server`]:
5//! it accepts every connection and routes each by its stream-id `m=` mode, in one
6//! of two directions:
7//!
8//! - `m=publish` (the default): ingest. Pump the caller's TS payload into the
9//!   origin as a broadcast.
10//! - `m=request`: egress. Re-mux the requested broadcast back to MPEG-TS and
11//!   stream it to the caller, so VLC / ffmpeg can play
12//!   `srt://host:port?streamid=#!::r=<broadcast>,m=request`.
13//!
14//! Routing: SRT's recommended stream-id form is `#!::r=<resource>,m=<mode>`. The
15//! `r=` resource (or the raw stream id, for OBS-style clients) names the
16//! broadcast, and the optional [`prefix`](Config::prefix) is prepended so a
17//! single listener can namespace all of its streams (e.g. prefix `live/` + stream
18//! id `cam0` -> broadcast `live/cam0`).
19//!
20//! Auth: [`run`] is unauthenticated. Anyone who can reach the UDP port can
21//! publish or request any broadcast, so gate it with the host firewall / a
22//! private network. To gate access (e.g. verify the stream id as a JWT) or to
23//! scope the origin per client, drive [`Server`] directly: loop on
24//! [`Server::accept`], match the [`Request`], and call accept/reject after making
25//! your own decision.
26
27use std::collections::HashSet;
28use std::net::SocketAddr;
29use std::sync::{Arc, Mutex};
30use std::time::Duration;
31
32use moq_net::origin;
33
34use crate::Result;
35use crate::server::{Request, Server};
36
37/// SRT gateway configuration.
38///
39/// Construct via [`Config::default`] and set the fields you need, so new
40/// options stay additive. The listener is disabled (and [`run`] stays pending)
41/// unless [`listen`](Config::listen) is set, letting an embedding relay run
42/// without SRT until it's configured.
43#[derive(Debug, Clone)]
44#[non_exhaustive]
45pub struct Config {
46	/// Address to listen on for SRT (e.g. `0.0.0.0:9000`). When `None`, the SRT
47	/// gateway is disabled.
48	pub listen: Option<SocketAddr>,
49
50	/// Prefix prepended to every broadcast path, for both publish and request.
51	/// Lets one listener namespace all of its streams (e.g. `live/`).
52	pub prefix: String,
53
54	/// SRT receive latency: the negotiated buffer that trades delay for loss
55	/// recovery.
56	pub latency: Duration,
57}
58
59impl Default for Config {
60	fn default() -> Self {
61		Self {
62			listen: None,
63			prefix: String::new(),
64			latency: crate::server::DEFAULT_LATENCY,
65		}
66	}
67}
68
69/// Run the SRT gateway until it fails, publishing `m=publish` connections into
70/// `origin` and serving `m=request` connections out of it.
71///
72/// This is the unauthenticated convenience entry point: it accepts every
73/// publisher and subscriber and routes by [`prefix`](Config::prefix) + resource
74/// name. Subscribe requests are served from `origin.consume()`, so anything in
75/// the origin (SRT ingests and otherwise) can be pulled back out over SRT. To
76/// gate access (e.g. verify the stream id as a JWT) or to scope the origin per
77/// client, drive [`Server`] directly.
78///
79/// Stays pending forever (rather than resolving) when SRT is disabled, so it
80/// composes cleanly inside a `tokio::select!` alongside a relay's other
81/// long-running tasks.
82pub async fn run(origin: origin::Producer, config: Config) -> Result<()> {
83	let Some(listen) = config.listen else {
84		tracing::info!("SRT gateway disabled (no listen address)");
85		std::future::pending::<()>().await;
86		unreachable!("pending future never resolves");
87	};
88
89	let mut server = Server::bind(listen, config.latency).await?;
90	tracing::info!(%listen, prefix = %config.prefix, "SRT listening");
91
92	// Read side of the origin, used to serve `m=request` callers their broadcast.
93	let consumer = origin.consume();
94
95	// Tracks which broadcast paths are currently being ingested so a second
96	// publisher on the same stream id is rejected (first-publisher-wins, like an
97	// RTMP stream key) instead of being silently parked as a backup that could
98	// take over the path when the first publisher drops.
99	let active = ActivePaths::default();
100	let prefix = Arc::new(config.prefix);
101
102	while let Some(request) = server.accept().await {
103		let prefix = prefix.clone();
104		match request {
105			Request::Publish(publish) => {
106				let origin = origin.clone();
107				let active = active.clone();
108				// Each connection runs on its own task: `accept` pumps media for the
109				// whole connection lifetime, so handling it inline would serialize
110				// publishers.
111				tokio::spawn(async move {
112					let peer = publish.peer();
113					let path = format!("{prefix}{}", publish.resource());
114					// Claim the path before accepting; the guard releases it when the
115					// connection task ends (success, error, or panic).
116					let Some(_guard) = active.claim(&path) else {
117						tracing::warn!(%peer, %path, "rejecting SRT publish: path already being ingested");
118						let _ = publish.reject().await;
119						return;
120					};
121					if let Err(err) = publish.accept(&origin, &path).await {
122						tracing::warn!(%peer, %path, %err, "SRT ingest ended with error");
123					} else {
124						tracing::info!(%peer, %path, "SRT ingest ended");
125					}
126				});
127			}
128			Request::Subscribe(subscribe) => {
129				let consumer = consumer.clone();
130				// Many viewers can request the same path concurrently, so subscribes
131				// don't claim an `ActivePaths` slot.
132				tokio::spawn(async move {
133					let peer = subscribe.peer();
134					let path = format!("{prefix}{}", subscribe.resource());
135					if let Err(err) = subscribe.accept(&consumer, &path).await {
136						tracing::warn!(%peer, %path, %err, "SRT request ended with error");
137					} else {
138						tracing::info!(%peer, %path, "SRT request ended");
139					}
140				});
141			}
142		}
143	}
144
145	Err(crate::Error::from(anyhow::anyhow!(
146		"SRT listener stopped accepting connections"
147	)))
148}
149
150/// The set of broadcast paths with a live ingest, used to reject duplicate
151/// stream ids. Cheap to clone (shared `Arc`).
152#[derive(Clone, Default)]
153struct ActivePaths(Arc<Mutex<HashSet<String>>>);
154
155impl ActivePaths {
156	/// Claim `path`, returning a guard that releases it on drop, or `None` if it
157	/// is already claimed.
158	fn claim(&self, path: &str) -> Option<PathGuard> {
159		let mut set = self.0.lock().expect("active paths mutex poisoned");
160		set.insert(path.to_string()).then(|| PathGuard {
161			paths: self.0.clone(),
162			path: path.to_string(),
163		})
164	}
165}
166
167/// Releases a claimed [`ActivePaths`] entry when dropped.
168struct PathGuard {
169	paths: Arc<Mutex<HashSet<String>>>,
170	path: String,
171}
172
173impl Drop for PathGuard {
174	fn drop(&mut self) {
175		self.paths
176			.lock()
177			.expect("active paths mutex poisoned")
178			.remove(&self.path);
179	}
180}
181
182#[cfg(test)]
183mod tests {
184	use super::*;
185
186	#[test]
187	fn active_paths_rejects_duplicates_and_releases_on_drop() {
188		let active = ActivePaths::default();
189
190		let guard = active.claim("live/cam0").expect("first claim succeeds");
191		// A second claim of the same path is rejected while the first is held.
192		assert!(active.claim("live/cam0").is_none());
193		// A different path is unaffected.
194		let other = active.claim("live/cam1").expect("distinct path claims");
195
196		// Dropping the guard releases the path so it can be reclaimed.
197		drop(guard);
198		assert!(active.claim("live/cam0").is_some());
199
200		drop(other);
201	}
202}