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};
17
18use axum::Router;
19use axum::extract::{Path, State};
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 (prompt unannounce) instead of lingering for a reconnect.
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(mut 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
150impl Default for Config {
151	fn default() -> Self {
152		Self {
153			ice_candidates: Vec::new(),
154			udp_bind: SocketAddr::from(([0, 0, 0, 0], 0)),
155		}
156	}
157}
158
159/// Glue that owns the moq-net origin pair and hands axum routers to the caller.
160///
161/// `publisher` is where `server publish` (WHIP) writes ingested broadcasts;
162/// `subscriber` is what `server subscribe` (WHEP) reads from. They're
163/// typically the two halves of the same upstream [`moq_net::Session`].
164#[derive(Clone)]
165pub struct Server {
166	inner: Arc<Inner>,
167}
168
169struct Inner {
170	config: Config,
171	publisher: moq_net::origin::Producer,
172	/// Source for `server subscribe` (WHEP) egress.
173	subscriber: moq_net::origin::Consumer,
174	/// The shared media socket + demux, bound lazily on the first accept so
175	/// `Server::new` can stay synchronous (and an idle server binds no port).
176	mux: OnceCell<Mux>,
177	/// Live sessions keyed by resource id, each holding a cancel sender the
178	/// session task selects on. Lets [`Server::terminate`] (and the bundled
179	/// `DELETE` route) end a session by its `Location` id.
180	sessions: Mutex<HashMap<String, oneshot::Sender<()>>>,
181}
182
183impl Server {
184	/// Build a server. `publisher` receives WHIP broadcasts; `subscriber`
185	/// is the source for WHEP egress.
186	pub fn new(config: Config, publisher: moq_net::origin::Producer, subscriber: moq_net::origin::Consumer) -> Self {
187		Self {
188			inner: Arc::new(Inner {
189				config,
190				publisher,
191				subscriber,
192				mux: OnceCell::new(),
193				sessions: Mutex::new(HashMap::new()),
194			}),
195		}
196	}
197
198	/// The shared media mux, bound (and its demux task spawned) on first use.
199	pub(crate) async fn mux(&self) -> Result<&Mux> {
200		self.inner
201			.mux
202			.get_or_try_init(|| Mux::bind(self.inner.config.udp_bind, &self.inner.config.ice_candidates))
203			.await
204	}
205
206	/// Router for `server publish` (WHIP). Mount under whichever HTTP path
207	/// the deployment prefers (`/whip`, `/`, ...).
208	///
209	/// The router derives the broadcast name from the request path and performs
210	/// no authentication. To own the route and authorize requests yourself
211	/// (resolving the broadcast name from a verified token), skip the router and
212	/// call [`whip::accept`] directly from your own handler.
213	pub fn publish_router(&self) -> Router {
214		whip::router(self.clone())
215	}
216
217	/// Router for `server subscribe` (WHEP). Mount under whichever HTTP path
218	/// the deployment prefers (`/whep`, `/`, ...).
219	///
220	/// The router derives the broadcast name from the request path and performs
221	/// no authentication. To own the route and authorize requests yourself
222	/// (resolving the broadcast name from a verified token), skip the router and
223	/// call [`whep::accept`] directly from your own handler.
224	pub fn subscribe_router(&self) -> Router {
225		whep::router(self.clone())
226	}
227
228	pub(crate) fn publisher(&self) -> &moq_net::origin::Producer {
229		&self.inner.publisher
230	}
231
232	pub(crate) fn subscriber(&self) -> &moq_net::origin::Consumer {
233		&self.inner.subscriber
234	}
235
236	/// Register a session under its resource id, returning the cancel receiver.
237	/// Called by [`whip::accept`] / [`whep::accept`] before returning the
238	/// negotiated session runner.
239	pub(crate) fn register_session(&self, resource_id: String) -> oneshot::Receiver<()> {
240		let (tx, rx) = oneshot::channel();
241		self.inner.sessions.lock().unwrap().insert(resource_id, tx);
242		rx
243	}
244
245	/// Drop a session's registry entry once it has ended on its own.
246	pub(crate) fn unregister_session(&self, resource_id: &str) {
247		self.inner.sessions.lock().unwrap().remove(resource_id);
248	}
249
250	/// Terminate a negotiated session by its resource id (the `Location` path
251	/// component from the WHIP/WHEP response). Returns `true` if a live session
252	/// was found and signalled to stop; the session task then releases its
253	/// broadcast announcement and mux registration. Embedders that own their own
254	/// HTTP routing call this to honor a WHIP/WHEP `DELETE`; the bundled routers
255	/// already wire it to the `DELETE` method.
256	pub fn terminate(&self, resource_id: &str) -> bool {
257		if let Some(cancel) = self.inner.sessions.lock().unwrap().remove(resource_id) {
258			let _ = cancel.send(());
259			true
260		} else {
261			false
262		}
263	}
264}
265
266/// Shared `DELETE` handler for both bundled routers: parse the resource id from
267/// the trailing path segment and terminate the matching session.
268pub(crate) async fn delete(State(server): State<Server>, Path(path): Path<String>) -> StatusCode {
269	match crate::sdp::parse_resource_id(&path) {
270		Ok(id) if server.terminate(&id.to_string()) => StatusCode::OK,
271		Ok(_) => StatusCode::NOT_FOUND,
272		Err(_) => StatusCode::BAD_REQUEST,
273	}
274}
275
276#[cfg(test)]
277mod tests {
278	use super::*;
279
280	fn server() -> Server {
281		let publisher = moq_net::Origin::random().produce();
282		let subscriber = moq_net::Origin::random().produce().consume();
283		Server::new(Config::default(), publisher, subscriber)
284	}
285
286	#[test]
287	fn terminate_unknown_session_is_false() {
288		assert!(!server().terminate("00000000-0000-0000-0000-000000000000"));
289	}
290
291	#[test]
292	fn terminate_registered_session_once() {
293		let server = server();
294		let id = "11111111-1111-1111-1111-111111111111";
295		let _cancel = server.register_session(id.to_string());
296		assert!(server.terminate(id), "first terminate finds the session");
297		assert!(!server.terminate(id), "second terminate is a no-op");
298	}
299
300	#[test]
301	fn unregister_drops_the_entry() {
302		let server = server();
303		let id = "22222222-2222-2222-2222-222222222222";
304		let _cancel = server.register_session(id.to_string());
305		server.unregister_session(id);
306		assert!(!server.terminate(id), "unregistered session can't be terminated");
307	}
308
309	#[test]
310	fn peer_close_is_a_successful_session_result() {
311		assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok());
312	}
313
314	#[test]
315	fn session_location_preserves_mount_path() {
316		let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap();
317		let location = session_location(&uri, "session-id").expect("header value");
318		assert_eq!(location, "/whip/live/cam0/session-id");
319	}
320}