Skip to main content

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;