1use crate::QuicBackend;
2use crate::crypto;
3use anyhow::Context;
4use std::path::PathBuf;
5use std::{net, sync::Arc};
6use url::Url;
7
8#[derive(Clone, Default, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
10#[serde(default, deny_unknown_fields)]
11#[non_exhaustive]
12pub struct ClientTls {
13 #[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 #[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#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
39#[serde(deny_unknown_fields, default)]
40#[non_exhaustive]
41pub struct ClientConfig {
42 #[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 #[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
54 pub backend: Option<QuicBackend>,
55
56 #[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 #[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 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#[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 #[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 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 for err in native.errors {
170 tracing::warn!(%err, "failed to load root cert");
171 }
172
173 for cert in native.certs {
175 roots.add(cert).context("failed to add root cert")?;
176 }
177 } else {
178 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 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 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 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 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 assert_eq!(config.versions().alpns().len(), moq_lite::ALPNS.len());
484 }
485}