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}