netget/client/torrent_dht/mod.rs
1//! BitTorrent DHT client implementation
2pub mod actions;
3
4pub use actions::TorrentDhtClientProtocol;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::net::UdpSocket;
11use tokio::sync::mpsc;
12use tracing::{error, info, trace, warn};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::torrent_dht::actions::DHT_RESPONSE_EVENT;
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::actions::protocol_trait::Protocol;
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::protocol::Event;
21use crate::state::app_state::AppState;
22use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
23use crate::state::{AccessLogOwner, ClientId, ClientStatus};
24
25/// DHT query/response types
26#[derive(Debug, Deserialize, Serialize)]
27struct DhtMessage {
28 #[serde(rename = "t")]
29 transaction_id: serde_bencode::value::Value,
30 #[serde(rename = "y")]
31 message_type: String, // "q" = query, "r" = response, "e" = error
32 #[serde(rename = "q")]
33 query_type: Option<String>, // "ping", "find_node", "get_peers", "announce_peer"
34 #[serde(rename = "a")]
35 arguments: Option<serde_bencode::value::Value>,
36 #[serde(rename = "r")]
37 response: Option<serde_bencode::value::Value>,
38 #[serde(rename = "e")]
39 error: Option<serde_bencode::value::Value>,
40}
41
42/// Decode a KRPC field the model was told is hex, and say plainly when it is not.
43///
44/// The model sees "20 bytes hex" in the parameter description, so the error has to name the
45/// field and what was wrong with it — a bare `hex::decode` failure gives it nothing to
46/// correct. `expect_len` is the decoded length BEP 5 fixes for that field, where it fixes one.
47fn decode_krpc_hex(field: &str, value: &str, expect_len: Option<usize>) -> Result<Vec<u8>> {
48 let bytes = hex::decode(value).map_err(|e| {
49 anyhow::anyhow!(
50 "'{}' must be hex-encoded ({} characters for {} bytes): {}",
51 field,
52 expect_len.map(|n| n * 2).unwrap_or(0),
53 expect_len.unwrap_or(0),
54 e
55 )
56 })?;
57 if let Some(expected) = expect_len {
58 if bytes.len() != expected {
59 return Err(anyhow::anyhow!(
60 "'{}' decoded to {} bytes; BEP 5 requires exactly {}",
61 field,
62 bytes.len(),
63 expected
64 ));
65 }
66 }
67 Ok(bytes)
68}
69
70/// A fresh two-byte KRPC transaction id.
71///
72/// A counter rather than a random value: it is only ever compared for equality against the
73/// reply, and a monotonic one makes a packet capture readable. Two bytes is what BEP 5's own
74/// examples use and what every client in the wild sends.
75fn next_transaction_id() -> [u8; 2] {
76 use std::sync::atomic::{AtomicU16, Ordering};
77 static NEXT: AtomicU16 = AtomicU16::new(1);
78 NEXT.fetch_add(1, Ordering::Relaxed).to_be_bytes()
79}
80
81/// BitTorrent DHT client
82pub struct TorrentDhtClient;
83
84impl TorrentDhtClient {
85 /// Connect to a BitTorrent DHT node with integrated LLM actions
86 pub async fn connect_with_llm_actions(
87 remote_addr: String,
88 llm_client: OllamaClient,
89 app_state: Arc<AppState>,
90 status_tx: mpsc::UnboundedSender<String>,
91 client_id: ClientId,
92 ) -> Result<SocketAddr> {
93 // Parse remote address
94 let remote_sock_addr: SocketAddr = remote_addr
95 .parse()
96 .context(format!("Invalid DHT node address: {}", remote_addr))?;
97
98 // Create UDP socket
99 let socket = UdpSocket::bind("0.0.0.0:0")
100 .await
101 .context("Failed to bind UDP socket for DHT")?;
102
103 let local_addr = socket.local_addr()?;
104
105 info!(
106 "BitTorrent DHT client {} initialized (local: {}, remote: {})",
107 client_id, local_addr, remote_sock_addr
108 );
109
110 // Update client state
111 app_state
112 .update_client_status(client_id, ClientStatus::Connected)
113 .await;
114 let _ = status_tx.send(format!(
115 "[CLIENT] BitTorrent DHT client {} connected",
116 client_id
117 ));
118 let _ = status_tx.send("__UPDATE_UI__".to_string());
119
120 let socket_arc = Arc::new(socket);
121
122 // Command channel for injected actions (the dashboard's [ send ]). Registered
123 // BEFORE the connected-event LLM call below, which a manual `*` routing rule can
124 // park for minutes - the operator must be able to reach the client while it waits.
125 let command_rx =
126 crate::client::command_support::register_command_channel(&app_state, client_id).await;
127
128 // Spawn read loop for DHT responses
129 let socket_clone = socket_arc.clone();
130 let app_state_clone = app_state.clone();
131 let status_tx_clone = status_tx.clone();
132 let llm_client_clone = llm_client.clone();
133 // Registered with AppState so stop_client can abort this task —
134 // dropping a JoinHandle only detaches it in Tokio.
135 let task_registrar = app_state.clone();
136 let task_handle = tokio::spawn(async move {
137 let mut buf = vec![0u8; 65536];
138 loop {
139 match socket_clone.recv_from(&mut buf).await {
140 Ok((len, peer)) => {
141 trace!(
142 "DHT client {} received {} bytes from {}",
143 client_id,
144 len,
145 peer
146 );
147
148 // Bound the nesting before serde_bencode sees it. A typed decode is
149 // no protection: serde's derive skips unknown fields with
150 // `IgnoredAny`, which serde_bencode forwards to `deserialize_any`,
151 // so `DhtMessage` reaches the same uncounted recursion as a raw
152 // `Value` would. One 65 KB datagram of `l` bytes overflows the
153 // stack, and a stack overflow is SIGSEGV — it takes the whole
154 // process with it, not just this task. The socket is unconnected,
155 // so any host that can reach the port can send it.
156 if let Err(e) = crate::utils::bencode::check_bencode_structure(&buf[..len])
157 {
158 warn!(
159 "DHT client {} rejected {} byte datagram from {}: {}",
160 client_id, len, peer, e
161 );
162 continue;
163 }
164
165 // Parse bencode response
166 match serde_bencode::from_bytes::<DhtMessage>(&buf[..len]) {
167 Ok(msg) => {
168 trace!("DHT message: {:?}", msg);
169
170 // Call LLM with response
171 if let Some(instruction) =
172 app_state_clone.get_instruction_for_client(client_id).await
173 {
174 let protocol = Arc::new(crate::client::torrent_dht::actions::TorrentDhtClientProtocol::new());
175 let event = Event::new(
176 &DHT_RESPONSE_EVENT,
177 serde_json::json!({
178 "message_type": msg.message_type,
179 "query_type": msg.query_type,
180 "response": format!("{:?}", msg.response),
181 "error": format!("{:?}", msg.error),
182 "peer": peer.to_string(),
183 }),
184 );
185
186 let memory = app_state_clone
187 .get_memory_for_client(client_id)
188 .await
189 .unwrap_or_default();
190
191 match call_llm_for_client(
192 &llm_client_clone,
193 &app_state_clone,
194 client_id.to_string(),
195 &instruction,
196 &memory,
197 Some(&event),
198 protocol.as_ref(),
199 &status_tx_clone,
200 )
201 .await
202 {
203 Ok(ClientLlmResult {
204 actions,
205 memory_updates,
206 }) => {
207 // Update memory
208 if let Some(mem) = memory_updates {
209 app_state_clone
210 .set_memory_for_client(client_id, mem)
211 .await;
212 }
213
214 // Execute actions
215 for action in actions {
216 if let Err(e) = Self::execute_dht_action(
217 client_id,
218 action,
219 &socket_clone,
220 remote_sock_addr,
221 protocol.as_ref(),
222 )
223 .await
224 {
225 error!("Failed to execute DHT action: {}", e);
226 }
227 }
228 }
229 Err(e) => {
230 error!("LLM error for DHT client {}: {}", client_id, e);
231 }
232 }
233 }
234 }
235 Err(e) => {
236 error!("Failed to parse DHT message: {}", e);
237 }
238 }
239 }
240 Err(e) => {
241 error!("DHT client {} recv error: {}", client_id, e);
242 app_state_clone
243 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
244 .await;
245 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
246 break;
247 }
248 }
249 }
250 // Every exit path lands here: drop the command handle so the dashboard stops
251 // offering [ send ] on a dead client, and a late send fails fast.
252 app_state_clone.remove_client_handle(client_id).await;
253 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
254 });
255 // Kept so an injected `disconnect` can actually stop the receive loop and release
256 // the UDP socket - a connectionless client has no wire close to send.
257 let read_abort = task_handle.abort_handle();
258 task_registrar
259 .register_client_task(client_id, task_handle)
260 .await;
261
262 // `recv_from` is cancellation-safe, but the read loop awaits `call_llm_for_client`
263 // inline, so a `select!` arm there would stall for the whole LLM round-trip. Drain
264 // commands in their own registered task instead; both share the `Arc<UdpSocket>`.
265 let cmd_state = app_state.clone();
266 let cmd_status = status_tx.clone();
267 let cmd_socket = socket_arc.clone();
268 let cmd_task = tokio::spawn(async move {
269 Self::command_loop(
270 command_rx,
271 Arc::new(TorrentDhtClientProtocol::new()),
272 cmd_socket,
273 remote_sock_addr,
274 client_id,
275 cmd_state,
276 cmd_status,
277 read_abort,
278 )
279 .await;
280 });
281 app_state.register_client_task(client_id, cmd_task).await;
282
283 // Call LLM with connected event
284 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
285 let protocol =
286 Arc::new(crate::client::torrent_dht::actions::TorrentDhtClientProtocol::new());
287 let event = Event::new(
288 &DHT_RESPONSE_EVENT,
289 serde_json::json!({
290 "status": "connected",
291 "local_addr": local_addr.to_string(),
292 "remote_addr": remote_sock_addr.to_string(),
293 }),
294 );
295
296 let memory = app_state
297 .get_memory_for_client(client_id)
298 .await
299 .unwrap_or_default();
300 let socket_clone = socket_arc.clone();
301 let app_state_clone = app_state.clone();
302 let status_tx_clone = status_tx.clone();
303
304 // Registered with AppState so stop_client can abort this task —
305 // dropping a JoinHandle only detaches it in Tokio.
306 let task_registrar = app_state.clone();
307 let task_handle = tokio::spawn(async move {
308 match call_llm_for_client(
309 &llm_client,
310 &app_state_clone,
311 client_id.to_string(),
312 &instruction,
313 &memory,
314 Some(&event),
315 protocol.as_ref(),
316 &status_tx_clone,
317 )
318 .await
319 {
320 Ok(ClientLlmResult {
321 actions,
322 memory_updates,
323 }) => {
324 if let Some(mem) = memory_updates {
325 app_state_clone.set_memory_for_client(client_id, mem).await;
326 }
327
328 for action in actions {
329 if let Err(e) = Self::execute_dht_action(
330 client_id,
331 action,
332 &socket_clone,
333 remote_sock_addr,
334 protocol.as_ref(),
335 )
336 .await
337 {
338 error!("Failed to execute DHT action: {}", e);
339 }
340 }
341 }
342 Err(e) => {
343 error!("LLM error: {}", e);
344 }
345 }
346 });
347 task_registrar
348 .register_client_task(client_id, task_handle)
349 .await;
350 }
351
352 Ok(local_addr)
353 }
354
355 /// Drain injected commands until the channel closes (client removed) or an injected
356 /// `disconnect` ends the session.
357 ///
358 /// The generic `command_support::handle_stream_client_command` cannot serve this client:
359 /// its verbs produce `ClientActionResult::Custom` and the transport is a datagram socket,
360 /// not an `AsyncWrite`. The action still goes through [`Self::apply_action`] - the same
361 /// function the LLM path uses - so the bencode encoding exists exactly once.
362 #[allow(clippy::too_many_arguments)]
363 async fn command_loop(
364 mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
365 protocol: Arc<TorrentDhtClientProtocol>,
366 socket: Arc<UdpSocket>,
367 remote_addr: SocketAddr,
368 client_id: ClientId,
369 app_state: Arc<AppState>,
370 status_tx: mpsc::UnboundedSender<String>,
371 read_abort: tokio::task::AbortHandle,
372 ) {
373 while let Some(command) = command_rx.recv().await {
374 let action = command.action.clone();
375 let outcome = match protocol.execute_action(action.clone()) {
376 Err(e) => Ok(ClientSendOutcome::Rejected {
377 error: e.to_string(),
378 }),
379 Ok(result) => Self::apply_action(client_id, result, &socket, remote_addr)
380 .await
381 .map(|applied| match applied {
382 Applied::Disconnect => ClientSendOutcome::Disconnected,
383 Applied::Sent(0) => ClientSendOutcome::Executed {
384 detail: "executed (no datagram sent)".to_string(),
385 },
386 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
387 Applied::Nothing(detail) => ClientSendOutcome::Executed { detail },
388 Applied::Refused(error) => ClientSendOutcome::Rejected { error },
389 }),
390 };
391
392 let outcome_json = match &outcome {
393 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
394 Err(e) => serde_json::json!({"error": e.to_string()}),
395 };
396 app_state
397 .record_access_log(
398 AccessLogOwner::Client(client_id.as_u32()),
399 protocol.protocol_name(),
400 None,
401 "injected_action",
402 action,
403 vec![outcome_json],
404 )
405 .await;
406
407 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
408 if let Err(e) = &outcome {
409 error!("DHT client {} injected action failed: {}", client_id, e);
410 let _ = status_tx.send(format!(
411 "[WARN] Client {} injected action failed: {}",
412 client_id, e
413 ));
414 }
415 let _ = status_tx.send("__UPDATE_UI__".to_string());
416 crate::client::command_support::reply(command, outcome);
417
418 if disconnect {
419 // No wire close exists for UDP, so "disconnected" means: stop receiving,
420 // release the socket, drop the handle so [ send ] is greyed out again.
421 read_abort.abort();
422 app_state.remove_client_handle(client_id).await;
423 app_state
424 .update_client_status(client_id, ClientStatus::Disconnected)
425 .await;
426 let _ = status_tx.send("__UPDATE_UI__".to_string());
427 break;
428 }
429 }
430 }
431
432 /// Execute a DHT action
433 async fn execute_dht_action(
434 client_id: ClientId,
435 action: serde_json::Value,
436 socket: &UdpSocket,
437 remote_addr: SocketAddr,
438 protocol: &dyn crate::llm::actions::client_trait::Client,
439 ) -> Result<()> {
440 let result = protocol.execute_action(action)?;
441 Self::apply_action(client_id, result, socket, remote_addr).await?;
442 Ok(())
443 }
444
445 /// Put one executed action on the wire. Shared by the LLM path and injected commands
446 /// so the bencode query encoding exists exactly once.
447 async fn apply_action(
448 client_id: ClientId,
449 result: ClientActionResult,
450 socket: &UdpSocket,
451 remote_addr: SocketAddr,
452 ) -> Result<Applied> {
453 match result {
454 ClientActionResult::Custom { name, data } if name == "dht_query" => {
455 use serde_bencode::value::Value as Bencode;
456
457 let query_type = data
458 .get("query_type")
459 .and_then(|v| v.as_str())
460 .context("Missing query_type")?;
461 let node_id = data
462 .get("node_id")
463 .and_then(|v| v.as_str())
464 .context("Missing node_id")?;
465
466 // Every query used to carry the literal transaction id "aa", so a node's
467 // replies could not be matched to the query that provoked them and two
468 // queries in flight were indistinguishable. `t` is the only correlation key
469 // KRPC has.
470 // A field the caller got wrong is `Refused`, not `Err`: nothing is broken,
471 // the request was simply not answerable, and the model needs to be told
472 // which field and why rather than seeing a transport failure.
473 macro_rules! decode_or_refuse {
474 ($field:expr, $value:expr, $len:expr) => {
475 match decode_krpc_hex($field, $value, $len) {
476 Ok(bytes) => bytes,
477 Err(e) => return Ok(Applied::Refused(e.to_string())),
478 }
479 };
480 }
481
482 let transaction_id = match data.get("transaction_id").and_then(|v| v.as_str()) {
483 Some(hex_id) => decode_or_refuse!("transaction_id", hex_id, None),
484 None => next_transaction_id().to_vec(),
485 };
486
487 // Build DHT query message.
488 //
489 // `id`, `target` and `info_hash` are declared to the model as "20 bytes hex"
490 // and are now decoded as such. They used to be placed as JSON strings and
491 // bencoded as their own UTF-8 bytes, so a model that obeyed the description
492 // put **40** bytes on the wire where BEP 5 requires 20 — the `send_tcp_data`
493 // defect shape, documented as hex and never decoded. It also meant this
494 // client could not talk to NetGet's own DHT *server*, which hex-decodes all
495 // three. The examples were the only part that matched the old behaviour, and
496 // they were not even valid hex ("abcdefghij…" contains g-j).
497 let mut args = std::collections::HashMap::new();
498 args.insert(
499 b"id".to_vec(),
500 Bencode::Bytes(decode_or_refuse!("node_id", node_id, Some(20))),
501 );
502
503 if let Some(target) = data.get("target").and_then(|v| v.as_str()) {
504 args.insert(
505 b"target".to_vec(),
506 Bencode::Bytes(decode_or_refuse!("target", target, Some(20))),
507 );
508 }
509
510 if let Some(info_hash) = data.get("info_hash").and_then(|v| v.as_str()) {
511 args.insert(
512 b"info_hash".to_vec(),
513 Bencode::Bytes(decode_or_refuse!("info_hash", info_hash, Some(20))),
514 );
515 }
516
517 let mut message = std::collections::HashMap::new();
518 message.insert(b"t".to_vec(), Bencode::Bytes(transaction_id));
519 message.insert(b"y".to_vec(), Bencode::Bytes(b"q".to_vec()));
520 message.insert(
521 b"q".to_vec(),
522 Bencode::Bytes(query_type.as_bytes().to_vec()),
523 );
524 message.insert(b"a".to_vec(), Bencode::Dict(args));
525
526 // Encode as bencode
527 let encoded = serde_bencode::to_bytes(&Bencode::Dict(message))?;
528
529 // Send query
530 let sent = socket.send_to(&encoded, remote_addr).await?;
531 trace!("DHT client {} sent {} query", client_id, query_type);
532 Ok(Applied::Sent(sent))
533 }
534 ClientActionResult::Disconnect => {
535 info!("DHT client {} disconnecting", client_id);
536 Ok(Applied::Disconnect)
537 }
538 ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more".to_string())),
539 ClientActionResult::Custom { name, .. } => Ok(Applied::Nothing(format!(
540 "custom result '{name}' is not a DHT query; nothing sent"
541 ))),
542 other => Ok(Applied::Nothing(format!(
543 "action result {other:?} produced no datagram"
544 ))),
545 }
546 }
547}
548
549/// What [`TorrentDhtClient::apply_action`] did with one action.
550#[derive(Debug)]
551enum Applied {
552 /// Datagram bytes actually handed to `send_to` (0 when nothing was sent).
553 Sent(usize),
554 /// Ran, but produced no datagram; the string says why.
555 Nothing(String),
556 /// The action's own parameters were wrong -- a `node_id` that is not hex, an
557 /// `info_hash` of the wrong length. Nothing was written and nothing is broken; the
558 /// caller simply asked for something impossible, exactly as when it names a verb the
559 /// protocol does not have. It therefore comes back as `Rejected`, not as a transport
560 /// error, so the dashboard shows the model what to correct.
561 Refused(String),
562 /// The session should end.
563 Disconnect,
564}