Skip to main content

moq_native/
client.rs

1use crate::{Backoff, Error, QuicBackend, Reconnect};
2#[cfg(feature = "websocket")]
3use std::future::Future;
4use std::net;
5use url::Url;
6
7const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
8
9/// Configuration for the MoQ client.
10#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
11#[serde(deny_unknown_fields, default)]
12#[non_exhaustive]
13pub struct ClientConfig {
14	/// The URL to dial.
15	///
16	/// Supports WebTransport (`https`/`http`), WebSocket (`ws`/`wss`), raw QUIC
17	/// (`moqt`/`moql`), qmux over `tcp`/`unix`, and `iroh`. The URL path is the
18	/// request/auth path (e.g. `/anon` for a public relay) and `?jwt=` supplies a
19	/// token. `http://` first fetches `/certificate.sha256` for the (insecure)
20	/// self-signed fingerprint; `https://` connects directly.
21	#[serde(skip_serializing_if = "Option::is_none")]
22	#[arg(id = "client-connect", long = "client-connect", env = "MOQ_CLIENT_CONNECT")]
23	pub connect: Option<Url>,
24
25	/// Listen for UDP packets on the given address.
26	#[arg(
27		id = "client-bind",
28		long = "client-bind",
29		default_value = "[::]:0",
30		env = "MOQ_CLIENT_BIND"
31	)]
32	pub bind: net::SocketAddr,
33
34	/// The QUIC backend to use.
35	/// Auto-detected from compiled features if not specified.
36	#[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
37	pub backend: Option<QuicBackend>,
38
39	/// Delay before also dialing the next resolved address (Happy Eyeballs).
40	///
41	/// When DNS returns multiple addresses, attempts alternate between IPv6 and
42	/// IPv4, each starting this long after the previous one (or immediately when
43	/// it fails), and the first connection to complete wins. `0s` dials every
44	/// address at once. Defaults to 250ms. Applies to the QUIC and `tcp://` dials.
45	///
46	/// This staggers the attempts within one [`Client::connect`]; [`Self::timeout`]
47	/// bounds that call as a whole.
48	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
49	#[arg(
50		id = "client-failover-delay",
51		long = "client-failover-delay",
52		env = "MOQ_CLIENT_FAILOVER_DELAY",
53		value_parser = humantime::parse_duration,
54	)]
55	pub failover_delay: Option<std::time::Duration>,
56
57	/// Maximum time for one [`Client::connect`], covering the dial and the MoQ
58	/// handshake. Defaults to 30 seconds; set to 0 to wait forever.
59	///
60	/// This has to live above the transports rather than inside one: QUIC bounds its
61	/// own dial, but the WebSocket fallback and the handshake that follows either
62	/// transport have no deadline of their own, so a peer that accepts TCP and then
63	/// never speaks would hang the whole connect. [`Client::reconnect`] only re-arms
64	/// its backoff between attempts, so an attempt that never returns stalls the
65	/// retry loop indefinitely.
66	#[arg(
67		id = "client-connect-timeout",
68		long = "client-connect-timeout",
69		env = "MOQ_CLIENT_CONNECT_TIMEOUT",
70		value_parser = humantime::parse_duration,
71	)]
72	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
73	pub timeout: Option<std::time::Duration>,
74
75	/// QUIC transport tuning (`--client-quic-*`): stream limits, GSO, timeouts.
76	#[command(flatten)]
77	#[serde(default)]
78	pub quic: crate::quic::Client,
79
80	/// Restrict the client to specific MoQ protocol version(s).
81	///
82	/// By default, the client offers all supported versions and lets the server choose.
83	/// Use this to force a specific version, e.g. `--client-version moq-lite-02`.
84	/// Can be specified multiple times to offer a subset of versions.
85	///
86	/// Valid values: moq-lite-01, moq-lite-02, moq-lite-03, moq-transport-14, moq-transport-15, moq-transport-16, moq-transport-17
87	#[serde(default, skip_serializing_if = "Vec::is_empty")]
88	#[arg(id = "client-version", long = "client-version", env = "MOQ_CLIENT_VERSION")]
89	pub version: Vec<moq_net::Version>,
90
91	/// TLS trust and client-certificate settings (`--client-tls-*`).
92	#[command(flatten)]
93	#[serde(default)]
94	pub tls: crate::tls::Client,
95
96	/// Retry pacing for [`Client::reconnect`] (`--client-backoff-*`).
97	#[command(flatten)]
98	#[serde(default)]
99	pub backoff: Backoff,
100
101	/// WebSocket fallback settings (`--client-websocket-*`), used when QUIC is
102	/// blocked.
103	#[cfg(feature = "websocket")]
104	#[command(flatten)]
105	#[serde(default)]
106	pub websocket: crate::websocket::Client,
107}
108
109impl ClientConfig {
110	/// Build the [`Client`] this config describes.
111	pub fn init(self) -> crate::Result<Client> {
112		Client::new(self)
113	}
114
115	/// Returns the configured versions, defaulting to all if none specified.
116	pub fn versions(&self) -> moq_net::Versions {
117		if self.version.is_empty() {
118			moq_net::Versions::all()
119		} else {
120			moq_net::Versions::from(self.version.clone())
121		}
122	}
123
124	/// The Happy Eyeballs stagger to use, resolving the default. Every backend
125	/// reads it from here so the four dial paths can't drift apart.
126	#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche", feature = "tcp"))]
127	pub(crate) fn effective_failover_delay(&self) -> std::time::Duration {
128		self.failover_delay.unwrap_or(crate::failover::DEFAULT_DELAY)
129	}
130
131	fn connect_timeout(&self) -> std::time::Duration {
132		self.timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT)
133	}
134}
135
136impl Default for ClientConfig {
137	fn default() -> Self {
138		Self {
139			connect: None,
140			bind: "[::]:0".parse().unwrap(),
141			backend: None,
142			failover_delay: None,
143			timeout: None,
144			quic: crate::quic::Client::default(),
145			version: Vec::new(),
146			tls: crate::tls::Client::default(),
147			backoff: Backoff::default(),
148			#[cfg(feature = "websocket")]
149			websocket: crate::websocket::Client::default(),
150		}
151	}
152}
153
154/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
155///
156/// Create via [`ClientConfig::init`] or [`Client::new`].
157#[derive(Clone)]
158pub struct Client {
159	moq: moq_net::Client,
160	/// The single resolved set of protocol versions, used to advertise moq ALPNs across
161	/// every transport (passed into the QUIC backends' `connect` and used directly for
162	/// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list
163	/// can't diverge between transports.
164	versions: moq_net::Versions,
165	/// The URL from [`ClientConfig::connect`], dialed by [`Client::publish`] / [`Client::consume`].
166	connect: Option<Url>,
167	/// Deadline for one [`Client::connect`], from [`ClientConfig::timeout`]. Zero waits forever.
168	timeout: std::time::Duration,
169	backoff: Backoff,
170	/// The resolved Happy Eyeballs stagger, used by the `tcp://` dial here; the
171	/// QUIC backends capture their own copy from the config.
172	#[cfg(feature = "tcp")]
173	failover_delay: std::time::Duration,
174	#[cfg(feature = "websocket")]
175	websocket: crate::websocket::Client,
176	tls: rustls::ClientConfig,
177	#[cfg(feature = "noq")]
178	noq: Option<crate::noq::NoqClient>,
179	#[cfg(feature = "quinn")]
180	quinn: Option<crate::quinn::QuinnClient>,
181	#[cfg(feature = "quiche")]
182	quiche: Option<crate::quiche::QuicheClient>,
183	#[cfg(feature = "iroh")]
184	iroh: Option<crate::iroh::Endpoint>,
185	#[cfg(feature = "iroh")]
186	iroh_addrs: Vec<std::net::SocketAddr>,
187}
188
189impl Client {
190	/// Build a client from its config.
191	///
192	/// Errors if no transport feature is compiled in.
193	#[cfg(not(any(
194		feature = "noq",
195		feature = "quinn",
196		feature = "quiche",
197		feature = "websocket",
198		feature = "tcp",
199		feature = "uds"
200	)))]
201	pub fn new(_config: ClientConfig) -> crate::Result<Self> {
202		Err(Error::NoBackend(
203			"no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
204		))
205	}
206
207	/// Build a client from its config, binding the QUIC socket up front.
208	#[cfg(any(
209		feature = "noq",
210		feature = "quinn",
211		feature = "quiche",
212		feature = "websocket",
213		feature = "tcp",
214		feature = "uds"
215	))]
216	pub fn new(config: ClientConfig) -> crate::Result<Self> {
217		#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
218		let backend = config.backend.clone().unwrap_or_else(crate::default_quic_backend);
219
220		config.quic.validate()?;
221
222		let tls = config.tls.build()?;
223
224		#[cfg(feature = "noq")]
225		#[allow(unreachable_patterns)]
226		let noq = match backend {
227			QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
228			_ => None,
229		};
230
231		#[cfg(feature = "quinn")]
232		#[allow(unreachable_patterns)]
233		let quinn = match backend {
234			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
235			_ => None,
236		};
237
238		#[cfg(feature = "quiche")]
239		let quiche = match backend {
240			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
241			_ => None,
242		};
243
244		let versions = config.versions();
245		// Read before the struct literal below moves fields out of `config`.
246		#[cfg(feature = "tcp")]
247		let failover_delay = config.effective_failover_delay();
248		let timeout = config.connect_timeout();
249
250		Ok(Self {
251			moq: moq_net::Client::new().with_versions(versions.clone()),
252			versions,
253			connect: config.connect,
254			timeout,
255			backoff: config.backoff,
256			#[cfg(feature = "tcp")]
257			failover_delay,
258			#[cfg(feature = "websocket")]
259			websocket: config.websocket,
260			tls,
261			#[cfg(feature = "noq")]
262			noq,
263			#[cfg(feature = "quinn")]
264			quinn,
265			#[cfg(feature = "quiche")]
266			quiche,
267			#[cfg(feature = "iroh")]
268			iroh: None,
269			#[cfg(feature = "iroh")]
270			iroh_addrs: Vec::new(),
271		})
272	}
273
274	/// Dial `iroh://` URLs through the given Iroh endpoint.
275	///
276	/// Required before [`connect`](Self::connect) can serve an `iroh://` URL;
277	/// without it those dials fail with [`crate::Error::IrohDisabled`].
278	#[cfg(feature = "iroh")]
279	pub fn with_iroh(mut self, iroh: crate::iroh::Endpoint) -> Self {
280		self.iroh = Some(iroh);
281		self
282	}
283
284	/// Set direct IP addresses for connecting to iroh peers.
285	///
286	/// This is useful when the peer's IP addresses are known ahead of time,
287	/// bypassing the need for peer discovery (e.g. in tests or local networks).
288	#[cfg(feature = "iroh")]
289	pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
290		self.iroh_addrs = addrs;
291		self
292	}
293
294	/// Publish the given origin to every session this client opens.
295	pub fn with_publisher(mut self, publish: impl moq_net::Consume<moq_net::origin::Consumer>) -> Self {
296		self.moq = self.moq.with_publisher(publish);
297		self
298	}
299
300	/// Subscribe to the peer's broadcasts, ingesting them into the given origin.
301	pub fn with_subscriber(mut self, subscribe: moq_net::origin::Producer) -> Self {
302		self.moq = self.moq.with_subscriber(subscribe);
303		self
304	}
305
306	/// Attach a per-connection [`moq_net::stats::Session`] context to all sessions
307	/// opened by this client.
308	pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self {
309		self.moq = self.moq.with_stats(stats);
310		self
311	}
312
313	/// Price the links this client dials; see [`moq_net::Client::with_cost`].
314	pub fn with_cost(mut self, cost: u64) -> Self {
315		self.moq = self.moq.with_cost(cost);
316		self
317	}
318
319	/// Assign an origin (hop) id to the peers this client dials, used whenever a
320	/// peer doesn't declare one itself; see [`moq_net::Client::with_peer_origin`].
321	pub fn with_peer_origin(mut self, origin: moq_net::Origin) -> Self {
322		self.moq = self.moq.with_peer_origin(origin);
323		self
324	}
325
326	/// Start a background reconnect loop that connects to the given URL,
327	/// waits for the session to close, then reconnects with exponential backoff.
328	///
329	/// Returns a [`Reconnect`] handle; drop the last handle to stop the loop.
330	pub fn reconnect(&self, url: Url) -> Reconnect {
331		Reconnect::new(self.clone(), url, self.backoff.clone())
332	}
333
334	/// Dial the configured [`ClientConfig::connect`] URL, publishing `origin` to it
335	/// and reconnecting with backoff until the returned handle is dropped.
336	///
337	/// Returns `None` when no `--client-connect` URL was configured, so a caller
338	/// that may run server-only doesn't have to branch on the URL itself.
339	pub fn publish(self, origin: moq_net::origin::Consumer) -> Option<Reconnect> {
340		let url = self.connect.clone()?;
341		Some(self.with_publisher(origin).reconnect(url))
342	}
343
344	/// Dial the configured [`ClientConfig::connect`] URL, consuming its broadcasts
345	/// into `origin` and reconnecting with backoff until the returned handle is
346	/// dropped.
347	///
348	/// Broadcasts fed by these sessions linger across a session drop for as long
349	/// as the reconnect loop keeps retrying ([`Backoff::linger`]): a relay restart
350	/// is a bounded gap the reconnect splices over, not a teardown. When the loop
351	/// gives up, its error surfaces (via [`Reconnect::closed`]) just before the
352	/// broadcasts abort.
353	///
354	/// Returns `None` when no `--client-connect` URL was configured.
355	pub fn consume(self, origin: moq_net::origin::Producer) -> Option<Reconnect> {
356		let url = self.connect.clone()?;
357		let origin = origin.with_linger(self.backoff.linger());
358		Some(self.with_subscriber(origin).reconnect(url))
359	}
360
361	/// Dial the given URL and complete the MoQ handshake.
362	///
363	/// Errors if no transport feature is compiled in.
364	#[cfg(not(any(
365		feature = "noq",
366		feature = "quinn",
367		feature = "quiche",
368		feature = "iroh",
369		feature = "websocket",
370		feature = "tcp",
371		feature = "uds"
372	)))]
373	pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
374		Err(Error::NoBackend(
375			"no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
376		))
377	}
378
379	/// Dial the given URL and complete the MoQ handshake.
380	///
381	/// The scheme picks the transport, and `https://` races QUIC against the
382	/// WebSocket fallback so a blocked UDP path still connects. The session's
383	/// protocol driver is spawned on the current tokio runtime; the session
384	/// closes once the last returned handle drops.
385	#[cfg(any(
386		feature = "noq",
387		feature = "quinn",
388		feature = "quiche",
389		feature = "iroh",
390		feature = "websocket",
391		feature = "tcp",
392		feature = "uds"
393	))]
394	pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
395		// Each compiled backend adds state to this dispatch future. Keep it off the
396		// caller's stack so all-feature builds remain safe on standard 2 MiB threads.
397		let attempt = Box::pin(self.connect_inner(url));
398
399		// The deadline covers the dial AND the handshake, for every transport: it is the
400		// only bound some of them have. Dropping `attempt` on expiry cancels whichever
401		// arm was still pending.
402		let pair = match self.timeout.is_zero() {
403			true => attempt.await?,
404			false => match tokio::time::timeout(self.timeout, attempt).await {
405				Ok(res) => res?,
406				Err(_) => return Err(Error::ConnectTimeout(self.timeout)),
407			},
408		};
409
410		tracing::info!(version = %pair.0.version(), "connected");
411		Ok(crate::spawn_session(pair))
412	}
413
414	/// The moq client builder, advertising `path` in the SETUP when there is one.
415	#[cfg(any(
416		feature = "noq",
417		feature = "quinn",
418		feature = "quiche",
419		feature = "iroh",
420		feature = "websocket",
421		feature = "tcp",
422		feature = "uds"
423	))]
424	fn moq_with_path(&self, path: Option<String>) -> moq_net::Client {
425		match path {
426			Some(path) => self.moq.clone().with_path(path),
427			None => self.moq.clone(),
428		}
429	}
430
431	#[cfg(any(
432		feature = "noq",
433		feature = "quinn",
434		feature = "quiche",
435		feature = "iroh",
436		feature = "websocket",
437		feature = "tcp",
438		feature = "uds"
439	))]
440	async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> {
441		// Transports with no request URI of their own advertise the request target in the
442		// SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where
443		// sending it again is a protocol violation.
444		let moq = self.moq_with_path(setup_path(&url));
445
446		// Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against
447		// QUIC, which can't speak it. Use only on a trusted network.
448		#[cfg(feature = "tcp")]
449		if url.scheme() == "tcp" {
450			let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay).await?;
451			return Ok(moq.connect(session).await?);
452		}
453
454		// Unix domain socket (qmux, no TLS). Same-host only; the server can
455		// authenticate us by uid/gid via SO_PEERCRED.
456		#[cfg(all(feature = "uds", unix))]
457		if url.scheme() == "unix" {
458			let session = crate::unix::connect(url, &self.versions.alpns()).await?;
459			return Ok(moq.connect(session).await?);
460		}
461
462		// iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw
463		// QUIC, which carries no request URI. The scheme can't tell us which we got, so the
464		// request target waits on the negotiated binding: the SETUP for raw QUIC, the
465		// CONNECT URL for H3 (where a SETUP path would be a protocol violation).
466		#[cfg(feature = "iroh")]
467		if url.scheme() == "iroh" {
468			let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
469			let target = request_target(&url);
470			let (session, binding) = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
471
472			let moq = match binding {
473				crate::iroh::Binding::Raw => self.moq_with_path(target),
474				crate::iroh::Binding::H3 => self.moq.clone(),
475			};
476
477			return Ok(moq.connect(session).await?);
478		}
479
480		#[cfg(feature = "noq")]
481		if let Some(noq) = self.noq.as_ref() {
482			let tls = self.tls.clone();
483			let quic_url = url.clone();
484			let quic_handle = async { noq.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
485
486			#[cfg(feature = "websocket")]
487			{
488				return self.race_moq_connect(&moq, url, quic_handle).await;
489			}
490
491			#[cfg(not(feature = "websocket"))]
492			{
493				let session = quic_handle.await?;
494				return Ok(moq.connect(session).await?);
495			}
496		}
497
498		#[cfg(feature = "quinn")]
499		if let Some(quinn) = self.quinn.as_ref() {
500			let tls = self.tls.clone();
501			let quic_url = url.clone();
502			let quic_handle = async { quinn.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
503
504			#[cfg(feature = "websocket")]
505			{
506				return self.race_moq_connect(&moq, url, quic_handle).await;
507			}
508
509			#[cfg(not(feature = "websocket"))]
510			{
511				let session = quic_handle.await?;
512				return Ok(moq.connect(session).await?);
513			}
514		}
515
516		#[cfg(feature = "quiche")]
517		if let Some(quiche) = self.quiche.as_ref() {
518			let quic_url = url.clone();
519			let quic_handle = async { quiche.connect(quic_url, &self.versions).await.map_err(Error::from) };
520
521			#[cfg(feature = "websocket")]
522			{
523				return self.race_moq_connect(&moq, url, quic_handle).await;
524			}
525
526			#[cfg(not(feature = "websocket"))]
527			{
528				let session = quic_handle.await?;
529				return Ok(moq.connect(session).await?);
530			}
531		}
532
533		#[cfg(feature = "websocket")]
534		{
535			let alpns = self.versions.alpns();
536			let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
537			return Ok(moq.connect(session).await?);
538		}
539
540		#[cfg(not(feature = "websocket"))]
541		return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
542	}
543
544	/// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins.
545	///
546	/// `moq` is the QUIC-side builder, which carries the SETUP path for a raw QUIC dial.
547	/// The WebSocket fallback uses the plain builder: qmux over WebSocket carries the
548	/// path in its request URI, so repeating it in the SETUP is a protocol violation.
549	#[cfg(feature = "websocket")]
550	async fn race_moq_connect<Q, S>(
551		&self,
552		moq: &moq_net::Client,
553		url: Url,
554		quic: Q,
555	) -> crate::Result<(moq_net::Session, moq_net::Driver)>
556	where
557		Q: Future<Output = crate::Result<S>>,
558		S: web_transport_trait::Session,
559	{
560		let alpns = self.versions.alpns();
561		let ws_config = self.websocket.clone();
562		let ws_tls = self.tls.clone();
563		let websocket = async move {
564			crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
565				.await
566				.map(|res| res.map_err(Error::from))
567		};
568
569		match race_transport_connect(quic, websocket).await? {
570			TransportRace::Quic(quic) => Ok(moq.connect(quic).await?),
571			TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
572		}
573	}
574}
575
576/// The request target a URI-less transport advertises in its SETUP: the URL path, plus
577/// `?` and the query when there is one (draft-ietf-moq-transport-19, section 10.3.1.2).
578/// That query is how `?jwt=` reaches a relay.
579///
580/// `None` when the result is empty, which means the same as omitting the parameter: the
581/// server's default path. A peer on published lite-05 rejects an empty value outright.
582#[cfg(any(
583	feature = "noq",
584	feature = "quinn",
585	feature = "quiche",
586	feature = "iroh",
587	feature = "websocket",
588	feature = "tcp",
589	feature = "uds"
590))]
591fn request_target(url: &Url) -> Option<String> {
592	// A trailing `?` parses as an empty query, which is not a query: appending it would
593	// spell one target two ways, and `moqt://host?` would yield a bare "?" rather than
594	// the empty value that means the default path.
595	let target = match url.query().filter(|query| !query.is_empty()) {
596		Some(query) => format!("{}?{}", url.path(), query),
597		None => url.path().to_owned(),
598	};
599
600	(!target.is_empty()).then_some(target)
601}
602
603/// The request target to advertise in the SETUP, chosen by the dial URL's scheme.
604///
605/// `None` for the schemes whose transport carries a request URI of its own
606/// (WebTransport, qmux over WebSocket): they convey the target there, and a SETUP path
607/// on top of it is a protocol violation. `iroh` is `None` here because its binding is
608/// picked by ALPN negotiation rather than by the scheme; that dial reads the negotiated
609/// [`crate::iroh::Binding`] and calls [`request_target`] itself.
610#[cfg(any(
611	feature = "noq",
612	feature = "quinn",
613	feature = "quiche",
614	feature = "iroh",
615	feature = "websocket",
616	feature = "tcp",
617	feature = "uds"
618))]
619fn setup_path(url: &Url) -> Option<String> {
620	match url.scheme() {
621		// A Unix socket URL's path is the socket file, so the request target rides in
622		// the `?path=` query, query string and all. It is one form-encoded value, so a
623		// target that carries its own `?query` percent-encodes it.
624		"unix" => url
625			.query_pairs()
626			.find(|(k, _)| k == "path")
627			.map(|(_, v)| v.into_owned())
628			.filter(|path| !path.is_empty()),
629		// Raw QUIC and qmux over TCP negotiate an ALPN and nothing else, so the whole
630		// request target travels in the SETUP.
631		"moqt" | "moql" | "tcp" => request_target(url),
632		_ => None,
633	}
634}
635
636#[cfg(feature = "websocket")]
637#[derive(Debug, PartialEq, Eq)]
638enum TransportRace<Q, W> {
639	Quic(Q),
640	WebSocket(W),
641}
642
643#[cfg(feature = "websocket")]
644async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
645where
646	Q: Future<Output = crate::Result<QT>>,
647	W: Future<Output = Option<crate::Result<WT>>>,
648{
649	tokio::pin!(quic);
650	tokio::pin!(websocket);
651
652	let mut quic_err = None;
653	let mut websocket_err = None;
654	let mut quic_done = false;
655	let mut websocket_done = false;
656
657	loop {
658		tokio::select! {
659			res = &mut quic, if !quic_done => {
660				match res {
661					Ok(session) => return Ok(TransportRace::Quic(session)),
662					Err(err) if err.is_auth() => return Err(err),
663					Err(err) => {
664						tracing::warn!(%err, "QUIC connection failed");
665						quic_err = Some(err);
666						quic_done = true;
667					}
668				}
669			}
670			res = &mut websocket, if !websocket_done => {
671				match res {
672					Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
673					Some(Err(err)) if err.is_auth() => return Err(err),
674					Some(Err(err)) => {
675						tracing::warn!(%err, "WebSocket connection failed");
676						websocket_err = Some(err);
677						websocket_done = true;
678					}
679					None => {
680						websocket_done = true;
681					}
682				}
683			}
684			else => break,
685		}
686
687		if quic_done && websocket_done {
688			break;
689		}
690	}
691
692	match (quic_err, websocket_err) {
693		(Some(quic), Some(websocket)) => Err(Error::TransportRace {
694			quic: std::sync::Arc::new(quic),
695			websocket: std::sync::Arc::new(websocket),
696		}),
697		(Some(err), None) | (None, Some(err)) => Err(err),
698		(None, None) => Err(Error::ConnectFailed),
699	}
700}
701
702#[cfg(test)]
703mod tests {
704	use super::*;
705	use clap::Parser;
706
707	#[cfg(any(
708		feature = "noq",
709		feature = "quinn",
710		feature = "quiche",
711		feature = "iroh",
712		feature = "websocket",
713		feature = "tcp",
714		feature = "uds"
715	))]
716	#[test]
717	fn setup_path_covers_the_uri_less_transports() {
718		// An empty path and an absent one both mean the server's default, so we send
719		// neither. A peer on published lite-05 rejects an empty value outright.
720		let cases = [
721			("unix:///run/moq.sock?path=/room", Some("/room")),
722			// The whole resource path is one form-encoded value, so a `?query` inside it
723			// arrives percent-encoded and comes back out whole.
724			("unix:///run/moq.sock?path=/room%3Fjwt%3Dabc", Some("/room?jwt=abc")),
725			("unix:///run/moq.sock?path=", None),
726			("unix:///run/moq.sock", None),
727			("tcp://localhost:4443/room", Some("/room")),
728			("tcp://localhost:4443/room?jwt=abc", Some("/room?jwt=abc")),
729			("tcp://localhost:4443", None),
730			// Raw QUIC: the URL is ours alone, so the path and query have to ride the
731			// SETUP or the server never sees them.
732			("moqt://relay.example.com/anon", Some("/anon")),
733			("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
734			("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
735			("moqt://relay.example.com", None),
736			// The fragment is processed by the client and never sent (draft-19 3.1.2).
737			("moqt://relay.example.com/anon?jwt=abc#pos:12", Some("/anon?jwt=abc")),
738			("moqt://relay.example.com/anon#pos:12", Some("/anon")),
739			// A trailing `?` is an empty query, not a query.
740			("moqt://relay.example.com/anon?", Some("/anon")),
741			("moqt://relay.example.com?", None),
742			// The transport's own request URI carries the path, so sending one here
743			// would be a protocol violation.
744			("https://relay.example.com/anon?jwt=abc", None),
745			("http://relay.example.com/anon", None),
746			("wss://relay.example.com/anon?jwt=abc", None),
747			// Decided after the ALPN is negotiated, not here.
748			("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None),
749		];
750
751		for (url, want) in cases {
752			let url = Url::parse(url).unwrap();
753			let got = setup_path(&url);
754			assert_eq!(got.as_deref(), want, "{url}");
755		}
756	}
757
758	/// The iroh dial derives its target here rather than through [`setup_path`], since
759	/// only the negotiated binding says whether to send one.
760	#[cfg(any(
761		feature = "noq",
762		feature = "quinn",
763		feature = "quiche",
764		feature = "iroh",
765		feature = "websocket",
766		feature = "tcp",
767		feature = "uds"
768	))]
769	#[test]
770	fn request_target_joins_the_path_and_query() {
771		const PEER: &str = "k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa";
772
773		let cases = [
774			(format!("iroh://{PEER}/room?jwt=abc"), Some("/room?jwt=abc")),
775			(format!("iroh://{PEER}/room"), Some("/room")),
776			(format!("iroh://{PEER}"), None),
777			(format!("iroh://{PEER}/"), Some("/")),
778		];
779
780		for (url, want) in cases {
781			let url = Url::parse(&url).unwrap();
782			let got = request_target(&url);
783			assert_eq!(got.as_deref(), want, "{url}");
784		}
785	}
786
787	#[test]
788	fn test_toml_disable_verify_survives_update_from() {
789		let toml = r#"
790			tls.disable_verify = true
791		"#;
792
793		let mut config: ClientConfig = toml::from_str(toml).unwrap();
794		assert_eq!(config.tls.disable_verify, Some(true));
795
796		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag).
797		config.update_from(["test"]);
798		assert_eq!(config.tls.disable_verify, Some(true));
799	}
800
801	#[test]
802	fn test_cli_disable_verify_flag() {
803		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
804		assert_eq!(config.tls.disable_verify, Some(true));
805	}
806
807	#[test]
808	fn test_cli_disable_verify_explicit_false() {
809		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
810		assert_eq!(config.tls.disable_verify, Some(false));
811	}
812
813	#[test]
814	fn test_cli_disable_verify_explicit_true() {
815		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
816		assert_eq!(config.tls.disable_verify, Some(true));
817	}
818
819	#[test]
820	fn test_cli_deprecated_tls_flags_fold_into_canonical() {
821		// The bare --tls-* forms are deprecated. They parse into a hidden field and
822		// fold into the canonical values via the effective_* accessors build() uses,
823		// so they keep working without touching the public Client fields.
824		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
825		assert_eq!(
826			config.tls.disable_verify, None,
827			"deprecated flag must not set the canonical field"
828		);
829		assert_eq!(config.tls.effective_disable_verify(), Some(true));
830		assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
831	}
832
833	#[test]
834	fn test_canonical_tls_flag_wins_over_deprecated() {
835		// Both spellings given: canonical wins for scalar options, vecs concatenate.
836		let config = ClientConfig::parse_from([
837			"test",
838			"--client-tls-disable-verify=false",
839			"--tls-disable-verify=true",
840			"--client-tls-fingerprint",
841			"aaaa",
842			"--tls-fingerprint",
843			"bbbb",
844		]);
845		assert_eq!(config.tls.effective_disable_verify(), Some(false));
846		assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
847	}
848
849	#[test]
850	fn test_cli_no_disable_verify() {
851		let config = ClientConfig::parse_from(["test"]);
852		assert_eq!(config.tls.disable_verify, None);
853	}
854
855	#[test]
856	fn test_toml_failover_delay_survives_update_from() {
857		let toml = r#"
858			failover_delay = "1s"
859		"#;
860
861		let mut config: ClientConfig = toml::from_str(toml).unwrap();
862		assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
863
864		// Simulate: TOML loaded, then CLI args re-applied (no --client-failover-delay flag).
865		config.update_from(["test"]);
866		assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
867	}
868
869	#[test]
870	fn test_cli_failover_delay() {
871		let config = ClientConfig::parse_from(["test", "--client-failover-delay", "50ms"]);
872		assert_eq!(config.failover_delay, Some(std::time::Duration::from_millis(50)));
873	}
874
875	#[test]
876	fn test_toml_fingerprint_survives_update_from() {
877		let toml = r#"
878			tls.fingerprint = ["abcd1234", "ef567890"]
879		"#;
880
881		let mut config: ClientConfig = toml::from_str(toml).unwrap();
882		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
883
884		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag).
885		config.update_from(["test"]);
886		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
887	}
888
889	#[test]
890	fn test_toml_fingerprint_accepts_single_string() {
891		let toml = r#"
892			tls.fingerprint = "abcd1234"
893		"#;
894
895		let config: ClientConfig = toml::from_str(toml).unwrap();
896		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
897	}
898
899	#[test]
900	fn test_cli_fingerprint() {
901		let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
902		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
903	}
904
905	#[test]
906	fn test_toml_version_survives_update_from() {
907		let toml = r#"
908			version = ["moq-lite-02"]
909		"#;
910
911		let mut config: ClientConfig = toml::from_str(toml).unwrap();
912		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
913
914		// Simulate: TOML loaded, then CLI args re-applied (no --client-version flag).
915		config.update_from(["test"]);
916		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
917	}
918
919	#[test]
920	fn test_cli_version() {
921		let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
922		assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
923	}
924
925	#[test]
926	fn test_toml_connect_survives_update_from() {
927		let toml = r#"
928			connect = "https://relay.example.com/anon"
929		"#;
930
931		let mut config: ClientConfig = toml::from_str(toml).unwrap();
932		assert_eq!(
933			config.connect.as_ref().unwrap().as_str(),
934			"https://relay.example.com/anon"
935		);
936
937		// Simulate: TOML loaded, then CLI args re-applied (no --client-connect flag).
938		config.update_from(["test"]);
939		assert_eq!(
940			config.connect.as_ref().unwrap().as_str(),
941			"https://relay.example.com/anon"
942		);
943	}
944
945	#[test]
946	fn test_cli_connect() {
947		let config = ClientConfig::parse_from(["test", "--client-connect", "https://relay.example.com/anon"]);
948		assert_eq!(
949			config.connect.as_ref().unwrap().as_str(),
950			"https://relay.example.com/anon"
951		);
952	}
953
954	#[test]
955	fn test_toml_host_name_survives_update_from() {
956		let toml = r#"
957			tls.host_name = "example.host"
958		"#;
959
960		let mut config: ClientConfig = toml::from_str(toml).unwrap();
961		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
962
963		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-host-name flag).
964		config.update_from(["test"]);
965		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
966	}
967
968	#[test]
969	fn test_cli_host_name() {
970		let config = ClientConfig::parse_from(["test", "--client-tls-host-name", "override.example"]);
971		assert_eq!(config.tls.host_name.as_deref(), Some("override.example"));
972	}
973
974	#[test]
975	fn test_cli_no_version_defaults_to_all() {
976		let config = ClientConfig::parse_from(["test"]);
977		assert!(config.version.is_empty());
978		// versions() helper returns all when none specified
979		assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
980	}
981
982	#[cfg(feature = "websocket")]
983	#[tokio::test]
984	async fn race_transport_connect_stops_on_quic_auth_error() {
985		let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
986		let websocket = async {
987			// This only needs to complete later than the immediately ready QUIC auth error.
988			tokio::task::yield_now().await;
989			Some(Ok(1usize))
990		};
991
992		let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
993		assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
994	}
995
996	#[cfg(feature = "websocket")]
997	#[tokio::test]
998	async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
999		let quic = async { Err::<usize, _>(Error::ConnectFailed) };
1000		let websocket = async { Some(Ok(7usize)) };
1001
1002		let value = super::race_transport_connect(quic, websocket).await.unwrap();
1003		assert_eq!(value, super::TransportRace::WebSocket(7));
1004	}
1005
1006	#[cfg(feature = "websocket")]
1007	#[tokio::test]
1008	async fn race_transport_connect_returns_when_quic_transport_connects() {
1009		let quic = async { Ok("quic") };
1010		let websocket = std::future::pending::<Option<crate::Result<&str>>>();
1011
1012		let value = tokio::time::timeout(
1013			std::time::Duration::from_secs(1),
1014			super::race_transport_connect(quic, websocket),
1015		)
1016		.await
1017		.expect("race waited for WebSocket after QUIC transport connected")
1018		.unwrap();
1019		assert_eq!(value, super::TransportRace::Quic("quic"));
1020	}
1021
1022	#[test]
1023	fn connect_timeout_defaults_to_thirty_seconds() {
1024		let config = ClientConfig::parse_from(["test"]);
1025		assert_eq!(config.timeout, None);
1026		assert_eq!(config.connect_timeout(), DEFAULT_CONNECT_TIMEOUT);
1027	}
1028
1029	/// A peer that completes the TCP handshake and then never speaks: the QUIC arm
1030	/// gives up on its own, but the WebSocket arm has no deadline of its own, so the
1031	/// race stays pending forever. Without the connect timeout this test hangs.
1032	///
1033	/// That is what wedged a publisher against a livelocked relay: `Reconnect` only
1034	/// re-arms its backoff (and checks its give-up timeout) *between* attempts, so an
1035	/// attempt that never returns stalls the retry loop for good.
1036	#[cfg(feature = "websocket")]
1037	#[tokio::test]
1038	async fn connect_times_out_against_a_peer_that_never_speaks() {
1039		let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1040		let addr = listener.local_addr().unwrap();
1041
1042		let timeout = DEFAULT_CONNECT_TIMEOUT;
1043		let mut config = ClientConfig {
1044			timeout: Some(timeout),
1045			..Default::default()
1046		};
1047		config.websocket.delay = Some(std::time::Duration::ZERO);
1048		let client = config.init().unwrap();
1049
1050		// Nothing is listening on UDP, so the QUIC arm fails and leaves the WebSocket
1051		// arm alone against the silent peer.
1052		let url: Url = format!("https://127.0.0.1:{}/", addr.port()).parse().unwrap();
1053
1054		let mut attempt = Box::pin(client.connect(url));
1055		let _silent = tokio::select! {
1056			res = &mut attempt => match res {
1057				Err(err) => panic!("connect failed before the silent peer accepted it: {err}"),
1058				Ok(_) => panic!("connected to a peer that never spoke"),
1059			},
1060			res = listener.accept() => res.unwrap().0,
1061		};
1062
1063		// Freeze only after TCP connected, then advance directly to the deadline. The
1064		// accepted socket stays in scope and silent until the attempt returns.
1065		tokio::time::pause();
1066		tokio::time::advance(timeout).await;
1067
1068		let err = match attempt.await {
1069			Err(err) => err,
1070			Ok(_) => panic!("connected to a peer that never spoke"),
1071		};
1072
1073		assert!(matches!(err, Error::ConnectTimeout(_)), "unexpected error: {err}");
1074	}
1075}