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