1use crate::{Backoff, Error, QuicBackend, Reconnect};
2#[cfg(feature = "websocket")]
3use std::future::Future;
4use std::net;
5use url::Url;
6
7#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
9#[serde(deny_unknown_fields, default)]
10#[non_exhaustive]
11pub struct ClientConfig {
12 #[arg(
14 id = "client-bind",
15 long = "client-bind",
16 default_value = "[::]:0",
17 env = "MOQ_CLIENT_BIND"
18 )]
19 pub bind: net::SocketAddr,
20
21 #[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
24 pub backend: Option<QuicBackend>,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
28 #[arg(
29 id = "client-max-streams",
30 long = "client-max-streams",
31 env = "MOQ_CLIENT_MAX_STREAMS"
32 )]
33 pub max_streams: Option<u64>,
34
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 #[arg(id = "client-version", long = "client-version", env = "MOQ_CLIENT_VERSION")]
44 pub version: Vec<moq_net::Version>,
45
46 #[command(flatten)]
47 #[serde(default)]
48 pub tls: crate::tls::Client,
49
50 #[command(flatten)]
51 #[serde(default)]
52 pub backoff: Backoff,
53
54 #[cfg(feature = "websocket")]
55 #[command(flatten)]
56 #[serde(default)]
57 pub websocket: crate::websocket::Client,
58}
59
60impl ClientConfig {
61 pub fn init(self) -> crate::Result<Client> {
62 Client::new(self)
63 }
64
65 pub fn versions(&self) -> moq_net::Versions {
67 if self.version.is_empty() {
68 moq_net::Versions::all()
69 } else {
70 moq_net::Versions::from(self.version.clone())
71 }
72 }
73}
74
75impl Default for ClientConfig {
76 fn default() -> Self {
77 Self {
78 bind: "[::]:0".parse().unwrap(),
79 backend: None,
80 max_streams: None,
81 version: Vec::new(),
82 tls: crate::tls::Client::default(),
83 backoff: Backoff::default(),
84 #[cfg(feature = "websocket")]
85 websocket: crate::websocket::Client::default(),
86 }
87 }
88}
89
90#[derive(Clone)]
94pub struct Client {
95 moq: moq_net::Client,
96 versions: moq_net::Versions,
97 backoff: Backoff,
98 #[cfg(feature = "websocket")]
99 websocket: crate::websocket::Client,
100 tls: rustls::ClientConfig,
101 #[cfg(feature = "noq")]
102 noq: Option<crate::noq::NoqClient>,
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 #[cfg(feature = "iroh")]
110 iroh_addrs: Vec<std::net::SocketAddr>,
111}
112
113impl Client {
114 #[cfg(not(any(
115 feature = "noq",
116 feature = "quinn",
117 feature = "quiche",
118 feature = "websocket",
119 feature = "tcp",
120 feature = "uds"
121 )))]
122 pub fn new(_config: ClientConfig) -> crate::Result<Self> {
123 Err(Error::NoBackend(
124 "no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
125 ))
126 }
127
128 #[cfg(any(
130 feature = "noq",
131 feature = "quinn",
132 feature = "quiche",
133 feature = "websocket",
134 feature = "tcp",
135 feature = "uds"
136 ))]
137 pub fn new(config: ClientConfig) -> crate::Result<Self> {
138 #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
139 let backend = config.backend.clone().unwrap_or({
140 #[cfg(feature = "quinn")]
141 {
142 QuicBackend::Quinn
143 }
144 #[cfg(all(feature = "noq", not(feature = "quinn")))]
145 {
146 QuicBackend::Noq
147 }
148 #[cfg(all(feature = "quiche", not(feature = "quinn"), not(feature = "noq")))]
149 {
150 QuicBackend::Quiche
151 }
152 #[cfg(all(not(feature = "quiche"), not(feature = "quinn"), not(feature = "noq")))]
153 panic!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
154 });
155
156 let tls = config.tls.build()?;
157
158 #[cfg(feature = "noq")]
159 #[allow(unreachable_patterns)]
160 let noq = match backend {
161 QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
162 _ => None,
163 };
164
165 #[cfg(feature = "quinn")]
166 #[allow(unreachable_patterns)]
167 let quinn = match backend {
168 QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
169 _ => None,
170 };
171
172 #[cfg(feature = "quiche")]
173 let quiche = match backend {
174 QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
175 _ => None,
176 };
177
178 let versions = config.versions();
179 Ok(Self {
180 moq: moq_net::Client::new().with_versions(versions.clone()),
181 versions,
182 backoff: config.backoff,
183 #[cfg(feature = "websocket")]
184 websocket: config.websocket,
185 tls,
186 #[cfg(feature = "noq")]
187 noq,
188 #[cfg(feature = "quinn")]
189 quinn,
190 #[cfg(feature = "quiche")]
191 quiche,
192 #[cfg(feature = "iroh")]
193 iroh: None,
194 #[cfg(feature = "iroh")]
195 iroh_addrs: Vec::new(),
196 })
197 }
198
199 #[cfg(feature = "iroh")]
200 pub fn with_iroh(mut self, iroh: Option<web_transport_iroh::iroh::Endpoint>) -> Self {
201 self.iroh = iroh;
202 self
203 }
204
205 #[cfg(feature = "iroh")]
210 pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
211 self.iroh_addrs = addrs;
212 self
213 }
214
215 pub fn with_publish(mut self, publish: impl Into<Option<moq_net::OriginConsumer>>) -> Self {
216 self.moq = self.moq.with_publish(publish);
217 self
218 }
219
220 pub fn with_consume(mut self, consume: impl Into<Option<moq_net::OriginProducer>>) -> Self {
221 self.moq = self.moq.with_consume(consume);
222 self
223 }
224
225 pub fn with_stats(mut self, stats: moq_net::StatsHandle) -> Self {
227 self.moq = self.moq.with_stats(stats);
228 self
229 }
230
231 pub fn reconnect(&self, url: Url) -> Reconnect {
236 Reconnect::new(self.clone(), url, self.backoff.clone())
237 }
238
239 #[cfg(not(any(
240 feature = "noq",
241 feature = "quinn",
242 feature = "quiche",
243 feature = "iroh",
244 feature = "websocket",
245 feature = "tcp",
246 feature = "uds"
247 )))]
248 pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
249 Err(Error::NoBackend(
250 "no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
251 ))
252 }
253
254 #[cfg(any(
255 feature = "noq",
256 feature = "quinn",
257 feature = "quiche",
258 feature = "iroh",
259 feature = "websocket",
260 feature = "tcp",
261 feature = "uds"
262 ))]
263 pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
264 let session = self.connect_inner(url).await?;
265 tracing::info!(version = %session.version(), "connected");
266 Ok(session)
267 }
268
269 #[cfg(any(
270 feature = "noq",
271 feature = "quinn",
272 feature = "quiche",
273 feature = "iroh",
274 feature = "websocket",
275 feature = "tcp",
276 feature = "uds"
277 ))]
278 async fn connect_inner(&self, url: Url) -> crate::Result<moq_net::Session> {
279 #[cfg(feature = "tcp")]
282 if url.scheme() == "tcp" {
283 let session = crate::tcp::connect(url, &self.versions.alpns()).await?;
284 return Ok(self.moq.connect(session).await?);
285 }
286
287 #[cfg(all(feature = "uds", unix))]
290 if url.scheme() == "unix" {
291 let session = crate::unix::connect(url, &self.versions.alpns()).await?;
292 return Ok(self.moq.connect(session).await?);
293 }
294
295 #[cfg(feature = "iroh")]
296 if url.scheme() == "iroh" {
297 let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
298 let session = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
299 let session = self.moq.connect(session).await?;
300 return Ok(session);
301 }
302
303 #[cfg(feature = "noq")]
304 if let Some(noq) = self.noq.as_ref() {
305 let tls = self.tls.clone();
306 let quic_url = url.clone();
307 let quic_handle = async { noq.connect(&tls, quic_url).await.map_err(Error::from) };
308
309 #[cfg(feature = "websocket")]
310 {
311 return self.race_moq_connect(url, quic_handle).await;
312 }
313
314 #[cfg(not(feature = "websocket"))]
315 {
316 let session = quic_handle.await?;
317 return Ok(self.moq.connect(session).await?);
318 }
319 }
320
321 #[cfg(feature = "quinn")]
322 if let Some(quinn) = self.quinn.as_ref() {
323 let tls = self.tls.clone();
324 let quic_url = url.clone();
325 let quic_handle = async { quinn.connect(&tls, quic_url).await.map_err(Error::from) };
326
327 #[cfg(feature = "websocket")]
328 {
329 return self.race_moq_connect(url, quic_handle).await;
330 }
331
332 #[cfg(not(feature = "websocket"))]
333 {
334 let session = quic_handle.await?;
335 return Ok(self.moq.connect(session).await?);
336 }
337 }
338
339 #[cfg(feature = "quiche")]
340 if let Some(quiche) = self.quiche.as_ref() {
341 let quic_url = url.clone();
342 let quic_handle = async { quiche.connect(quic_url).await.map_err(Error::from) };
343
344 #[cfg(feature = "websocket")]
345 {
346 return self.race_moq_connect(url, quic_handle).await;
347 }
348
349 #[cfg(not(feature = "websocket"))]
350 {
351 let session = quic_handle.await?;
352 return Ok(self.moq.connect(session).await?);
353 }
354 }
355
356 #[cfg(feature = "websocket")]
357 {
358 let alpns = self.versions.alpns();
359 let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
360 return Ok(self.moq.connect(session).await?);
361 }
362
363 #[cfg(not(feature = "websocket"))]
364 return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
365 }
366
367 #[cfg(feature = "websocket")]
368 async fn race_moq_connect<Q, S>(&self, url: Url, quic: Q) -> crate::Result<moq_net::Session>
369 where
370 Q: Future<Output = crate::Result<S>>,
371 S: web_transport_trait::Session,
372 {
373 let alpns = self.versions.alpns();
374 let ws_config = self.websocket.clone();
375 let ws_tls = self.tls.clone();
376 let websocket = async move {
377 crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
378 .await
379 .map(|res| res.map_err(Error::from))
380 };
381
382 match race_transport_connect(quic, websocket).await? {
383 TransportRace::Quic(quic) => Ok(self.moq.connect(quic).await?),
384 TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
385 }
386 }
387}
388
389#[cfg(feature = "websocket")]
390#[derive(Debug, PartialEq, Eq)]
391enum TransportRace<Q, W> {
392 Quic(Q),
393 WebSocket(W),
394}
395
396#[cfg(feature = "websocket")]
397async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
398where
399 Q: Future<Output = crate::Result<QT>>,
400 W: Future<Output = Option<crate::Result<WT>>>,
401{
402 tokio::pin!(quic);
403 tokio::pin!(websocket);
404
405 let mut quic_err = None;
406 let mut websocket_err = None;
407 let mut quic_done = false;
408 let mut websocket_done = false;
409
410 loop {
411 tokio::select! {
412 res = &mut quic, if !quic_done => {
413 match res {
414 Ok(session) => return Ok(TransportRace::Quic(session)),
415 Err(err) if err.is_auth() => return Err(err),
416 Err(err) => {
417 tracing::warn!(%err, "QUIC connection failed");
418 quic_err = Some(err);
419 quic_done = true;
420 }
421 }
422 }
423 res = &mut websocket, if !websocket_done => {
424 match res {
425 Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
426 Some(Err(err)) if err.is_auth() => return Err(err),
427 Some(Err(err)) => {
428 tracing::warn!(%err, "WebSocket connection failed");
429 websocket_err = Some(err);
430 websocket_done = true;
431 }
432 None => {
433 websocket_done = true;
434 }
435 }
436 }
437 else => break,
438 }
439
440 if quic_done && websocket_done {
441 break;
442 }
443 }
444
445 match (quic_err, websocket_err) {
446 (Some(quic), Some(websocket)) => Err(Error::TransportRace {
447 quic: std::sync::Arc::new(quic),
448 websocket: std::sync::Arc::new(websocket),
449 }),
450 (Some(err), None) | (None, Some(err)) => Err(err),
451 (None, None) => Err(Error::ConnectFailed),
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use clap::Parser;
459
460 #[test]
461 fn test_toml_disable_verify_survives_update_from() {
462 let toml = r#"
463 tls.disable_verify = true
464 "#;
465
466 let mut config: ClientConfig = toml::from_str(toml).unwrap();
467 assert_eq!(config.tls.disable_verify, Some(true));
468
469 config.update_from(["test"]);
471 assert_eq!(config.tls.disable_verify, Some(true));
472 }
473
474 #[test]
475 fn test_cli_disable_verify_flag() {
476 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
477 assert_eq!(config.tls.disable_verify, Some(true));
478 }
479
480 #[test]
481 fn test_cli_disable_verify_explicit_false() {
482 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
483 assert_eq!(config.tls.disable_verify, Some(false));
484 }
485
486 #[test]
487 fn test_cli_disable_verify_explicit_true() {
488 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
489 assert_eq!(config.tls.disable_verify, Some(true));
490 }
491
492 #[test]
493 fn test_cli_deprecated_tls_flags_fold_into_canonical() {
494 let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
498 assert_eq!(
499 config.tls.disable_verify, None,
500 "deprecated flag must not set the canonical field"
501 );
502 assert_eq!(config.tls.effective_disable_verify(), Some(true));
503 assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
504 }
505
506 #[test]
507 fn test_canonical_tls_flag_wins_over_deprecated() {
508 let config = ClientConfig::parse_from([
510 "test",
511 "--client-tls-disable-verify=false",
512 "--tls-disable-verify=true",
513 "--client-tls-fingerprint",
514 "aaaa",
515 "--tls-fingerprint",
516 "bbbb",
517 ]);
518 assert_eq!(config.tls.effective_disable_verify(), Some(false));
519 assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
520 }
521
522 #[test]
523 fn test_cli_no_disable_verify() {
524 let config = ClientConfig::parse_from(["test"]);
525 assert_eq!(config.tls.disable_verify, None);
526 }
527
528 #[test]
529 fn test_toml_fingerprint_survives_update_from() {
530 let toml = r#"
531 tls.fingerprint = ["abcd1234", "ef567890"]
532 "#;
533
534 let mut config: ClientConfig = toml::from_str(toml).unwrap();
535 assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
536
537 config.update_from(["test"]);
539 assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
540 }
541
542 #[test]
543 fn test_toml_fingerprint_accepts_single_string() {
544 let toml = r#"
545 tls.fingerprint = "abcd1234"
546 "#;
547
548 let config: ClientConfig = toml::from_str(toml).unwrap();
549 assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
550 }
551
552 #[test]
553 fn test_cli_fingerprint() {
554 let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
555 assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
556 }
557
558 #[test]
559 fn test_toml_version_survives_update_from() {
560 let toml = r#"
561 version = ["moq-lite-02"]
562 "#;
563
564 let mut config: ClientConfig = toml::from_str(toml).unwrap();
565 assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
566
567 config.update_from(["test"]);
569 assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
570 }
571
572 #[test]
573 fn test_cli_version() {
574 let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
575 assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
576 }
577
578 #[test]
579 fn test_cli_no_version_defaults_to_all() {
580 let config = ClientConfig::parse_from(["test"]);
581 assert!(config.version.is_empty());
582 assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
584 }
585
586 #[cfg(feature = "websocket")]
587 #[tokio::test]
588 async fn race_transport_connect_stops_on_quic_auth_error() {
589 let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
590 let websocket = async {
591 tokio::task::yield_now().await;
593 Some(Ok(1usize))
594 };
595
596 let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
597 assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
598 }
599
600 #[cfg(feature = "websocket")]
601 #[tokio::test]
602 async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
603 let quic = async { Err::<usize, _>(Error::ConnectFailed) };
604 let websocket = async { Some(Ok(7usize)) };
605
606 let value = super::race_transport_connect(quic, websocket).await.unwrap();
607 assert_eq!(value, super::TransportRace::WebSocket(7));
608 }
609
610 #[cfg(feature = "websocket")]
611 #[tokio::test]
612 async fn race_transport_connect_returns_when_quic_transport_connects() {
613 let quic = async { Ok("quic") };
614 let websocket = std::future::pending::<Option<crate::Result<&str>>>();
615
616 let value = tokio::time::timeout(
617 std::time::Duration::from_secs(1),
618 super::race_transport_connect(quic, websocket),
619 )
620 .await
621 .expect("race waited for WebSocket after QUIC transport connected")
622 .unwrap();
623 assert_eq!(value, super::TransportRace::Quic("quic"));
624 }
625}