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::OriginProducer`] (WHIP
4//! / `server publish`) and pull from [`moq_net::OriginConsumer`] (WHEP /
5//! `server subscribe`). The HTTP listener itself is the caller's
6//! responsibility; the binary in `bin/moq-rtc.rs` mounts these under
7//! axum_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	/// Build a negotiated session response.
39	pub(crate) fn new(
40		server: Server,
41		resource_id: String,
42		answer: String,
43		session: crate::session::Session,
44		registration: mux::Registration,
45		cancel: oneshot::Receiver<()>,
46		role: &'static str,
47	) -> Self {
48		Self {
49			resource_id: resource_id.clone(),
50			answer,
51			session: AcceptedSession {
52				server,
53				resource_id,
54				session: Some(session),
55				registration: Some(registration),
56				cancel: Some(cancel),
57				role,
58			},
59		}
60	}
61
62	/// Run the negotiated media session until the peer disconnects, DELETE terminates it, or it errors.
63	pub async fn run(self) -> Result<()> {
64		self.session.run().await
65	}
66}
67
68impl std::fmt::Debug for Response {
69	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70		f.debug_struct("Response")
71			.field("resource_id", &self.resource_id)
72			.field("answer", &self.answer)
73			.finish_non_exhaustive()
74	}
75}
76
77struct AcceptedSession {
78	server: Server,
79	resource_id: String,
80	session: Option<crate::session::Session>,
81	registration: Option<mux::Registration>,
82	cancel: Option<oneshot::Receiver<()>>,
83	role: &'static str,
84}
85
86impl AcceptedSession {
87	async fn run(mut self) -> Result<()> {
88		let session = self.session.take().expect("accepted session missing driver");
89		let registration = self
90			.registration
91			.take()
92			.expect("accepted session missing mux registration");
93		let cancel = self.cancel.take().expect("accepted session missing cancel receiver");
94
95		let result = {
96			let _registration = registration;
97			tokio::select! {
98				res = session.run() => {
99					crate::session::log_session_end(self.role, &res);
100					res
101				}
102				_ = cancel => {
103					tracing::debug!(role = self.role, "webrtc session terminated by DELETE");
104					Ok(())
105				}
106			}
107		};
108		normalize_session_result(result)
109	}
110}
111
112impl Drop for AcceptedSession {
113	fn drop(&mut self) {
114		self.server.unregister_session(&self.resource_id);
115	}
116}
117
118fn normalize_session_result(result: Result<()>) -> Result<()> {
119	match result {
120		Ok(()) | Err(Error::SessionClosed) => Ok(()),
121		Err(err) => Err(err),
122	}
123}
124
125pub(crate) fn session_location(uri: &Uri, resource_id: &str) -> Option<HeaderValue> {
126	let base = uri.path().trim_end_matches('/');
127	let path = if base.is_empty() {
128		format!("/{resource_id}")
129	} else {
130		format!("{base}/{resource_id}")
131	};
132	HeaderValue::from_str(&path).ok()
133}
134
135/// Configuration shared by both `server publish` and `server subscribe`.
136#[derive(Clone, Debug)]
137#[non_exhaustive]
138pub struct Config {
139	/// Public UDP socket addresses that should be advertised as ICE host
140	/// candidates. Each is sent as a separate `candidate` line in the SDP
141	/// answer so a remote peer can reach us.
142	///
143	/// If empty, the mux advertises the bound address, substituting loopback
144	/// when the socket is bound to an unspecified address. That works for
145	/// loopback testing but not behind NAT.
146	pub ice_candidates: Vec<SocketAddr>,
147
148	/// Address the shared WebRTC media socket binds to. Every WHIP/WHEP session
149	/// shares this one UDP port (demuxed by ICE ufrag), so a deployment opens
150	/// exactly one media port in its firewall. `0.0.0.0:0` (the default) lets
151	/// the OS pick a port, which is fine for dev/loopback; production pins it.
152	pub udp_bind: SocketAddr,
153}
154
155impl Default for Config {
156	fn default() -> Self {
157		Self {
158			ice_candidates: Vec::new(),
159			udp_bind: SocketAddr::from(([0, 0, 0, 0], 0)),
160		}
161	}
162}
163
164/// Glue that owns the moq-net origin pair and hands axum routers to the caller.
165///
166/// `publisher` is where `server publish` (WHIP) writes ingested broadcasts;
167/// `subscriber` is what `server subscribe` (WHEP) reads from. They're
168/// typically the two halves of the same upstream [`moq_net::Session`].
169#[derive(Clone)]
170pub struct Server {
171	inner: Arc<Inner>,
172}
173
174struct Inner {
175	config: Config,
176	publisher: moq_net::OriginProducer,
177	/// Source for `server subscribe` (WHEP) egress.
178	subscriber: moq_net::OriginConsumer,
179	/// The shared media socket + demux, bound lazily on the first accept so
180	/// `Server::new` can stay synchronous (and an idle server binds no port).
181	mux: OnceCell<Mux>,
182	/// Live sessions keyed by resource id, each holding a cancel sender the
183	/// session task selects on. Lets [`Server::terminate`] (and the bundled
184	/// `DELETE` route) end a session by its `Location` id.
185	sessions: Mutex<HashMap<String, oneshot::Sender<()>>>,
186}
187
188impl Server {
189	/// Build a server. `publisher` receives WHIP broadcasts; `subscriber`
190	/// is the source for WHEP egress.
191	pub fn new(config: Config, publisher: moq_net::OriginProducer, subscriber: moq_net::OriginConsumer) -> Self {
192		Self {
193			inner: Arc::new(Inner {
194				config,
195				publisher,
196				subscriber,
197				mux: OnceCell::new(),
198				sessions: Mutex::new(HashMap::new()),
199			}),
200		}
201	}
202
203	/// The shared media mux, bound (and its demux task spawned) on first use.
204	pub(crate) async fn mux(&self) -> Result<&Mux> {
205		self.inner
206			.mux
207			.get_or_try_init(|| Mux::bind(self.inner.config.udp_bind, &self.inner.config.ice_candidates))
208			.await
209	}
210
211	/// Router for `server publish` (WHIP). Mount under whichever HTTP path
212	/// the deployment prefers (`/whip`, `/`, ...).
213	///
214	/// The router derives the broadcast name from the request path and performs
215	/// no authentication. To own the route and authorize requests yourself
216	/// (resolving the broadcast name from a verified token), skip the router and
217	/// call [`whip::accept`] directly from your own handler.
218	pub fn publish_router(&self) -> Router {
219		whip::router(self.clone())
220	}
221
222	/// Router for `server subscribe` (WHEP). Mount under whichever HTTP path
223	/// the deployment prefers (`/whep`, `/`, ...).
224	///
225	/// The router derives the broadcast name from the request path and performs
226	/// no authentication. To own the route and authorize requests yourself
227	/// (resolving the broadcast name from a verified token), skip the router and
228	/// call [`whep::accept`] directly from your own handler.
229	pub fn subscribe_router(&self) -> Router {
230		whep::router(self.clone())
231	}
232
233	pub(crate) fn publisher(&self) -> &moq_net::OriginProducer {
234		&self.inner.publisher
235	}
236
237	pub(crate) fn subscriber(&self) -> &moq_net::OriginConsumer {
238		&self.inner.subscriber
239	}
240
241	/// Register a session under its resource id, returning the cancel receiver.
242	/// Called by [`whip::accept`] / [`whep::accept`] before returning the
243	/// negotiated session runner.
244	pub(crate) fn register_session(&self, resource_id: String) -> oneshot::Receiver<()> {
245		let (tx, rx) = oneshot::channel();
246		self.inner.sessions.lock().unwrap().insert(resource_id, tx);
247		rx
248	}
249
250	/// Drop a session's registry entry once it has ended on its own.
251	pub(crate) fn unregister_session(&self, resource_id: &str) {
252		self.inner.sessions.lock().unwrap().remove(resource_id);
253	}
254
255	/// Terminate a negotiated session by its resource id (the `Location` path
256	/// component from the WHIP/WHEP response). Returns `true` if a live session
257	/// was found and signalled to stop; the session task then releases its
258	/// broadcast announcement and mux registration. Embedders that own their own
259	/// HTTP routing call this to honor a WHIP/WHEP `DELETE`; the bundled routers
260	/// already wire it to the `DELETE` method.
261	pub fn terminate(&self, resource_id: &str) -> bool {
262		if let Some(cancel) = self.inner.sessions.lock().unwrap().remove(resource_id) {
263			let _ = cancel.send(());
264			true
265		} else {
266			false
267		}
268	}
269}
270
271/// Shared `DELETE` handler for both bundled routers: parse the resource id from
272/// the trailing path segment and terminate the matching session.
273pub(crate) async fn delete(State(server): State<Server>, Path(path): Path<String>) -> StatusCode {
274	match crate::sdp::parse_resource_id(&path) {
275		Ok(id) if server.terminate(&id.to_string()) => StatusCode::OK,
276		Ok(_) => StatusCode::NOT_FOUND,
277		Err(_) => StatusCode::BAD_REQUEST,
278	}
279}
280
281#[cfg(test)]
282mod tests {
283	use super::*;
284
285	fn server() -> Server {
286		let publisher = moq_net::Origin::random().produce();
287		let subscriber = moq_net::Origin::random().produce().consume();
288		Server::new(Config::default(), publisher, subscriber)
289	}
290
291	#[test]
292	fn terminate_unknown_session_is_false() {
293		assert!(!server().terminate("00000000-0000-0000-0000-000000000000"));
294	}
295
296	#[test]
297	fn terminate_registered_session_once() {
298		let server = server();
299		let id = "11111111-1111-1111-1111-111111111111";
300		let _cancel = server.register_session(id.to_string());
301		assert!(server.terminate(id), "first terminate finds the session");
302		assert!(!server.terminate(id), "second terminate is a no-op");
303	}
304
305	#[test]
306	fn unregister_drops_the_entry() {
307		let server = server();
308		let id = "22222222-2222-2222-2222-222222222222";
309		let _cancel = server.register_session(id.to_string());
310		server.unregister_session(id);
311		assert!(!server.terminate(id), "unregistered session can't be terminated");
312	}
313
314	#[test]
315	fn peer_close_is_a_successful_session_result() {
316		assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok());
317	}
318
319	#[test]
320	fn session_location_preserves_mount_path() {
321		let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap();
322		let location = session_location(&uri, "session-id").expect("header value");
323		assert_eq!(location, "/whip/live/cam0/session-id");
324	}
325}