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