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}