decdn-client 0.1.0

The deCDN client SDK: fetch content-addressed blobs from deCDN nodes, pay per megabyte from a USDC payment pool, and verify every byte as it arrives
//! Shared relay resolution for the one-shot client commands (`fetch`, `probe`).
//!
//! Relays are environment configuration, not a per-invocation decision (#935):
//! they come from the config file (`network.relay_urls`), exactly the field the
//! node already consumes. `--relay-url` stays as an optional override that replaces the
//! config list. An absent config file yields no relays, so a client that dials
//! a direct `--addr` needs no config at all.

use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::Path;
use std::str::FromStr;

use decdn_common::config::{ResolvedDiscovery, load_file_config, resolve_discovery};
use decdn_common::identity::fresh_secret_key;
use decdn_common::redact::redact_userinfo;
use iroh::address_lookup::{DnsAddressLookup, MemoryLookup};
use iroh::endpoint::{QuicTransportConfig, VarInt, presets};
use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMap, RelayMode, RelayUrl};

/// Per-stream QUIC receive window for a client pull.
///
/// QUIC flow control is receiver-advertised, and a download makes the client
/// the receiver, so this window — not any node-side setting — bounds
/// single-stream throughput at roughly `window / RTT`. It is sized to a
/// default-configured node's paid-pull pacing envelope: a node serves at most
/// one credit window of unpaid bytes ahead of the paid frontier, and that
/// window ramps as `clamp(paid / credit_ramp_divisor, 1 MiB, credit_max)`
/// toward `credit_max` (ADR 003 §Credit window), whose default is 64 MiB but is
/// operator-configurable with no hard upper bound. Matching this window to that
/// 64 MiB default keeps transport from being the bottleneck against a default
/// node at any RTT. An operator who raises `payment.credit_max` past 64 MiB
/// makes transport the binding cap again; the fixed default here needs no
/// knowledge of the node's config and covers the common case.
const CLIENT_STREAM_RECEIVE_WINDOW: u32 = 64 * 1024 * 1024;

/// Whole-connection QUIC receive window for a client pull. A pull holds one
/// connection per node ([`crate::Connections`]), and every lane and leg to
/// that node opens its streams on it, so several bulk streams (e.g. in a
/// bundle pull) share this window and each gets a part of it. It holds at least
/// [`CLIENT_STREAM_RECEIVE_WINDOW`], so a lone stream can fill its own window;
/// the 2x headroom lets two streams run at full window, without letting
/// worst-case buffering grow larger than it must.
const CLIENT_RECEIVE_WINDOW: u32 = 128 * 1024 * 1024;

// A single stream must be able to fill its own window within the
// whole-connection cap, or the per-stream window is unreachable.
const _: () = assert!(CLIENT_STREAM_RECEIVE_WINDOW <= CLIENT_RECEIVE_WINDOW);

/// QUIC transport config for a one-shot client [`Endpoint`]. It raises only the
/// receive windows over the iroh defaults: on a download the client is the
/// flow-control receiver, so these windows bound single-stream throughput on a
/// high-latency path. Congestion control lives at the sender, so a node-side
/// change is what would alter that half.
fn client_transport_config() -> QuicTransportConfig {
    QuicTransportConfig::builder()
        .stream_receive_window(VarInt::from_u32(CLIENT_STREAM_RECEIVE_WINDOW))
        .receive_window(VarInt::from_u32(CLIENT_RECEIVE_WINDOW))
        .build()
}

/// Resolve the relay URLs for a client command. The `--relay-url` override
/// (`flag`) wins; otherwise `network.relay_urls` from the config is used. An
/// absent config file
/// — including an explicit `--config` path that does not exist — resolves to
/// an empty list rather than an error, so a client dialing a direct `--addr`
/// needs no config at all.
pub fn resolve_relays(
    flag: Option<&str>,
    config_path: Option<&Path>,
) -> anyhow::Result<Vec<RelayUrl>> {
    let raw: Vec<String> = if let Some(s) = flag {
        vec![s.to_owned()]
    } else if config_path.is_none_or(Path::exists) {
        // `None` → `load_file_config` resolves the default path (and returns
        // an empty default when it is absent). An explicit path is only loaded
        // when it exists; a present-but-malformed file still surfaces its parse
        // error.
        let net = load_file_config(config_path)?.network.unwrap_or_default();
        net.relay_urls.unwrap_or_default()
    } else {
        Vec::new()
    };
    raw.iter()
        .map(|s| {
            // Redact any `user:pass@` userinfo before echoing a malformed entry
            // into an error that may reach logs (mirrors node bring-up's
            // `parse_relay_urls`).
            RelayUrl::from_str(s)
                .map_err(|e| anyhow::anyhow!("invalid relay url {:?}: {e}", redact_userinfo(s)))
        })
        .collect()
}

/// Build the `RelayMode` for a one-shot client: a custom map when any relays
/// are configured, else disabled (the client dials a direct `--addr`). Mirrors
/// the client commands' prior behaviour, just sourced from the resolved list.
#[must_use]
#[doc(hidden)]
pub fn relay_mode(relays: &[RelayUrl]) -> RelayMode {
    if relays.is_empty() {
        RelayMode::Disabled
    } else {
        RelayMode::Custom(relays.iter().cloned().collect::<RelayMap>())
    }
}

