Skip to main content

moq_native/
client.rs

1use crate::{Backoff, Error, QuicBackend, Reconnect};
2#[cfg(feature = "websocket")]
3use std::future::Future;
4use std::net;
5use url::Url;
6
7/// Configuration for the MoQ client.
8#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
9#[serde(deny_unknown_fields, default)]
10#[non_exhaustive]
11pub struct ClientConfig {
12	/// Listen for UDP packets on the given address.
13	#[arg(
14		id = "client-bind",
15		long = "client-bind",
16		default_value = "[::]:0",
17		env = "MOQ_CLIENT_BIND"
18	)]
19	pub bind: net::SocketAddr,
20
21	/// The QUIC backend to use.
22	/// Auto-detected from compiled features if not specified.
23	#[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
24	pub backend: Option<QuicBackend>,
25
26	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
27	#[serde(skip_serializing_if = "Option::is_none")]
28	#[arg(
29		id = "client-max-streams",
30		long = "client-max-streams",
31		env = "MOQ_CLIENT_MAX_STREAMS"
32	)]
33	pub max_streams: Option<u64>,
34
35	/// Restrict the client to specific MoQ protocol version(s).
36	///
37	/// By default, the client offers all supported versions and lets the server choose.
38	/// Use this to force a specific version, e.g. `--client-version moq-lite-02`.
39	/// Can be specified multiple times to offer a subset of versions.
40	///
41	/// Valid values: moq-lite-01, moq-lite-02, moq-lite-03, moq-transport-14, moq-transport-15, moq-transport-16, moq-transport-17
42	#[serde(default, skip_serializing_if = "Vec::is_empty")]
43	#[arg(id = "client-version", long = "client-version", env = "MOQ_CLIENT_VERSION")]
44	pub version: Vec<moq_net::Version>,
45
46	#[command(flatten)]
47	#[serde(default)]
48	pub tls: crate::tls::Client,
49
50	#[command(flatten)]
51	#[serde(default)]
52	pub backoff: Backoff,
53
54	#[cfg(feature = "websocket")]
55	#[command(flatten)]
56	#[serde(default)]
57	pub websocket: crate::websocket::Client,
58}
59
60impl ClientConfig {
61	pub fn init(self) -> crate::Result<Client> {
62		Client::new(self)
63	}
64
65	/// Returns the configured versions, defaulting to all if none specified.
66	pub fn versions(&self) -> moq_net::Versions {
67		if self.version.is_empty() {
68			moq_net::Versions::all()
69		} else {
70			moq_net::Versions::from(self.version.clone())
71		}
72	}
73}
74
75impl Default for ClientConfig {
76	fn default() -> Self {
77		Self {
78			bind: "[::]:0".parse().unwrap(),
79			backend: None,
80			max_streams: None,
81			version: Vec::new(),
82			tls: crate::tls::Client::default(),
83			backoff: Backoff::default(),
84			#[cfg(feature = "websocket")]
85			websocket: crate::websocket::Client::default(),
86		}
87	}
88}
89
90/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
91///
92/// Create via [`ClientConfig::init`] or [`Client::new`].
93#[derive(Clone)]
94pub struct Client {
95	moq: moq_net::Client,
96	versions: moq_net::Versions,
97	backoff: Backoff,
98	#[cfg(feature = "websocket")]
99	websocket: crate::websocket::Client,
100	tls: rustls::ClientConfig,
101	#[cfg(feature = "noq")]
102	noq: Option<crate::noq::NoqClient>,
103	#[cfg(feature = "quinn")]
104	quinn: Option<crate::quinn::QuinnClient>,
105	#[cfg(feature = "quiche")]
106	quiche: Option<crate::quiche::QuicheClient>,
107	#[cfg(feature = "iroh")]
108	iroh: Option<web_transport_iroh::iroh::Endpoint>,
109	#[cfg(feature = "iroh")]
110	iroh_addrs: Vec<std::net::SocketAddr>,
111}
112
113impl Client {
114	#[cfg(not(any(
115		feature = "noq",
116		feature = "quinn",
117		feature = "quiche",
118		feature = "websocket",
119		feature = "tcp",
120		feature = "uds"
121	)))]
122	pub fn new(_config: ClientConfig) -> crate::Result<Self> {
123		Err(Error::NoBackend(
124			"no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
125		))
126	}
127
128	/// Create a new client
129	#[cfg(any(
130		feature = "noq",
131		feature = "quinn",
132		feature = "quiche",
133		feature = "websocket",
134		feature = "tcp",
135		feature = "uds"
136	))]
137	pub fn new(config: ClientConfig) -> crate::Result<Self> {
138		#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
139		let backend = config.backend.clone().unwrap_or({
140			#[cfg(feature = "quinn")]
141			{
142				QuicBackend::Quinn
143			}
144			#[cfg(all(feature = "noq", not(feature = "quinn")))]
145			{
146				QuicBackend::Noq
147			}
148			#[cfg(all(feature = "quiche", not(feature = "quinn"), not(feature = "noq")))]
149			{
150				QuicBackend::Quiche
151			}
152			#[cfg(all(not(feature = "quiche"), not(feature = "quinn"), not(feature = "noq")))]
153			panic!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
154		});
155
156		let tls = config.tls.build()?;
157
158		#[cfg(feature = "noq")]
159		#[allow(unreachable_patterns)]
160		let noq = match backend {
161			QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
162			_ => None,
163		};
164
165		#[cfg(feature = "quinn")]
166		#[allow(unreachable_patterns)]
167		let quinn = match backend {
168			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
169			_ => None,
170		};
171
172		#[cfg(feature = "quiche")]
173		let quiche = match backend {
174			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
175			_ => None,
176		};
177
178		let versions = config.versions();
179		Ok(Self {
180			moq: moq_net::Client::new().with_versions(versions.clone()),
181			versions,
182			backoff: config.backoff,
183			#[cfg(feature = "websocket")]
184			websocket: config.websocket,
185			tls,
186			#[cfg(feature = "noq")]
187			noq,
188			#[cfg(feature = "quinn")]
189			quinn,
190			#[cfg(feature = "quiche")]
191			quiche,
192			#[cfg(feature = "iroh")]
193			iroh: None,
194			#[cfg(feature = "iroh")]
195			iroh_addrs: Vec::new(),
196		})
197	}
198
199	#[cfg(feature = "iroh")]
200	pub fn with_iroh(mut self, iroh: Option<web_transport_iroh::iroh::Endpoint>) -> Self {
201		self.iroh = iroh;
202		self
203	}
204
205	/// Set direct IP addresses for connecting to iroh peers.
206	///
207	/// This is useful when the peer's IP addresses are known ahead of time,
208	/// bypassing the need for peer discovery (e.g. in tests or local networks).
209	#[cfg(feature = "iroh")]
210	pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
211		self.iroh_addrs = addrs;
212		self
213	}
214
215	pub fn with_publish(mut self, publish: impl Into<Option<moq_net::OriginConsumer>>) -> Self {
216		self.moq = self.moq.with_publish(publish);
217		self
218	}
219
220	pub fn with_consume(mut self, consume: impl Into<Option<moq_net::OriginProducer>>) -> Self {
221		self.moq = self.moq.with_consume(consume);
222		self
223	}
224
225	/// Attach a tier-scoped [`moq_net::StatsHandle`] to all sessions opened by this client.
226	pub fn with_stats(mut self, stats: moq_net::StatsHandle) -> Self {
227		self.moq = self.moq.with_stats(stats);
228		self
229	}
230
231	/// Start a background reconnect loop that connects to the given URL,
232	/// waits for the session to close, then reconnects with exponential backoff.
233	///
234	/// Returns a [`Reconnect`] handle; drop the last handle to stop the loop.
235	pub fn reconnect(&self, url: Url) -> Reconnect {
236		Reconnect::new(self.clone(), url, self.backoff.clone())
237	}
238
239	#[cfg(not(any(
240		feature = "noq",
241		feature = "quinn",
242		feature = "quiche",
243		feature = "iroh",
244		feature = "websocket",
245		feature = "tcp",
246		feature = "uds"
247	)))]
248	pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
249		Err(Error::NoBackend(
250			"no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
251		))
252	}
253
254	#[cfg(any(
255		feature = "noq",
256		feature = "quinn",
257		feature = "quiche",
258		feature = "iroh",
259		feature = "websocket",
260		feature = "tcp",
261		feature = "uds"
262	))]
263	pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
264		let session = self.connect_inner(url).await?;
265		tracing::info!(version = %session.version(), "connected");
266		Ok(session)
267	}
268
269	#[cfg(any(
270		feature = "noq",
271		feature = "quinn",
272		feature = "quiche",
273		feature = "iroh",
274		feature = "websocket",
275		feature = "tcp",
276		feature = "uds"
277	))]
278	async fn connect_inner(&self, url: Url) -> crate::Result<moq_net::Session> {
279		// Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against
280		// QUIC, which can't speak it. Use only on a trusted network.
281		#[cfg(feature = "tcp")]
282		if url.scheme() == "tcp" {
283			let session = crate::tcp::connect(url, &self.versions.alpns()).await?;
284			return Ok(self.moq.connect(session).await?);
285		}
286
287		// Unix domain socket (qmux, no TLS). Same-host only; the server can
288		// authenticate us by uid/gid via SO_PEERCRED.
289		#[cfg(all(feature = "uds", unix))]
290		if url.scheme() == "unix" {
291			let session = crate::unix::connect(url, &self.versions.alpns()).await?;
292			return Ok(self.moq.connect(session).await?);
293		}
294
295		#[cfg(feature = "iroh")]
296		if url.scheme() == "iroh" {
297			let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
298			let session = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
299			let session = self.moq.connect(session).await?;
300			return Ok(session);
301		}
302
303		#[cfg(feature = "noq")]
304		if let Some(noq) = self.noq.as_ref() {
305			let tls = self.tls.clone();
306			let quic_url = url.clone();
307			let quic_handle = async { noq.connect(&tls, quic_url).await.map_err(Error::from) };
308
309			#[cfg(feature = "websocket")]
310			{
311				return self.race_moq_connect(url, quic_handle).await;
312			}
313
314			#[cfg(not(feature = "websocket"))]
315			{
316				let session = quic_handle.await?;
317				return Ok(self.moq.connect(session).await?);
318			}
319		}
320
321		#[cfg(feature = "quinn")]
322		if let Some(quinn) = self.quinn.as_ref() {
323			let tls = self.tls.clone();
324			let quic_url = url.clone();
325			let quic_handle = async { quinn.connect(&tls, quic_url).await.map_err(Error::from) };
326
327			#[cfg(feature = "websocket")]
328			{
329				return self.race_moq_connect(url, quic_handle).await;
330			}
331
332			#[cfg(not(feature = "websocket"))]
333			{
334				let session = quic_handle.await?;
335				return Ok(self.moq.connect(session).await?);
336			}
337		}
338
339		#[cfg(feature = "quiche")]
340		if let Some(quiche) = self.quiche.as_ref() {
341			let quic_url = url.clone();
342			let quic_handle = async { quiche.connect(quic_url).await.map_err(Error::from) };
343
344			#[cfg(feature = "websocket")]
345			{
346				return self.race_moq_connect(url, quic_handle).await;
347			}
348
349			#[cfg(not(feature = "websocket"))]
350			{
351				let session = quic_handle.await?;
352				return Ok(self.moq.connect(session).await?);
353			}
354		}
355
356		#[cfg(feature = "websocket")]
357		{
358			let alpns = self.versions.alpns();
359			let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
360			return Ok(self.moq.connect(session).await?);
361		}
362
363		#[cfg(not(feature = "websocket"))]
364		return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
365	}
366
367	#[cfg(feature = "websocket")]
368	async fn race_moq_connect<Q, S>(&self, url: Url, quic: Q) -> crate::Result<moq_net::Session>
369	where
370		Q: Future<Output = crate::Result<S>>,
371		S: web_transport_trait::Session,
372	{
373		let alpns = self.versions.alpns();
374		let ws_config = self.websocket.clone();
375		let ws_tls = self.tls.clone();
376		let websocket = async move {
377			crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
378				.await
379				.map(|res| res.map_err(Error::from))
380		};
381
382		match race_transport_connect(quic, websocket).await? {
383			TransportRace::Quic(quic) => Ok(self.moq.connect(quic).await?),
384			TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
385		}
386	}
387}
388
389#[cfg(feature = "websocket")]
390#[derive(Debug, PartialEq, Eq)]
391enum TransportRace<Q, W> {
392	Quic(Q),
393	WebSocket(W),
394}
395
396#[cfg(feature = "websocket")]
397async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
398where
399	Q: Future<Output = crate::Result<QT>>,
400	W: Future<Output = Option<crate::Result<WT>>>,
401{
402	tokio::pin!(quic);
403	tokio::pin!(websocket);
404
405	let mut quic_err = None;
406	let mut websocket_err = None;
407	let mut quic_done = false;
408	let mut websocket_done = false;
409
410	loop {
411		tokio::select! {
412			res = &mut quic, if !quic_done => {
413				match res {
414					Ok(session) => return Ok(TransportRace::Quic(session)),
415					Err(err) if err.is_auth() => return Err(err),
416					Err(err) => {
417						tracing::warn!(%err, "QUIC connection failed");
418						quic_err = Some(err);
419						quic_done = true;
420					}
421				}
422			}
423			res = &mut websocket, if !websocket_done => {
424				match res {
425					Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
426					Some(Err(err)) if err.is_auth() => return Err(err),
427					Some(Err(err)) => {
428						tracing::warn!(%err, "WebSocket connection failed");
429						websocket_err = Some(err);
430						websocket_done = true;
431					}
432					None => {
433						websocket_done = true;
434					}
435				}
436			}
437			else => break,
438		}
439
440		if quic_done && websocket_done {
441			break;
442		}
443	}
444
445	match (quic_err, websocket_err) {
446		(Some(quic), Some(websocket)) => Err(Error::TransportRace {
447			quic: std::sync::Arc::new(quic),
448			websocket: std::sync::Arc::new(websocket),
449		}),
450		(Some(err), None) | (None, Some(err)) => Err(err),
451		(None, None) => Err(Error::ConnectFailed),
452	}
453}
454
455#[cfg(test)]
456mod tests {
457	use super::*;
458	use clap::Parser;
459
460	#[test]
461	fn test_toml_disable_verify_survives_update_from() {
462		let toml = r#"
463			tls.disable_verify = true
464		"#;
465
466		let mut config: ClientConfig = toml::from_str(toml).unwrap();
467		assert_eq!(config.tls.disable_verify, Some(true));
468
469		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag).
470		config.update_from(["test"]);
471		assert_eq!(config.tls.disable_verify, Some(true));
472	}
473
474	#[test]
475	fn test_cli_disable_verify_flag() {
476		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
477		assert_eq!(config.tls.disable_verify, Some(true));
478	}
479
480	#[test]
481	fn test_cli_disable_verify_explicit_false() {
482		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
483		assert_eq!(config.tls.disable_verify, Some(false));
484	}
485
486	#[test]
487	fn test_cli_disable_verify_explicit_true() {
488		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
489		assert_eq!(config.tls.disable_verify, Some(true));
490	}
491
492	#[test]
493	fn test_cli_deprecated_tls_flags_fold_into_canonical() {
494		// The bare --tls-* forms are deprecated. They parse into a hidden field and
495		// fold into the canonical values via the effective_* accessors build() uses,
496		// so they keep working without touching the public Client fields.
497		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
498		assert_eq!(
499			config.tls.disable_verify, None,
500			"deprecated flag must not set the canonical field"
501		);
502		assert_eq!(config.tls.effective_disable_verify(), Some(true));
503		assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
504	}
505
506	#[test]
507	fn test_canonical_tls_flag_wins_over_deprecated() {
508		// Both spellings given: canonical wins for scalar options, vecs concatenate.
509		let config = ClientConfig::parse_from([
510			"test",
511			"--client-tls-disable-verify=false",
512			"--tls-disable-verify=true",
513			"--client-tls-fingerprint",
514			"aaaa",
515			"--tls-fingerprint",
516			"bbbb",
517		]);
518		assert_eq!(config.tls.effective_disable_verify(), Some(false));
519		assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
520	}
521
522	#[test]
523	fn test_cli_no_disable_verify() {
524		let config = ClientConfig::parse_from(["test"]);
525		assert_eq!(config.tls.disable_verify, None);
526	}
527
528	#[test]
529	fn test_toml_fingerprint_survives_update_from() {
530		let toml = r#"
531			tls.fingerprint = ["abcd1234", "ef567890"]
532		"#;
533
534		let mut config: ClientConfig = toml::from_str(toml).unwrap();
535		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
536
537		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag).
538		config.update_from(["test"]);
539		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
540	}
541
542	#[test]
543	fn test_toml_fingerprint_accepts_single_string() {
544		let toml = r#"
545			tls.fingerprint = "abcd1234"
546		"#;
547
548		let config: ClientConfig = toml::from_str(toml).unwrap();
549		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
550	}
551
552	#[test]
553	fn test_cli_fingerprint() {
554		let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
555		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
556	}
557
558	#[test]
559	fn test_toml_version_survives_update_from() {
560		let toml = r#"
561			version = ["moq-lite-02"]
562		"#;
563
564		let mut config: ClientConfig = toml::from_str(toml).unwrap();
565		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
566
567		// Simulate: TOML loaded, then CLI args re-applied (no --client-version flag).
568		config.update_from(["test"]);
569		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
570	}
571
572	#[test]
573	fn test_cli_version() {
574		let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
575		assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
576	}
577
578	#[test]
579	fn test_cli_no_version_defaults_to_all() {
580		let config = ClientConfig::parse_from(["test"]);
581		assert!(config.version.is_empty());
582		// versions() helper returns all when none specified
583		assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
584	}
585
586	#[cfg(feature = "websocket")]
587	#[tokio::test]
588	async fn race_transport_connect_stops_on_quic_auth_error() {
589		let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
590		let websocket = async {
591			// This only needs to complete later than the immediately ready QUIC auth error.
592			tokio::task::yield_now().await;
593			Some(Ok(1usize))
594		};
595
596		let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
597		assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
598	}
599
600	#[cfg(feature = "websocket")]
601	#[tokio::test]
602	async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
603		let quic = async { Err::<usize, _>(Error::ConnectFailed) };
604		let websocket = async { Some(Ok(7usize)) };
605
606		let value = super::race_transport_connect(quic, websocket).await.unwrap();
607		assert_eq!(value, super::TransportRace::WebSocket(7));
608	}
609
610	#[cfg(feature = "websocket")]
611	#[tokio::test]
612	async fn race_transport_connect_returns_when_quic_transport_connects() {
613		let quic = async { Ok("quic") };
614		let websocket = std::future::pending::<Option<crate::Result<&str>>>();
615
616		let value = tokio::time::timeout(
617			std::time::Duration::from_secs(1),
618			super::race_transport_connect(quic, websocket),
619		)
620		.await
621		.expect("race waited for WebSocket after QUIC transport connected")
622		.unwrap();
623		assert_eq!(value, super::TransportRace::Quic("quic"));
624	}
625}