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 whatever address the OS picked for the
144	/// shared socket. That works for loopback testing but not behind NAT.
145	pub ice_candidates: Vec<SocketAddr>,
146
147	/// Address the shared WebRTC media socket binds to. Every WHIP/WHEP session
148	/// shares this one UDP port (demuxed by ICE ufrag), so a deployment opens
149	/// exactly one media port in its firewall. `0.0.0.0:0` (the default) lets
150	/// the OS pick a port, which is fine for dev/loopback; production pins it.
151	pub udp_bind: SocketAddr,
152}
153
154impl Default for Config {
155	fn default() -> Self {
156		Self {
157			ice_candidates: Vec::new(),
158			udp_bind: SocketAddr::from(([0, 0, 0, 0], 0)),
159		}
160	}
161}
162
163/// Glue that owns the moq-net origin pair and hands axum routers to the caller.
164///
165/// `publisher` is where `server publish` (WHIP) writes ingested broadcasts;
166/// `subscriber` is what `server subscribe` (WHEP) reads from. They're
167/// typically the two halves of the same upstream [`moq_net::Session`].
168#[derive(Clone)]
169pub struct Server {
170	inner: Arc<Inner>,
171}
172
173struct Inner {
174	config: Config,
175	publisher: moq_net::OriginProducer,
176	/// Source for `server subscribe` (WHEP) egress.
177	subscriber: moq_net::OriginConsumer,
178	/// The shared media socket + demux, bound lazily on the first accept so
179	/// `Server::new` can stay synchronous (and an idle server binds no port).
180	mux: OnceCell<Mux>,
181	/// Live sessions keyed by resource id, each holding a cancel sender the
182	/// session task selects on. Lets [`Server::terminate`] (and the bundled
183	/// `DELETE` route) end a session by its `Location` id.
184	sessions: Mutex<HashMap<String, oneshot::Sender<()>>>,
185}
186
187impl Server {
188	/// Build a server. `publisher` receives WHIP broadcasts; `subscriber`
189	/// is the source for WHEP egress.
190	pub fn new(config: Config, publisher: moq_net::OriginProducer, subscriber: moq_net::OriginConsumer) -> Self {
191		Self {
192			inner: Arc::new(Inner {
193				config,
194				publisher,
195				subscriber,
196				mux: OnceCell::new(),
197				sessions: Mutex::new(HashMap::new()),
198			}),
199		}
200	}
201
202	/// The shared media mux, bound (and its demux task spawned) on first use.
203	pub(crate) async fn mux(&self) -> Result<&Mux> {
204		self.inner
205			.mux
206			.get_or_try_init(|| Mux::bind(self.inner.config.udp_bind, &self.inner.config.ice_candidates))
207			.await
208	}
209
210	/// Router for `server publish` (WHIP). Mount under whichever HTTP path
211	/// the deployment prefers (`/whip`, `/`, ...).
212	///
213	/// The router derives the broadcast name from the request path and performs
214	/// no authentication. To own the route and authorize requests yourself
215	/// (resolving the broadcast name from a verified token), skip the router and
216	/// call [`whip::accept`] directly from your own handler.
217	pub fn publish_router(&self) -> Router {
218		whip::router(self.clone())
219	}
220
221	/// Router for `server subscribe` (WHEP). Mount under whichever HTTP path
222	/// the deployment prefers (`/whep`, `/`, ...).
223	///
224	/// The router derives the broadcast name from the request path and performs
225	/// no authentication. To own the route and authorize requests yourself
226	/// (resolving the broadcast name from a verified token), skip the router and
227	/// call [`whep::accept`] directly from your own handler.
228	pub fn subscribe_router(&self) -> Router {
229		whep::router(self.clone())
230	}
231
232	pub(crate) fn publisher(&self) -> &moq_net::OriginProducer {
233		&self.inner.publisher
234	}
235
236	pub(crate) fn subscriber(&self) -> &moq_net::OriginConsumer {
237		&self.inner.subscriber
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) async fn delete(State(server): State<Server>, Path(path): Path<String>) -> 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		let publisher = moq_net::Origin::random().produce();
286		let subscriber = moq_net::Origin::random().produce().consume();
287		Server::new(Config::default(), publisher, subscriber)
288	}
289
290	#[test]
291	fn terminate_unknown_session_is_false() {
292		assert!(!server().terminate("00000000-0000-0000-0000-000000000000"));
293	}
294
295	#[test]
296	fn terminate_registered_session_once() {
297		let server = server();
298		let id = "11111111-1111-1111-1111-111111111111";
299		let _cancel = server.register_session(id.to_string());
300		assert!(server.terminate(id), "first terminate finds the session");
301		assert!(!server.terminate(id), "second terminate is a no-op");
302	}
303
304	#[test]
305	fn unregister_drops_the_entry() {
306		let server = server();
307		let id = "22222222-2222-2222-2222-222222222222";
308		let _cancel = server.register_session(id.to_string());
309		server.unregister_session(id);
310		assert!(!server.terminate(id), "unregistered session can't be terminated");
311	}
312
313	#[test]
314	fn peer_close_is_a_successful_session_result() {
315		assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok());
316	}
317
318	#[test]
319	fn session_location_preserves_mount_path() {
320		let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap();
321		let location = session_location(&uri, "session-id").expect("header value");
322		assert_eq!(location, "/whip/live/cam0/session-id");
323	}
324}