Skip to main content

moq_rtc/server/
mod.rs

1//! HTTP-server side: accept WHIP/WHEP offers from remote clients.
2//!
3//! Mounts axum routers that publish into [`moq_net::origin::Producer`] (WHIP
4//! / `server publish`) and pull from [`moq_net::origin::Consumer`] (WHEP /
5//! `server subscribe`). The HTTP listener itself is the caller's
6//! responsibility; the `moq-cli` `rtc` subcommand mounts these under an
7//! HTTP server.
8
9pub mod whep;
10pub mod whip;
11
12mod mux;
13
14use std::collections::HashMap;
15use std::net::SocketAddr;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19use axum::Router;
20use axum::http::{HeaderValue, StatusCode, Uri};
21use tokio::sync::{OnceCell, oneshot};
22
23use crate::{Error, Result};
24use mux::Mux;
25
26/// The result of a WHIP/WHEP [`whip::accept`] / [`whep::accept`]: the SDP answer
27/// to return to the client, plus an opaque resource id for the `Location` header
28/// (the RFC 9725 session resource URL).
29pub struct Response {
30	/// Opaque id identifying the negotiated session, for the `Location` header.
31	pub resource_id: String,
32	/// The SDP answer body (`Content-Type: application/sdp`).
33	pub answer: String,
34	session: AcceptedSession,
35}
36
37impl Response {
38	/// Run the negotiated media session until the peer disconnects, DELETE terminates it, or it errors.
39	pub async fn run(self) -> Result<()> {
40		self.session.run().await
41	}
42}
43
44impl std::fmt::Debug for Response {
45	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46		f.debug_struct("Response")
47			.field("resource_id", &self.resource_id)
48			.field("answer", &self.answer)
49			.finish_non_exhaustive()
50	}
51}
52
53/// The negotiated session runner behind [`Response::run`]: holds the mux
54/// registration for the session's lifetime, and unregisters the session from
55/// the server registry on drop. Built by [`whip::accept`] / [`whep::accept`]
56/// (descendant modules, so the private fields are in scope there).
57struct AcceptedSession {
58	server: Server,
59	resource_id: String,
60	session: Option<crate::session::Session>,
61	registration: Option<mux::Registration>,
62	cancel: Option<oneshot::Receiver<()>>,
63	role: &'static str,
64	// WHIP only: a clone of the ingest broadcast, so a deliberate DELETE can
65	// finish() it: a clean end instead of an abort error.
66	broadcast: Option<moq_net::broadcast::Producer>,
67}
68
69impl AcceptedSession {
70	async fn run(mut self) -> Result<()> {
71		let session = self.session.take().expect("accepted session missing driver");
72		let registration = self
73			.registration
74			.take()
75			.expect("accepted session missing mux registration");
76		let cancel = self.cancel.take().expect("accepted session missing cancel receiver");
77
78		let result = {
79			// Hold the mux registration for the session's lifetime; it
80			// unregisters on exit.
81			let _registration = registration;
82			tokio::select! {
83				res = session.run() => {
84					crate::session::log_session_end(self.role, &res);
85					res
86				}
87				_ = cancel => {
88					tracing::debug!(role = self.role, "webrtc session terminated by DELETE");
89					// A deliberate end: finish the broadcast so the origin
90					// unannounces it immediately.
91					if let Some(broadcast) = self.broadcast.take() {
92						broadcast.finish();
93					}
94					Ok(())
95				}
96			}
97		};
98		normalize_session_result(result)
99	}
100}
101
102impl Drop for AcceptedSession {
103	fn drop(&mut self) {
104		self.server.unregister_session(&self.resource_id);
105	}
106}
107
108/// Fold an ordinary peer disconnect into `Ok` so [`Response::run`] only errors
109/// on genuine failures.
110fn normalize_session_result(result: Result<()>) -> Result<()> {
111	match result {
112		Ok(()) | Err(Error::SessionClosed) => Ok(()),
113		Err(err) => Err(err),
114	}
115}
116
117/// Build the `Location` header for a negotiated session by appending the
118/// resource id to the request path, preserving whatever prefix the router is
119/// mounted under.
120pub(crate) fn session_location(uri: &Uri, resource_id: &str) -> Option<HeaderValue> {
121	let base = uri.path().trim_end_matches('/');
122	let path = if base.is_empty() {
123		format!("/{resource_id}")
124	} else {
125		format!("{base}/{resource_id}")
126	};
127	HeaderValue::from_str(&path).ok()
128}
129
130/// Configuration shared by both `server publish` and `server subscribe`.
131#[derive(Clone, Debug)]
132#[non_exhaustive]
133pub struct Config {
134	/// Public UDP socket addresses that should be advertised as ICE host
135	/// candidates. Each is sent as a separate `candidate` line in the SDP
136	/// answer so a remote peer can reach us.
137	///
138	/// If empty, the mux advertises the bound address, substituting loopback
139	/// when the socket is bound to an unspecified address. That works for
140	/// loopback testing but not behind NAT.
141	pub ice_candidates: Vec<SocketAddr>,
142
143	/// Address the shared WebRTC media socket binds to. Every WHIP/WHEP session
144	/// shares this one UDP port (demuxed by ICE ufrag), so a deployment opens
145	/// exactly one media port in its firewall. `0.0.0.0:0` (the default) lets
146	/// the OS pick a port, which is fine for dev/loopback; production pins it.
147	pub udp_bind: SocketAddr,
148
149	/// How long relays keep a non-latest group of an ingested media track fetchable.
150	///
151	/// A retention budget, not a delivery one: it never makes a subscriber play further
152	/// behind live, it caps how far back a FETCH can still reach. `None` keeps hang's own
153	/// default, which suits a segmented egress (HLS/DASH) reading the broadcast downstream:
154	/// it may only advertise segments that are still fetchable. Lower it when nothing reads
155	/// history and the memory matters.
156	///
157	/// Ingest only (`server publish` / WHIP): WHEP egress reads a broadcast someone else
158	/// declared, so it ignores this.
159	pub max_age: Option<Duration>,
160
161	/// Connection allocator each ingested track claims its peak-hold bitrate on.
162	/// Ingest only (`server publish` / WHIP); WHEP egress ignores this.
163	pub bandwidth: moq_net::bandwidth::Allocator,
164}
165
166impl Default for Config {
167	fn default() -> Self {
168		Self {
169			ice_candidates: Vec::new(),
170			udp_bind: SocketAddr::from(([0, 0, 0, 0], 0)),
171			max_age: None,
172			bandwidth: moq_net::bandwidth::Allocator::unlimited(),
173		}
174	}
175}
176
177/// Shared WebRTC media state that hands axum routers to the caller.
178#[derive(Clone)]
179pub struct Server {
180	inner: Arc<Inner>,
181}
182
183struct Inner {
184	config: Config,
185	/// The shared media socket + demux, bound lazily on the first accept so
186	/// `Server::new` can stay synchronous (and an idle server binds no port).
187	mux: OnceCell<Mux>,
188	/// Live sessions keyed by resource id, each holding a cancel sender the
189	/// session task selects on. Lets [`Server::terminate`] (and the bundled
190	/// `DELETE` route) end a session by its `Location` id.
191	sessions: Mutex<HashMap<String, oneshot::Sender<()>>>,
192}
193
194impl Server {
195	/// Build a server with shared ICE and media settings.
196	pub fn new(config: Config) -> Self {
197		Self {
198			inner: Arc::new(Inner {
199				config,
200				mux: OnceCell::new(),
201				sessions: Mutex::new(HashMap::new()),
202			}),
203		}
204	}
205
206	/// The shared media mux, bound (and its demux task spawned) on first use.
207	pub(crate) async fn mux(&self) -> Result<&Mux> {
208		self.inner
209			.mux
210			.get_or_try_init(|| Mux::bind(self.inner.config.udp_bind, &self.inner.config.ice_candidates))
211			.await
212	}
213
214	/// Router for `server publish` (WHIP). Mount under whichever HTTP path
215	/// the deployment prefers (`/whip`, `/`, ...).
216	///
217	/// The router derives the broadcast name from the request path and performs
218	/// no authentication. To own the route and authorize requests yourself
219	/// (resolving the broadcast name from a verified token), skip the router and
220	/// call [`whip::accept`] directly from your own handler.
221	pub fn publish_router(&self, publisher: moq_net::origin::Producer) -> Router {
222		whip::router(self.clone(), publisher)
223	}
224
225	/// Router for `server subscribe` (WHEP). Mount under whichever HTTP path
226	/// the deployment prefers (`/whep`, `/`, ...).
227	///
228	/// The router derives the broadcast name from the request path and performs
229	/// no authentication. To own the route and authorize requests yourself
230	/// (resolving the broadcast name from a verified token), skip the router and
231	/// call [`whep::accept`] directly from your own handler.
232	pub fn subscribe_router(&self, subscriber: moq_net::origin::Consumer) -> Router {
233		whep::router(self.clone(), subscriber)
234	}
235
236	pub(crate) fn config(&self) -> &Config {
237		&self.inner.config
238	}
239
240	/// Register a session under its resource id, returning the cancel receiver.
241	/// Called by [`whip::accept`] / [`whep::accept`] before returning the
242	/// negotiated session runner.
243	pub(crate) fn register_session(&self, resource_id: String) -> oneshot::Receiver<()> {
244		let (tx, rx) = oneshot::channel();
245		self.inner.sessions.lock().unwrap().insert(resource_id, tx);
246		rx
247	}
248
249	/// Drop a session's registry entry once it has ended on its own.
250	pub(crate) fn unregister_session(&self, resource_id: &str) {
251		self.inner.sessions.lock().unwrap().remove(resource_id);
252	}
253
254	/// Terminate a negotiated session by its resource id (the `Location` path
255	/// component from the WHIP/WHEP response). Returns `true` if a live session
256	/// was found and signalled to stop; the session task then releases its
257	/// broadcast announcement and mux registration. Embedders that own their own
258	/// HTTP routing call this to honor a WHIP/WHEP `DELETE`; the bundled routers
259	/// already wire it to the `DELETE` method.
260	pub fn terminate(&self, resource_id: &str) -> bool {
261		if let Some(cancel) = self.inner.sessions.lock().unwrap().remove(resource_id) {
262			let _ = cancel.send(());
263			true
264		} else {
265			false
266		}
267	}
268}
269
270/// Shared `DELETE` handler for both bundled routers: parse the resource id from
271/// the trailing path segment and terminate the matching session.
272pub(crate) fn delete(server: &Server, path: &str) -> StatusCode {
273	match crate::sdp::parse_resource_id(path) {
274		Ok(id) if server.terminate(&id.to_string()) => StatusCode::OK,
275		Ok(_) => StatusCode::NOT_FOUND,
276		Err(_) => StatusCode::BAD_REQUEST,
277	}
278}
279
280#[cfg(test)]
281mod tests {
282	use super::*;
283
284	fn server() -> Server {
285		Server::new(Config::default())
286	}
287
288	#[test]
289	fn terminate_unknown_session_is_false() {
290		assert!(!server().terminate("00000000-0000-0000-0000-000000000000"));
291	}
292
293	#[test]
294	fn terminate_registered_session_once() {
295		let server = server();
296		let id = "11111111-1111-1111-1111-111111111111";
297		let _cancel = server.register_session(id.to_string());
298		assert!(server.terminate(id), "first terminate finds the session");
299		assert!(!server.terminate(id), "second terminate is a no-op");
300	}
301
302	#[test]
303	fn unregister_drops_the_entry() {
304		let server = server();
305		let id = "22222222-2222-2222-2222-222222222222";
306		let _cancel = server.register_session(id.to_string());
307		server.unregister_session(id);
308		assert!(!server.terminate(id), "unregistered session can't be terminated");
309	}
310
311	#[test]
312	fn peer_close_is_a_successful_session_result() {
313		assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok());
314	}
315
316	#[test]
317	fn session_location_preserves_mount_path() {
318		let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap();
319		let location = session_location(&uri, "session-id").expect("header value");
320		assert_eq!(location, "/whip/live/cam0/session-id");
321	}
322}