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	#[command(flatten)]
66	#[serde(default)]
67	pub tls: ClientTls,
68
69	#[cfg(feature = "websocket")]
70	#[command(flatten)]
71	#[serde(default)]
72	pub websocket: super::ClientWebSocket,
73}
74
75impl ClientConfig {
76	pub fn init(self) -> anyhow::Result<Client> {
77		Client::new(self)
78	}
79}
80
81impl Default for ClientConfig {
82	fn default() -> Self {
83		Self {
84			bind: "[::]:0".parse().unwrap(),
85			backend: None,
86			max_streams: None,
87			tls: ClientTls::default(),
88			#[cfg(feature = "websocket")]
89			websocket: super::ClientWebSocket::default(),
90		}
91	}
92}
93
94/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
95///
96/// Create via [`ClientConfig::init`] or [`Client::new`].
97#[derive(Clone)]
98pub struct Client {
99	moq: moq_lite::Client,
100	#[cfg(feature = "websocket")]
101	websocket: super::ClientWebSocket,
102	tls: rustls::ClientConfig,
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}
110
111impl Client {
112	#[cfg(not(any(feature = "quinn", feature = "quiche")))]
113	pub fn new(_config: ClientConfig) -> anyhow::Result<Self> {
114		anyhow::bail!("no QUIC backend compiled; enable quinn or quiche feature");
115	}
116
117	/// Create a new client
118	#[cfg(any(feature = "quinn", feature = "quiche"))]
119	pub fn new(config: ClientConfig) -> anyhow::Result<Self> {
120		let backend = config.backend.clone().unwrap_or({
121			#[cfg(feature = "quinn")]
122			{
123				QuicBackend::Quinn
124			}
125			#[cfg(all(feature = "quiche", not(feature = "quinn")))]
126			{
127				QuicBackend::Quiche
128			}
129			#[cfg(all(not(feature = "quiche"), not(feature = "quinn")))]
130			panic!("no QUIC backend compiled; enable quinn or quiche feature");
131		});
132
133		let provider = crypto::provider();
134
135		// Create a list of acceptable root certificates.
136		let mut roots = rustls::RootCertStore::empty();
137
138		if config.tls.root.is_empty() {
139			let native = rustls_native_certs::load_native_certs();
140
141			// Log any errors that occurred while loading the native root certificates.
142			for err in native.errors {
143				tracing::warn!(%err, "failed to load root cert");
144			}
145
146			// Add the platform's native root certificates.
147			for cert in native.certs {
148				roots.add(cert).context("failed to add root cert")?;
149			}
150		} else {
151			// Add the specified root certificates.
152			for root in &config.tls.root {
153				let root = std::fs::File::open(root).context("failed to open root cert file")?;
154				let mut root = std::io::BufReader::new(root);
155
156				let root = rustls_pemfile::certs(&mut root)
157					.next()
158					.context("no roots found")?
159					.context("failed to read root cert")?;
160
161				roots.add(root).context("failed to add root cert")?;
162			}
163		}
164
165		// Create the TLS configuration we'll use as a client (relay -> relay)
166		let mut tls = rustls::ClientConfig::builder_with_provider(provider.clone())
167			.with_protocol_versions(&[&rustls::version::TLS13])?
168			.with_root_certificates(roots)
169			.with_no_client_auth();
170
171		// Allow disabling TLS verification altogether.
172		if config.tls.disable_verify.unwrap_or_default() {
173			tracing::warn!("TLS server certificate verification is disabled; A man-in-the-middle attack is possible.");
174
175			let noop = NoCertificateVerification(provider.clone());
176			tls.dangerous().set_certificate_verifier(Arc::new(noop));
177		}
178
179		#[cfg(feature = "quinn")]
180		#[allow(unreachable_patterns)]
181		let quinn = match backend {
182			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
183			_ => None,
184		};
185
186		#[cfg(feature = "quiche")]
187		let quiche = match backend {
188			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
189			_ => None,
190		};
191
192		Ok(Self {
193			moq: moq_lite::Client::new(),
194			#[cfg(feature = "websocket")]
195			websocket: config.websocket,
196			tls,
197			#[cfg(feature = "quinn")]
198			quinn,
199			#[cfg(feature = "quiche")]
200			quiche,
201			#[cfg(feature = "iroh")]
202			iroh: None,
203		})
204	}
205
206	#[cfg(feature = "iroh")]
207	pub fn with_iroh(mut self, iroh: Option<web_transport_iroh::iroh::Endpoint>) -> Self {
208		self.iroh = iroh;
209		self
210	}
211
212	pub fn with_publish(mut self, publish: impl Into<Option<moq_lite::OriginConsumer>>) -> Self {
213		self.moq = self.moq.with_publish(publish);
214		self
215	}
216
217	pub fn with_consume(mut self, consume: impl Into<Option<moq_lite::OriginProducer>>) -> Self {
218		self.moq = self.moq.with_consume(consume);
219		self
220	}
221
222	#[cfg(not(any(feature = "quinn", feature = "quiche", feature = "iroh")))]
223	pub async fn connect(&self, _url: Url) -> anyhow::Result<moq_lite::Session> {
224		anyhow::bail!("no QUIC backend compiled; enable quinn, quiche, or iroh feature");
225	}
226
227	#[cfg(any(feature = "quinn", feature = "quiche", feature = "iroh"))]
228	pub async fn connect(&self, url: Url) -> anyhow::Result<moq_lite::Session> {
229		#[cfg(feature = "iroh")]
230		if url.scheme() == "iroh" {
231			let endpoint = self.iroh.as_ref().context("Iroh support is not enabled")?;
232			let session = crate::iroh::connect(endpoint, url).await?;
233			let session = self.moq.connect(session).await?;
234			return Ok(session);
235		}
236
237		#[cfg(feature = "quinn")]
238		if let Some(quinn) = self.quinn.as_ref() {
239			let tls = self.tls.clone();
240			let quic_url = url.clone();
241			let quic_handle = async {
242				let res = quinn.connect(&tls, quic_url).await;
243				if let Err(err) = &res {
244					tracing::warn!(%err, "QUIC connection failed");
245				}
246				res
247			};
248
249			#[cfg(feature = "websocket")]
250			{
251				let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url);
252
253				return Ok(tokio::select! {
254					Ok(quic) = quic_handle => self.moq.connect(quic).await?,
255					Some(Ok(ws)) = ws_handle => self.moq.connect(ws).await?,
256					else => anyhow::bail!("failed to connect to server"),
257				});
258			}
259
260			#[cfg(not(feature = "websocket"))]
261			{
262				let session = quic_handle.await?;
263				return Ok(self.moq.connect(session).await?);
264			}
265		}
266
267		#[cfg(feature = "quiche")]
268		if let Some(quiche) = self.quiche.as_ref() {
269			let quic_url = url.clone();
270			let quic_handle = async {
271				let res = quiche.connect(quic_url).await;
272				if let Err(err) = &res {
273					tracing::warn!(%err, "QUIC connection failed");
274				}
275				res
276			};
277
278			#[cfg(feature = "websocket")]
279			{
280				let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url);
281
282				return Ok(tokio::select! {
283					Ok(quic) = quic_handle => self.moq.connect(quic).await?,
284					Some(Ok(ws)) = ws_handle => self.moq.connect(ws).await?,
285					else => anyhow::bail!("failed to connect to server"),
286				});
287			}
288
289			#[cfg(not(feature = "websocket"))]
290			{
291				let session = quic_handle.await?;
292				return Ok(self.moq.connect(session).await?);
293			}
294		}
295
296		anyhow::bail!("no QUIC backend compiled; enable quinn or quiche feature");
297	}
298}
299
300use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
301
302#[derive(Debug)]
303struct NoCertificateVerification(crypto::Provider);
304
305impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
306	fn verify_server_cert(
307		&self,
308		_end_entity: &CertificateDer<'_>,
309		_intermediates: &[CertificateDer<'_>],
310		_server_name: &ServerName<'_>,
311		_ocsp: &[u8],
312		_now: UnixTime,
313	) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
314		Ok(rustls::client::danger::ServerCertVerified::assertion())
315	}
316
317	fn verify_tls12_signature(
318		&self,
319		message: &[u8],
320		cert: &CertificateDer<'_>,
321		dss: &rustls::DigitallySignedStruct,
322	) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
323		rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms)
324	}
325
326	fn verify_tls13_signature(
327		&self,
328		message: &[u8],
329		cert: &CertificateDer<'_>,
330		dss: &rustls::DigitallySignedStruct,
331	) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
332		rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms)
333	}
334
335	fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
336		self.0.signature_verification_algorithms.supported_schemes()
337	}
338}
339
340#[cfg(test)]
341mod tests {
342	use super::*;
343	use clap::Parser;
344
345	#[test]
346	fn test_toml_disable_verify_survives_update_from() {
347		let toml = r#"
348			tls.disable_verify = true
349		"#;
350
351		let mut config: ClientConfig = toml::from_str(toml).unwrap();
352		assert_eq!(config.tls.disable_verify, Some(true));
353
354		// Simulate: TOML loaded, then CLI args re-applied (no --tls-disable-verify flag).
355		config.update_from(["test"]);
356		assert_eq!(config.tls.disable_verify, Some(true));
357	}
358
359	#[test]
360	fn test_cli_disable_verify_flag() {
361		let config = ClientConfig::parse_from(["test", "--tls-disable-verify"]);
362		assert_eq!(config.tls.disable_verify, Some(true));
363	}
364
365	#[test]
366	fn test_cli_disable_verify_explicit_false() {
367		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=false"]);
368		assert_eq!(config.tls.disable_verify, Some(false));
369	}
370
371	#[test]
372	fn test_cli_disable_verify_explicit_true() {
373		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true"]);
374		assert_eq!(config.tls.disable_verify, Some(true));
375	}
376
377	#[test]
378	fn test_cli_no_disable_verify() {
379		let config = ClientConfig::parse_from(["test"]);
380		assert_eq!(config.tls.disable_verify, None);
381	}
382}