decdn_client/endpoint.rs
1//! Shared relay resolution for the one-shot client commands (`fetch`, `probe`).
2//!
3//! Relays are environment configuration, not a per-invocation decision (#935):
4//! they come from the config file (`network.relay_urls`), exactly the field the
5//! node already consumes. `--relay-url` stays as an optional override that replaces the
6//! config list. An absent config file yields no relays, so a client that dials
7//! a direct `--addr` needs no config at all.
8
9use std::net::{Ipv4Addr, SocketAddrV4};
10use std::path::Path;
11use std::str::FromStr;
12
13use decdn_common::config::{ResolvedDiscovery, load_file_config, resolve_discovery};
14use decdn_common::identity::fresh_secret_key;
15use decdn_common::redact::redact_userinfo;
16use iroh::address_lookup::{DnsAddressLookup, MemoryLookup};
17use iroh::endpoint::{QuicTransportConfig, VarInt, presets};
18use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMap, RelayMode, RelayUrl};
19
20/// Per-stream QUIC receive window for a client pull.
21///
22/// QUIC flow control is receiver-advertised, and a download makes the client
23/// the receiver, so this window — not any node-side setting — bounds
24/// single-stream throughput at roughly `window / RTT`. It is sized to a
25/// default-configured node's paid-pull pacing envelope: a node serves at most
26/// one credit window of unpaid bytes ahead of the paid frontier, and that
27/// window ramps as `clamp(paid / credit_ramp_divisor, 1 MiB, credit_max)`
28/// toward `credit_max` (ADR 003 §Credit window), whose default is 64 MiB but is
29/// operator-configurable with no hard upper bound. Matching this window to that
30/// 64 MiB default keeps transport from being the bottleneck against a default
31/// node at any RTT. An operator who raises `payment.credit_max` past 64 MiB
32/// makes transport the binding cap again; the fixed default here needs no
33/// knowledge of the node's config and covers the common case.
34const CLIENT_STREAM_RECEIVE_WINDOW: u32 = 64 * 1024 * 1024;
35
36/// Whole-connection QUIC receive window for a client pull. A pull holds one
37/// connection per node ([`crate::Connections`]), and every lane and leg to
38/// that node opens its streams on it, so several bulk streams (e.g. in a
39/// bundle pull) share this window and each gets a part of it. It holds at least
40/// [`CLIENT_STREAM_RECEIVE_WINDOW`], so a lone stream can fill its own window;
41/// the 2x headroom lets two streams run at full window, without letting
42/// worst-case buffering grow larger than it must.
43const CLIENT_RECEIVE_WINDOW: u32 = 128 * 1024 * 1024;
44
45// A single stream must be able to fill its own window within the
46// whole-connection cap, or the per-stream window is unreachable.
47const _: () = assert!(CLIENT_STREAM_RECEIVE_WINDOW <= CLIENT_RECEIVE_WINDOW);
48
49/// QUIC transport config for a one-shot client [`Endpoint`]. It raises only the
50/// receive windows over the iroh defaults: on a download the client is the
51/// flow-control receiver, so these windows bound single-stream throughput on a
52/// high-latency path. Congestion control lives at the sender, so a node-side
53/// change is what would alter that half.
54fn client_transport_config() -> QuicTransportConfig {
55 QuicTransportConfig::builder()
56 .stream_receive_window(VarInt::from_u32(CLIENT_STREAM_RECEIVE_WINDOW))
57 .receive_window(VarInt::from_u32(CLIENT_RECEIVE_WINDOW))
58 .build()
59}
60
61/// Resolve the relay URLs for a client command. The `--relay-url` override
62/// (`flag`) wins; otherwise `network.relay_urls` from the config is used. An
63/// absent config file
64/// — including an explicit `--config` path that does not exist — resolves to
65/// an empty list rather than an error, so a client dialing a direct `--addr`
66/// needs no config at all.
67pub fn resolve_relays(
68 flag: Option<&str>,
69 config_path: Option<&Path>,
70) -> anyhow::Result<Vec<RelayUrl>> {
71 let raw: Vec<String> = if let Some(s) = flag {
72 vec![s.to_owned()]
73 } else if config_path.is_none_or(Path::exists) {
74 // `None` → `load_file_config` resolves the default path (and returns
75 // an empty default when it is absent). An explicit path is only loaded
76 // when it exists; a present-but-malformed file still surfaces its parse
77 // error.
78 let net = load_file_config(config_path)?.network.unwrap_or_default();
79 net.relay_urls.unwrap_or_default()
80 } else {
81 Vec::new()
82 };
83 raw.iter()
84 .map(|s| {
85 // Redact any `user:pass@` userinfo before echoing a malformed entry
86 // into an error that may reach logs (mirrors node bring-up's
87 // `parse_relay_urls`).
88 RelayUrl::from_str(s)
89 .map_err(|e| anyhow::anyhow!("invalid relay url {:?}: {e}", redact_userinfo(s)))
90 })
91 .collect()
92}
93
94/// Build the `RelayMode` for a one-shot client: a custom map when any relays
95/// are configured, else disabled (the client dials a direct `--addr`). Mirrors
96/// the client commands' prior behaviour, just sourced from the resolved list.
97#[must_use]
98#[doc(hidden)]
99pub fn relay_mode(relays: &[RelayUrl]) -> RelayMode {
100 if relays.is_empty() {
101 RelayMode::Disabled
102 } else {
103 RelayMode::Custom(relays.iter().cloned().collect::<RelayMap>())
104 }
105}
106
107/// Resolve `[network.discovery]` for a client command, tolerating a missing
108/// config exactly like [`resolve_relays`]: an explicit `--config` path that does
109/// not exist yields no discovery overrides (→ `presets::N0`), so a client that
110/// dials a direct `--addr` needs no config at all.
111///
112/// # Errors
113///
114/// Surfaces a present-but-malformed config's parse/validation error.
115pub fn client_discovery(config_path: Option<&Path>) -> anyhow::Result<ResolvedDiscovery> {
116 if config_path.is_some_and(|p| !p.exists()) {
117 return Ok(ResolvedDiscovery::default());
118 }
119 resolve_discovery(&load_file_config(config_path)?)
120}
121
122/// Build a one-shot client [`Endpoint`] that can resolve a target node by its
123/// iroh `NodeId`. With no `[network.discovery]` config we use `presets::N0`
124/// (the n0-hosted pkarr/DNS lookup the node defaults to); with operator
125/// discovery configured we compose only the *resolution* legs onto
126/// `presets::Minimal`.
127///
128/// Relay selection is an independent leg, mirroring the node's `build_endpoint`:
129/// a configured relay list (`--relay-url`/`network.relay_urls`, #935) becomes a
130/// `RelayMode::Custom` map; with no custom relays we keep `presets::N0`'s n0
131/// default relay map, and on `presets::Minimal` (operator discovery) we restore
132/// `RelayMode::Default` — dropping the n0 *discovery* leg must not also disable
133/// relays, or a NodeId-only dial of a NAT'd node could not connect.
134///
135/// Note: the pkarr *publisher* leg the node wires (`network.discovery.pkarr_url`)
136/// is intentionally omitted — a one-shot client resolves peers, it never
137/// publishes its own ephemeral address record. Resolution for an operator
138/// namespace goes through its `dns_origin`, which `resolve_config` already
139/// requires whenever `pkarr_url` is set. Upgrade path: add a pkarr resolver leg
140/// here if a deployment ever resolves via pkarr without a DNS bridge.
141pub async fn client_endpoint(
142 relays: &[RelayUrl],
143 discovery: &ResolvedDiscovery,
144) -> anyhow::Result<Endpoint> {
145 let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);
146 let mut builder = if discovery.is_empty() {
147 Endpoint::builder(presets::N0)
148 } else {
149 add_resolution_lookups(Endpoint::builder(presets::Minimal), discovery)?
150 };
151 builder = builder
152 .secret_key(fresh_secret_key())
153 .transport_config(client_transport_config());
154 builder = if !relays.is_empty() {
155 builder.relay_mode(relay_mode(relays))
156 } else if discovery.is_empty() {
157 builder // presets::N0 already carries the n0 default relay map.
158 } else {
159 // presets::Minimal sets no relay mode; restore the n0 default.
160 builder.relay_mode(RelayMode::Default)
161 };
162 builder
163 .bind_addr(bind_addr)
164 .map_err(|e| anyhow::anyhow!("invalid bind addr {bind_addr}: {e}"))?
165 .bind()
166 .await
167 .map_err(|e| anyhow::anyhow!("endpoint bind failed: {e}"))
168}
169
170/// Compose the operator-configured *resolution* legs onto `builder`: a
171/// `DnsAddressLookup` for `dns_origin` and a `MemoryLookup` for any static
172/// peers. Mirrors the node's `add_discovery_lookups` minus the publisher leg
173/// (see [`client_endpoint`]). The peer fields were shape-validated at
174/// resolution; they are re-parsed here into iroh types, and any echoed URL is
175/// `redact_userinfo`'d so a credential-bearing typo never reaches an error.
176fn add_resolution_lookups(
177 mut builder: iroh::endpoint::Builder,
178 discovery: &ResolvedDiscovery,
179) -> anyhow::Result<iroh::endpoint::Builder> {
180 if let Some(origin) = &discovery.dns_origin {
181 builder = builder.address_lookup(DnsAddressLookup::builder(origin.clone()));
182 }
183 if !discovery.peers.is_empty() {
184 let mut infos = Vec::with_capacity(discovery.peers.len());
185 for peer in &discovery.peers {
186 let id = peer.node_id.parse::<PublicKey>().map_err(|e| {
187 anyhow::anyhow!("invalid network.discovery peer id {}: {e}", peer.node_id)
188 })?;
189 let mut addr = EndpointAddr::new(id);
190 if let Some(relay) = &peer.relay_url {
191 let relay_url = relay.parse::<RelayUrl>().map_err(|e| {
192 anyhow::anyhow!(
193 "invalid relay_url {:?} for network.discovery peer {}: {e}",
194 redact_userinfo(relay),
195 peer.node_id
196 )
197 })?;
198 addr = addr.with_relay_url(relay_url);
199 }
200 for a in &peer.addrs {
201 let sock = a.parse::<std::net::SocketAddr>().map_err(|e| {
202 anyhow::anyhow!(
203 "invalid addr {a:?} for network.discovery peer {}: {e}",
204 peer.node_id
205 )
206 })?;
207 addr = addr.with_ip_addr(sock);
208 }
209 infos.push(addr);
210 }
211 builder = builder.address_lookup(MemoryLookup::from_endpoint_info(infos));
212 }
213 Ok(builder)
214}
215
216#[cfg(test)]
217#[allow(
218 clippy::unwrap_used,
219 clippy::expect_used,
220 clippy::indexing_slicing,
221 clippy::panic
222)]
223mod tests;