/// Resolve `[network.discovery]` for a client command, tolerating a missing
/// config exactly like [`resolve_relays`]: an explicit `--config` path that does
/// not exist yields no discovery overrides (→ `presets::N0`), so a client that
/// dials a direct `--addr` needs no config at all.
///
/// # Errors
///
/// Surfaces a present-but-malformed config's parse/validation error.
pub fn client_discovery(config_path: Option<&Path>) -> anyhow::Result<ResolvedDiscovery> {
    if config_path.is_some_and(|p| !p.exists()) {
        return Ok(ResolvedDiscovery::default());
    }
    resolve_discovery(&load_file_config(config_path)?)
}

/// Build a one-shot client [`Endpoint`] that can resolve a target node by its
/// iroh `NodeId`. With no `[network.discovery]` config we use `presets::N0`
/// (the n0-hosted pkarr/DNS lookup the node defaults to); with operator
/// discovery configured we compose only the *resolution* legs onto
/// `presets::Minimal`.
///
/// Relay selection is an independent leg, mirroring the node's `build_endpoint`:
/// a configured relay list (`--relay-url`/`network.relay_urls`, #935) becomes a
/// `RelayMode::Custom` map; with no custom relays we keep `presets::N0`'s n0
/// default relay map, and on `presets::Minimal` (operator discovery) we restore
/// `RelayMode::Default` — dropping the n0 *discovery* leg must not also disable
/// relays, or a NodeId-only dial of a NAT'd node could not connect.
///
/// Note: the pkarr *publisher* leg the node wires (`network.discovery.pkarr_url`)
/// is intentionally omitted — a one-shot client resolves peers, it never
/// publishes its own ephemeral address record. Resolution for an operator
/// namespace goes through its `dns_origin`, which `resolve_config` already
/// requires whenever `pkarr_url` is set. Upgrade path: add a pkarr resolver leg
/// here if a deployment ever resolves via pkarr without a DNS bridge.
pub async fn client_endpoint(
    relays: &[RelayUrl],
    discovery: &ResolvedDiscovery,
) -> anyhow::Result<Endpoint> {
    let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);
    let mut builder = if discovery.is_empty() {
        Endpoint::builder(presets::N0)
    } else {
        add_resolution_lookups(Endpoint::builder(presets::Minimal), discovery)?
    };
    builder = builder
        .secret_key(fresh_secret_key())
        .transport_config(client_transport_config());
    builder = if !relays.is_empty() {
        builder.relay_mode(relay_mode(relays))
    } else if discovery.is_empty() {
        builder // presets::N0 already carries the n0 default relay map.
    } else {
        // presets::Minimal sets no relay mode; restore the n0 default.
        builder.relay_mode(RelayMode::Default)
    };
    builder
        .bind_addr(bind_addr)
        .map_err(|e| anyhow::anyhow!("invalid bind addr {bind_addr}: {e}"))?
        .bind()
        .await
        .map_err(|e| anyhow::anyhow!("endpoint bind failed: {e}"))
}

/// Compose the operator-configured *resolution* legs onto `builder`: a
/// `DnsAddressLookup` for `dns_origin` and a `MemoryLookup` for any static
/// peers. Mirrors the node's `add_discovery_lookups` minus the publisher leg
/// (see [`client_endpoint`]). The peer fields were shape-validated at
/// resolution; they are re-parsed here into iroh types, and any echoed URL is
/// `redact_userinfo`'d so a credential-bearing typo never reaches an error.
fn add_resolution_lookups(
    mut builder: iroh::endpoint::Builder,
    discovery: &ResolvedDiscovery,
) -> anyhow::Result<iroh::endpoint::Builder> {
    if let Some(origin) = &discovery.dns_origin {
        builder = builder.address_lookup(DnsAddressLookup::builder(origin.clone()));
    }
    if !discovery.peers.is_empty() {
        let mut infos = Vec::with_capacity(discovery.peers.len());
        for peer in &discovery.peers {
            let id = peer.node_id.parse::<PublicKey>().map_err(|e| {
                anyhow::anyhow!("invalid network.discovery peer id {}: {e}", peer.node_id)
            })?;
            let mut addr = EndpointAddr::new(id);
            if let Some(relay) = &peer.relay_url {
                let relay_url = relay.parse::<RelayUrl>().map_err(|e| {
                    anyhow::anyhow!(
                        "invalid relay_url {:?} for network.discovery peer {}: {e}",
                        redact_userinfo(relay),
                        peer.node_id
                    )
                })?;
                addr = addr.with_relay_url(relay_url);
            }
            for a in &peer.addrs {
                let sock = a.parse::<std::net::SocketAddr>().map_err(|e| {
                    anyhow::anyhow!(
                        "invalid addr {a:?} for network.discovery peer {}: {e}",
                        peer.node_id
                    )
                })?;
                addr = addr.with_ip_addr(sock);
            }
            infos.push(addr);
        }
        builder = builder.address_lookup(MemoryLookup::from_endpoint_info(infos));
    }
    Ok(builder)
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::indexing_slicing,
    clippy::panic
)]
mod tests;