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 #[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#[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 #[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 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 for err in native.errors {
133 tracing::warn!(%err, "failed to load root cert");
134 }
135
136 for cert in native.certs {
138 roots.add(cert).context("failed to add root cert")?;
139 }
140 } else {
141 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 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 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 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}