Skip to main content

decdn_node/dht/
client.rs

1//! Client-side `cdn/dht/v1` request primitives.
2//!
3//! The handler in [`crate::handlers::dht`] is the server half — it
4//! receives `FindNode` / `FindValue` / `Store` / `BatchStore` requests
5//! and writes responses. This module is the *client* half: outbound
6//! requests issued by the bootstrap path (initial
7//! `FindNode(self.node_id)`), the republish scheduler (a per-hash
8//! `Store` on eager publish and one `BatchStore` per receiver on the
9//! periodic drain), and the iterative requester-side `FindValue` lookup
10//! in [`crate::dht::lookup`].
11//!
12//! Each call:
13//! 1. Connects to the peer's [`EndpointAddr`] with ALPN `cdn/dht/v1`.
14//! 2. Opens a bi stream.
15//! 3. Writes one framed [`wire::DhtMessage`] request.
16//! 4. Reads one framed response, decodes, and returns it.
17//! 5. Closes the connection with `0u32` (no app error).
18//!
19//! The signatures mirror the server-side message types: callers
20//! provide the request fields, get back the response struct. Error
21//! handling surfaces via `anyhow::Result`; a peer that closes the
22//! stream with an ADR 013 application error code (rate-limited,
23//! malformed) surfaces as an `anyhow` error the caller logs and retries
24//! on its next cycle.
25
26use std::time::Duration;
27
28use anyhow::Context;
29use decdn_protocol::{
30    ALPN_DHT, ContentHash, Coverage, NodeId, decode_message, dht as wire, encode_message,
31    encode_store_request, read_frame, write_frame,
32};
33use iroh::endpoint::{ConnectOptions, Connection};
34use iroh::{Endpoint, EndpointAddr};
35
36/// Per-request hard timeout for outbound DHT exchanges. Shorter than
37/// the sum of the server-side accept-bi timeout (5s) and read
38/// timeout (5s) in [`crate::handlers::dht`] so a hung peer doesn't
39/// wedge a republish sweep.
40pub const DHT_CLIENT_TIMEOUT: Duration = Duration::from_secs(8);
41
42/// Send a single `FindNode` request to `target` and return the response.
43///
44/// Used by the bootstrap path (`FindNode(self.node_id)` to seed the
45/// routing table) and by the bucket-refresh task
46/// (`FindNode(random_id_in_bucket)`).
47pub async fn find_node(
48    endpoint: &Endpoint,
49    target: EndpointAddr,
50    target_id: NodeId,
51    requester: NodeId,
52) -> anyhow::Result<wire::FindNodeResponse> {
53    let request = wire::DhtMessage::FindNode(wire::FindNodeRequest {
54        target: target_id,
55        requester,
56    });
57    let response = exchange(endpoint, target, &request).await?;
58    match response {
59        wire::DhtMessage::FindNodeResponse(r) => Ok(r),
60        other => anyhow::bail!(
61            "dht client: expected FindNodeResponse, got {} variant",
62            variant_name(&other)
63        ),
64    }
65}
66
67/// Send a single `Store` request to `target` and return the ack.
68///
69/// Used by the republish scheduler's eager publish of a newly advertisable
70/// hash — one call per K+3 closest peer, per ADR 022 §STORE Flow steps 1-2.
71pub async fn store(
72    endpoint: &Endpoint,
73    target: EndpointAddr,
74    hash: ContentHash,
75    holder: NodeId,
76    coverage: Coverage,
77) -> anyhow::Result<wire::StoreAck> {
78    let request = wire::StoreRequest {
79        hash,
80        holder,
81        coverage,
82    };
83    // Encode through the typed two-phase helper (ADR 013 §Tier 1) so the
84    // client half mirrors the server's `parse_store_request_ext` seam: the
85    // `StoreRequestExt` is appended here (empty today) and the payload is
86    // sent as pre-encoded bytes via `exchange_payload`.
87    let payload =
88        encode_store_request(&request, None).context("dht client: encode store request")?;
89    let response = exchange_payload(endpoint, target, &payload).await?;
90    match response {
91        wire::DhtMessage::StoreAck(a) => Ok(a),
92        other => anyhow::bail!(
93            "dht client: expected StoreAck, got {} variant",
94            variant_name(&other)
95        ),
96    }
97}
98
99/// Send a single `BatchStore` request to `target` and return the ack
100/// (ADR 022 §STORE Flow Batched STORE). One `bool` per request hash comes
101/// back in request order. Used by the re-publish scheduler, which groups
102/// a drain cycle's hashes by receiver and sends each receiver its set via
103/// this. Every
104/// DHT node implements `BatchStore`, so there is no per-hash fallback.
105///
106/// `entries` MUST be ≤ [`decdn_protocol::dht::MAX_BATCH_STORE_HASHES`];
107/// an oversize batch is rejected by the receiver at wire decode with
108/// `MALFORMED_MESSAGE` and surfaces here as an error (the caller must
109/// split the set, not retry).
110pub async fn batch_store(
111    endpoint: &Endpoint,
112    target: EndpointAddr,
113    entries: Vec<(ContentHash, Coverage)>,
114    holder: NodeId,
115) -> anyhow::Result<wire::BatchStoreAck> {
116    let n = entries.len();
117    let request = wire::DhtMessage::BatchStore(wire::BatchStoreRequest { entries, holder });
118    let response = exchange(endpoint, target, &request).await?;
119    match response {
120        // A conformant receiver returns exactly one `bool` per request hash
121        // (`results[i]` ↔ `hashes[i]`); the wire type only caps the max
122        // length. Reject a mis-sized ack as an error rather than hand back a
123        // result vector that silently mis-maps onto the request hashes.
124        wire::DhtMessage::BatchStoreAck(a) if a.results.len() == n => Ok(a),
125        wire::DhtMessage::BatchStoreAck(a) => anyhow::bail!(
126            "dht client: BatchStoreAck has {} results for a {n}-hash request",
127            a.results.len(),
128        ),
129        other => anyhow::bail!(
130            "dht client: expected BatchStoreAck, got {} variant",
131            variant_name(&other)
132        ),
133    }
134}
135
136/// Send a single `FindValue` request to `target` and return the response.
137///
138/// Used by the requester-side iterative lookup ([`crate::dht::lookup`]).
139/// Exposed here so the same connect/encode/decode plumbing isn't
140/// duplicated across the bootstrap / republish / lookup call sites.
141pub async fn find_value(
142    endpoint: &Endpoint,
143    target: EndpointAddr,
144    hash: ContentHash,
145    requester: NodeId,
146) -> anyhow::Result<wire::FindValueResponse> {
147    let request = wire::DhtMessage::FindValue(wire::FindValueRequest { hash, requester });
148    let response = exchange(endpoint, target, &request).await?;
149    match response {
150        wire::DhtMessage::FindValueResponse(r) => Ok(r),
151        other => anyhow::bail!(
152            "dht client: expected FindValueResponse, got {} variant",
153            variant_name(&other)
154        ),
155    }
156}
157
158/// Inner: connect, `open_bi`, write request frame, read response
159/// frame, decode. Wraps the whole sequence in a single
160/// [`DHT_CLIENT_TIMEOUT`] so a hung peer can't block the caller's
161/// scheduler indefinitely.
162async fn exchange(
163    endpoint: &Endpoint,
164    target: EndpointAddr,
165    request: &wire::DhtMessage,
166) -> anyhow::Result<wire::DhtMessage> {
167    let payload = encode_message(request).context("dht client: encode request")?;
168    exchange_payload(endpoint, target, &payload).await
169}
170
171/// Inner: connect, `open_bi`, write a pre-encoded request frame, read
172/// response frame, decode. The payload is already encoded so a caller
173/// that needs a typed two-phase encode (e.g. `store` via
174/// [`encode_store_request`]) can hand over its bytes directly, keeping
175/// the connect/exchange plumbing in one place.
176async fn exchange_payload(
177    endpoint: &Endpoint,
178    target: EndpointAddr,
179    payload: &[u8],
180) -> anyhow::Result<wire::DhtMessage> {
181    let response = tokio::time::timeout(DHT_CLIENT_TIMEOUT, async {
182        let connecting = endpoint
183            .connect_with_opts(target, ALPN_DHT, ConnectOptions::new())
184            .await
185            .context("dht client: connect failed")?;
186        let conn = connecting.await.context("dht client: handshake failed")?;
187        let resp = exchange_on(&conn, payload).await?;
188        // `0u32` = "no app error"; matches the server's normal-close
189        // code so the peer's `conn.closed()` arm reads the same way
190        // it does for a probe.
191        conn.close(0u32.into(), b"dht-done");
192        Ok::<_, anyhow::Error>(resp)
193    })
194    .await
195    .map_err(|_| {
196        anyhow::anyhow!(
197            "dht client: request timed out after {} ms",
198            DHT_CLIENT_TIMEOUT.as_millis()
199        )
200    })??;
201    Ok(response)
202}
203
204/// Inner-inner: do the bi-stream exchange on an already-connected
205/// [`Connection`]. A separate function so a caller that wants to bundle
206/// multiple DHT requests on one connection (e.g. a republish sweep to
207/// the same target peer) can re-use the connection.
208async fn exchange_on(conn: &Connection, payload: &[u8]) -> anyhow::Result<wire::DhtMessage> {
209    let (mut send, mut recv) = conn
210        .open_bi()
211        .await
212        .map_err(|e| anyhow::anyhow!("dht client: open_bi failed: {e}"))?;
213    write_frame(&mut send, payload)
214        .await
215        .map_err(|e| anyhow::anyhow!("dht client: write request: {e}"))?;
216    send.finish()
217        .map_err(|e| anyhow::anyhow!("dht client: finish send: {e}"))?;
218    let frame = read_frame(&mut recv)
219        .await
220        .map_err(|e| anyhow::Error::new(e).context("dht client: read response"))?;
221    let (msg, _tail) = decode_message::<wire::DhtMessage>(&frame)
222        .map_err(|e| anyhow::anyhow!("dht client: decode response: {e}"))?;
223    Ok(msg)
224}
225
226const fn variant_name(msg: &wire::DhtMessage) -> &'static str {
227    match msg {
228        wire::DhtMessage::FindValue(_) => "FindValue",
229        wire::DhtMessage::FindValueResponse(_) => "FindValueResponse",
230        wire::DhtMessage::Store(_) => "Store",
231        wire::DhtMessage::StoreAck(_) => "StoreAck",
232        wire::DhtMessage::BatchStore(_) => "BatchStore",
233        wire::DhtMessage::BatchStoreAck(_) => "BatchStoreAck",
234        wire::DhtMessage::FindNode(_) => "FindNode",
235        wire::DhtMessage::FindNodeResponse(_) => "FindNodeResponse",
236    }
237}