Skip to main content

moq_native/
client.rs

1use crate::QuicBackend;
2use crate::crypto;
3use anyhow::Context;
4use std::path::PathBuf;
5use std::{net, sync::Arc};
6use url::Url;
7
8/// TLS configuration for the client.
9#[derive(Clone, Default, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
10#[serde(default, deny_unknown_fields)]
11#[non_exhaustive]
12pub struct ClientTls {
13	/// Use the TLS root at this path, encoded as PEM.
14	///
15	/// This value can be provided multiple times for multiple roots.
16	/// If this is empty, system roots will be used instead
17	#[serde(skip_serializing_if = "Vec::is_empty")]
18	#[arg(id = "tls-root", long = "tls-root", env = "MOQ_CLIENT_TLS_ROOT")]
19	pub root: Vec<PathBuf>,
20
21	/// Danger: Disable TLS certificate verification.
22	///
23	/// Fine for local development and between relays, but should be used in caution in production.
24	#[serde(skip_serializing_if = "Option::is_none")]
25	#[arg(
26		id = "tls-disable-verify",
27		long = "tls-disable-verify",
28		env = "MOQ_CLIENT_TLS_DISABLE_VERIFY",
29		default_missing_value = "true",
30		num_args = 0..=1,
31		require_equals = true,
32		value_parser = clap::value_parser!(bool),
33	)]
34	pub disable_verify: Option<bool>,
35}
36
37/// Configuration for the MoQ client.
38#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
39#[serde(deny_unknown_fields, default)]
40#[non_exhaustive]
41pub struct ClientConfig {
42	/// Listen for UDP packets on the given address.
43	#[arg(
44		id = "client-bind",
45		long = "client-bind",
46		default_value = "[::]:0",
47		env = "MOQ_CLIENT_BIND"
48	)]
49	pub bind: net::SocketAddr,
50
51	/// The QUIC backend to use.
52	/// Auto-detected from compiled features if not specified.
53	#[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
54	pub backend: Option<QuicBackend>,
55
56	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
57	#[serde(skip_serializing_if = "Option::is_none")]
58	#[arg(
59		id = "client-max-streams",
60		long = "client-max-streams",
61		env = "MOQ_CLIENT_MAX_STREAMS"
62	)]
63	pub max_streams: Option<u64>,
64
65	/// Restrict the client to specific MoQ protocol version(s).
66	///
67	/// By default, the client offers all supported versions and lets the server choose.
68	/// Use this to force a specific version, e.g. `--client-version moq-lite-02`.
69	/// Can be specified multiple times to offer a subset of versions.
70	///
71	/// Valid values: moq-lite-01, moq-lite-02, moq-lite-03, moq-transport-14, moq-transport-15, moq-transport-16, moq-transport-17
72	#[serde(default, skip_serializing_if = "Vec::is_empty")]
73	#[arg(id = "client-version", long = "client-version", env = "MOQ_CLIENT_VERSION")]
74	pub version: Vec<moq_lite::Version>,
75
76	#[command(flatten)]
77	#[serde(default)]
78	pub tls: ClientTls,
79
80	#[cfg(feature = "websocket")]
81	#[command(flatten)]
82	#[serde(default)]
83	pub websocket: super::ClientWebSocket,
84}
85
86impl ClientConfig {
87	pub fn init(self) -> anyhow::Result<Client> {
88		Client::new(self)
89	}
90
91	/// Returns the configured versions, defaulting to all if none specified.
92	pub fn versions(&self) -> moq_lite::Versions {
93		if self.version.is_empty() {
94			moq_lite::Versions::all()
95		} else {
96			moq_lite::Versions::from(self.version.clone())
97		}
98	}
99}
100
101impl Default for ClientConfig {
102	fn default() -> Self {
103		Self {
104			bind: "[::]:0".parse().unwrap(),
105			backend: None,
106			max_streams: None,
107			version: Vec::new(),
108			tls: ClientTls::default(),
109			#[cfg(feature = "websocket")]
110			websocket: super::ClientWebSocket::default(),
111		}
112	}
113}
114
115/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
116///
117/// Create via [`ClientConfig::init`] or [`Client::new`].
118#[derive(Clone)]
119pub struct Client {
120	moq: moq_lite::Client,
121	#[cfg(feature = "websocket")]
122	websocket: super::ClientWebSocket,
123	tls: rustls::ClientConfig,
124	#[cfg(feature = "noq")]
125	noq: Option<crate::noq::NoqClient>,
126	#[cfg(feature = "quinn")]
127	quinn: Option<crate::quinn::QuinnClient>,
128	#[cfg(feature = "quiche")]
129	quiche: Option<crate::quiche::QuicheClient>,
130	#[cfg(feature = "iroh")]
131	iroh: Option<web_transport_iroh::iroh::Endpoint>,
132}
133
134impl Client {
135	#[cfg(not(any(feature = "noq", feature = "quinn", feature = "quiche")))]
136	pub fn new(_config: ClientConfig) -> anyhow::Result<Self> {
137		anyhow::bail!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
138	}
139
140	/// Create a new client
141	#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
142	pub fn new(config: ClientConfig) -> anyhow::Result<Self> {
143		let backend = config.backend.clone().unwrap_or({
144			#[cfg(feature = "quinn")]
145			{
146				QuicBackend::Quinn
147			}
148			#[cfg(all(feature = "noq", not(feature = "quinn")))]
149			{
150				QuicBackend::Noq
151			}
152			#[cfg(all(feature = "quiche", not(feature = "quinn"), not(feature = "noq")))]
153			{
154				QuicBackend::Quiche
155			}
156			#[cfg(all(not(feature = "quiche"), not(feature = "quinn"), not(feature = "noq")))]
157			panic!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
158		});
159
160		let provider = crypto::provider();
161
162		// Create a list of acceptable root certificates.
163		let mut roots = rustls::RootCertStore::empty();
164
165		if config.tls.root.is_empty() {
166			let native = rustls_native_certs::load_native_certs();
167
168			// Log any errors that occurred while loading the native root certificates.
169			for err in native.errors {
170				tracing::warn!(%err, "failed to load root cert");
171			}
172
173			// Add the platform's native root certificates.
174			for cert in native.certs {
175				roots.add(cert).context("failed to add root cert")?;
176			}
177		} else {
178			// Add the specified root certificates.
179			for root in &config.tls.root {
180				let root = std::fs::File::open(root).context("failed to open root cert file")?;
181				let mut root = std::io::BufReader::new(root);
182
183				let root = rustls_pemfile::certs(&mut root)
184					.next()
185					.context("no roots found")?
186					.context("failed to read root cert")?;
187
188				roots.add(root).context("failed to add root cert")?;
189			}
190		}
191
192		// Create the TLS configuration we'll use as a client (relay -> relay)
193		let mut tls = rustls::ClientConfig::builder_with_provider(provider.clone())
194			.with_protocol_versions(&[&rustls::version::TLS13])?
195			.with_root_certificates(roots)
196			.with_no_client_auth();
197
198		// Allow disabling TLS verification altogether.
199		if config.tls.disable_verify.unwrap_or_default() {
200			tracing::warn!("TLS server certificate verification is disabled; A man-in-the-middle attack is possible.");
201
202			let noop = NoCertificateVerification(provider.clone());
203			tls.dangerous().set_certificate_verifier(Arc::new(noop));
204		}
205
206		#[cfg(feature = "noq")]
207		#[allow(unreachable_patterns)]
208		let noq = match backend {
209			QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
210			_ => None,
211		};
212
213		#[cfg(feature = "quinn")]
214		#[allow(unreachable_patterns)]
215		let quinn = match backend {
216			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
217			_ => None,
218		};
219
220		#[cfg(feature = "quiche")]
221		let quiche = match backend {
222			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
223			_ => None,
224		};
225
226		Ok(Self {
227			moq: moq_lite::Client::new().with_versions(config.versions()),
228			#[cfg(feature = "websocket")]
229			websocket: config.websocket,
230			tls,
231			#[cfg(feature = "noq")]
232			noq,
233			#[cfg(feature = "quinn")]
234			quinn,
235			#[cfg(feature = "quiche")]
236			quiche,
237			#[cfg(feature = "iroh")]
238			iroh: None,
239		})
240	}
241
242	#[cfg(feature = "iroh")]
243	pub fn with_iroh(mut self, iroh: Option<web_transport_iroh::iroh::Endpoint>) -> Self {
244		self.iroh = iroh;
245		self
246	}
247
248	pub fn with_publish(mut self, publish: impl Into<Option<moq_lite::OriginConsumer>>) -> Self {
249		self.moq = self.moq.with_publish(publish);
250		self
251	}
252
253	pub fn with_consume(mut self, consume: impl Into<Option<moq_lite::OriginProducer>>) -> Self {
254		self.moq = self.moq.with_consume(consume);
255		self
256	}
257
258	#[cfg(not(any(feature = "noq", feature = "quinn", feature = "quiche", feature = "iroh")))]
259	pub async fn connect(&self, _url: Url) -> anyhow::Result<moq_lite::Session> {
260		anyhow::bail!("no QUIC backend compiled; enable noq, quinn, quiche, or iroh feature");
261	}
262
263	#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche", feature = "iroh"))]
264	pub async fn connect(&self, url: Url) -> anyhow::Result<moq_lite::Session> {
265		#[cfg(feature = "iroh")]
266		if url.scheme() == "iroh" {
267			let endpoint = self.iroh.as_ref().context("Iroh support is not enabled")?;
268			let session = crate::iroh::connect(endpoint, url).await?;
269			let session = self.moq.connect(session).await?;
270			return Ok(session);
271		}
272
273		#[cfg(feature = "noq")]
274		if let Some(noq) = self.noq.as_ref() {
275			let tls = self.tls.clone();
276			let quic_url = url.clone();
277			let quic_handle = async {
278				let res = noq.connect(&tls, quic_url).await;
279				if let Err(err) = &res {
280					tracing::warn!(%err, "QUIC connection failed");
281				}
282				res
283			};
284
285			#[cfg(feature = "websocket")]
286			{
287				let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url);
288
289				return Ok(tokio::select! {
290					Ok(quic) = quic_handle => self.moq.connect(quic).await?,
291					Some(Ok(ws)) = ws_handle => self.moq.connect(ws).await?,
292					else => anyhow::bail!("failed to connect to server"),
293				});
294			}
295
296			#[cfg(not(feature = "websocket"))]
297			{
298				let session = quic_handle.await?;
299				return Ok(self.moq.connect(session).await?);
300			}
301		}
302
303		#[cfg(feature = "quinn")]
304		if let Some(quinn) = self.quinn.as_ref() {
305			let tls = self.tls.clone();
306			let quic_url = url.clone();
307			let quic_handle = async {
308				let res = quinn.connect(&tls, quic_url).await;
309				if let Err(err) = &res {
310					tracing::warn!(%err, "QUIC connection failed");
311				}
312				res
313			};
314
315			#[cfg(feature = "websocket")]
316			{
317				let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url);
318
319				return Ok(tokio::select! {
320					Ok(quic) = quic_handle => self.moq.connect(quic).await?,
321					Some(Ok(ws)) = ws_handle => self.moq.connect(ws).await?,
322					else => anyhow::bail!("failed to connect to server"),
323				});
324			}
325
326			#[cfg(not(feature = "websocket"))]
327			{
328				let session = quic_handle.await?;
329				return Ok(self.moq.connect(session).await?);
330			}
331		}
332
333		#[cfg(feature = "quiche")]
334		if let Some(quiche) = self.quiche.as_ref() {
335			let quic_url = url.clone();
336			let quic_handle = async {
337				let res = quiche.connect(quic_url).await;
338				if let Err(err) = &res {
339					tracing::warn!(%err, "QUIC connection failed");
340				}
341				res
342			};
343
344			#[cfg(feature = "websocket")]
345			{
346				let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url);
347
348				return Ok(tokio::select! {
349					Ok(quic) = quic_handle => self.moq.connect(quic).await?,
350					Some(Ok(ws)) = ws_handle => self.moq.connect(ws).await?,
351					else => anyhow::bail!("failed to connect to server"),
352				});
353			}
354
355			#[cfg(not(feature = "websocket"))]
356			{
357				let session = quic_handle.await?;
358				return Ok(self.moq.connect(session).await?);
359			}
360		}
361
362		anyhow::bail!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
363	}
364}
365
366use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
367
368#[derive(Debug)]
369struct NoCertificateVerification(crypto::Provider);
370
371impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
372	fn verify_server_cert(
373		&self,
374		_end_entity: &CertificateDer<'_>,
375		_intermediates: &[CertificateDer<'_>],
376		_server_name: &ServerName<'_>,
377		_ocsp: &[u8],
378		_now: UnixTime,
379	) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
380		Ok(rustls::client::danger::ServerCertVerified::assertion())
381	}
382
383	fn verify_tls12_signature(
384		&self,
385		message: &[u8],
386		cert: &CertificateDer<'_>,
387		dss: &rustls::DigitallySignedStruct,
388	) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
389		rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms)
390	}
391
392	fn verify_tls13_signature(
393		&self,
394		message: &[u8],
395		cert: &CertificateDer<'_>,
396		dss: &rustls::DigitallySignedStruct,
397	) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
398		rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms)
399	}
400
401	fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
402		self.0.signature_verification_algorithms.supported_schemes()
403	}
404}
405
406#[cfg(test)]
407mod tests {
408	use super::*;
409	use clap::Parser;
410
411	#[test]
412	fn test_toml_disable_verify_survives_update_from() {
413		let toml = r#"
414			tls.disable_verify = true
415		"#;
416
417		let mut config: ClientConfig = toml::from_str(toml).unwrap();
418		assert_eq!(config.tls.disable_verify, Some(true));
419
420		// Simulate: TOML loaded, then CLI args re-applied (no --tls-disable-verify flag).
421		config.update_from(["test"]);
422		assert_eq!(config.tls.disable_verify, Some(true));
423	}
424
425	#[test]
426	fn test_cli_disable_verify_flag() {
427		let config = ClientConfig::parse_from(["test", "--tls-disable-verify"]);
428		assert_eq!(config.tls.disable_verify, Some(true));
429	}
430
431	#[test]
432	fn test_cli_disable_verify_explicit_false() {
433		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=false"]);
434		assert_eq!(config.tls.disable_verify, Some(false));
435	}
436
437	#[test]
438	fn test_cli_disable_verify_explicit_true() {
439		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true"]);
440		assert_eq!(config.tls.disable_verify, Some(true));
441	}
442
443	#[test]
444	fn test_cli_no_disable_verify() {
445		let config = ClientConfig::parse_from(["test"]);
446		assert_eq!(config.tls.disable_verify, None);
447	}
448
449	#[test]
450	fn test_toml_version_survives_update_from() {
451		let toml = r#"
452			version = ["moq-lite-02"]
453		"#;
454
455		let mut config: ClientConfig = toml::from_str(toml).unwrap();
456		assert_eq!(
457			config.version,
458			vec!["moq-lite-02".parse::<moq_lite::Version>().unwrap()]
459		);
460
461		// Simulate: TOML loaded, then CLI args re-applied (no --client-version flag).
462		config.update_from(["test"]);
463		assert_eq!(
464			config.version,
465			vec!["moq-lite-02".parse::<moq_lite::Version>().unwrap()]
466		);
467	}
468
469	#[test]
470	fn test_cli_version() {
471		let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
472		assert_eq!(
473			config.version,
474			vec!["moq-lite-03".parse::<moq_lite::Version>().unwrap()]
475		);
476	}
477
478	#[test]
479	fn test_cli_no_version_defaults_to_all() {
480		let config = ClientConfig::parse_from(["test"]);
481		assert!(config.version.is_empty());
482		// versions() helper returns all when none specified
483		assert_eq!(config.versions().alpns().len(), moq_lite::ALPNS.len());
484	}
485}