Skip to main content

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}