netget/server/torrent_dht/mod.rs
1//! BitTorrent DHT server implementation
2//!
3//! UDP-based Kademlia DHT for distributed peer discovery (BEP 5)
4
5pub mod actions;
6
7use anyhow::Result;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::net::UdpSocket;
11use tokio::sync::mpsc;
12use tracing::{debug, error, info, trace};
13
14use crate::llm::action_helper::call_llm;
15use crate::llm::ollama_client::OllamaClient;
16use crate::protocol::Event;
17use crate::server::connection::ConnectionId;
18use crate::state::app_state::AppState;
19use crate::utils::WireFailure;
20use crate::{console_debug, console_trace};
21use actions::TorrentDhtProtocol;
22
23/// BEP 5 error code 201 — "Generic Error".
24const KRPC_GENERIC_ERROR: i64 = 201;
25/// BEP 5 error code 202 — "Server Error". Used for the transient (overloaded) case so a
26/// querying node distinguishes "come back later" from a permanent fault on this node.
27const KRPC_SERVER_ERROR: i64 = 202;
28
29/// Bound converted values independently of the wire decoder's preflight.
30pub const MAX_DHT_VALUE_DEPTH: usize = crate::utils::bencode::MAX_BENCODE_DEPTH;
31
32/// BitTorrent DHT server
33pub struct TorrentDhtServer;
34
35impl TorrentDhtServer {
36 /// Spawn BitTorrent DHT server with LLM actions
37 pub async fn spawn_with_llm_actions(
38 listen_addr: SocketAddr,
39 llm_client: OllamaClient,
40 app_state: Arc<AppState>,
41 status_tx: mpsc::UnboundedSender<String>,
42 server_id: crate::state::ServerId,
43 ) -> Result<SocketAddr> {
44 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
45 let local_addr = socket.local_addr()?;
46 info!(
47 "BitTorrent DHT server (action-based) listening on {}",
48 local_addr
49 );
50 let _ = status_tx.send(format!(
51 "[INFO] BitTorrent DHT server listening on {}",
52 local_addr
53 ));
54
55 let protocol = Arc::new(TorrentDhtProtocol::new());
56
57 let task_registrar = app_state.clone();
58 let accept_handle = tokio::spawn(async move {
59 let mut buffer = vec![0u8; 65535];
60
61 loop {
62 match socket.recv_from(&mut buffer).await {
63 Ok((n, peer_addr)) => {
64 let data = buffer[..n].to_vec();
65 let connection_id =
66 ConnectionId::new(app_state.get_next_unified_id().await);
67
68 // Add connection to ServerInstance
69 use crate::state::server::{
70 ConnectionState as ServerConnectionState, ConnectionStatus,
71 ProtocolConnectionInfo,
72 };
73 let now = crate::utils::clock::Instant::now();
74 let conn_state = ServerConnectionState {
75 id: connection_id,
76 remote_addr: peer_addr,
77 local_addr,
78 bytes_sent: 0,
79 bytes_received: n as u64,
80 packets_sent: 0,
81 packets_received: 1,
82 last_activity: now,
83 status: ConnectionStatus::Active,
84 status_changed_at: now,
85 protocol_info: ProtocolConnectionInfo::empty(),
86 };
87 app_state
88 .add_connection_to_server(server_id, conn_state)
89 .await;
90 let _ = status_tx.send("__UPDATE_UI__".to_string());
91
92 console_debug!(
93 status_tx,
94 "BitTorrent DHT received {} bytes from {}",
95 n,
96 peer_addr
97 );
98
99 // TRACE: Log full payload
100 let hex_str = hex::encode(&data);
101 console_trace!(status_tx, "BitTorrent DHT data (hex): {}", hex_str);
102
103 let llm_clone = llm_client.clone();
104 let state_clone = app_state.clone();
105 let status_clone = status_tx.clone();
106 let socket_clone = socket.clone();
107 let protocol_clone = protocol.clone();
108
109 // Tracked, not detached: stop_server must abort this task too.
110 let task_owner = app_state.clone();
111 task_owner.spawn_server_task(server_id, async move {
112 // Parse bencode KRPC message
113 match Self::parse_krpc_message(&data) {
114 Ok((query_type, params)) => {
115 debug!("BitTorrent DHT query type: {}", query_type);
116 let _ = status_clone.send(format!(
117 "[DEBUG] BitTorrent DHT query type: {}",
118 query_type
119 ));
120
121 // Create event for LLM
122 let event_type = match query_type.as_str() {
123 "ping" => &actions::DHT_PING_QUERY_EVENT,
124 "find_node" => &actions::DHT_FIND_NODE_QUERY_EVENT,
125 "get_peers" => &actions::DHT_GET_PEERS_QUERY_EVENT,
126 "announce_peer" => &actions::DHT_ANNOUNCE_PEER_QUERY_EVENT,
127 // An unrecognised `q` still reaches the ping
128 // handler, whose reply shape (`{"id": ...}`) is the
129 // KRPC minimum. `query_type` in the event data says
130 // what actually arrived, so a handler can answer
131 // with send_dht_error_response code 204 instead.
132 other => {
133 tracing::warn!(
134 "BitTorrent DHT: unsupported query '{}', \
135 routing to dht_ping_query",
136 other
137 );
138 &actions::DHT_PING_QUERY_EVENT
139 }
140 };
141 // Kept out of the event so a failure reply can still echo
142 // `t`: a KRPC reply that does not carry the querying
143 // node's transaction id is dropped, and the peer waits
144 // out its own timeout exactly as if we had said nothing.
145 let transaction_id_hex = params
146 .get("transaction_id")
147 .and_then(|v| v.as_str())
148 .map(|s| s.to_string());
149
150 let event = Event::new(event_type, serde_json::json!(params));
151
152 debug!("BitTorrent DHT calling LLM for {} query", query_type);
153 let _ = status_clone.send(format!(
154 "[DEBUG] BitTorrent DHT calling LLM for {} query",
155 query_type
156 ));
157
158 // Call LLM
159 match call_llm(
160 &llm_clone,
161 &state_clone,
162 server_id,
163 None,
164 &event,
165 protocol_clone.as_ref(),
166 )
167 .await
168 {
169 Ok(execution_result) => {
170 for message in &execution_result.messages {
171 info!("{}", message);
172 let _ = status_clone
173 .send(format!("[INFO] {}", message));
174 }
175
176 debug!(
177 "BitTorrent DHT got {} protocol results",
178 execution_result.protocol_results.len()
179 );
180 let _ = status_clone.send(format!(
181 "[DEBUG] BitTorrent DHT got {} protocol results",
182 execution_result.protocol_results.len()
183 ));
184
185 // An explicit refusal by the model is a KRPC
186 // error reply it asked for itself; keep it
187 // distinguishable in the log from our own
188 // fail-closed reply, which says nothing about
189 // what the model thought.
190 let model_rejected =
191 execution_result.raw_actions.iter().any(|a| {
192 a.get("type").and_then(|t| t.as_str())
193 == Some("send_dht_error_response")
194 });
195
196 let mut sent_any = false;
197 for protocol_result in execution_result.protocol_results
198 {
199 if let Some(output_data) =
200 protocol_result.get_all_output().first()
201 {
202 if let Err(e) = socket_clone
203 .send_to(output_data, peer_addr)
204 .await
205 {
206 error!(
207 "Failed to send DHT response: {}",
208 e
209 );
210 } else {
211 sent_any = true;
212 debug!(
213 "BitTorrent DHT sent {} bytes to {}",
214 output_data.len(),
215 peer_addr
216 );
217 let _ = status_clone.send(format!("[DEBUG] BitTorrent DHT sent {} bytes to {}", output_data.len(), peer_addr));
218
219 let hex_str = hex::encode(output_data);
220 trace!(
221 "BitTorrent DHT sent (hex): {}",
222 hex_str
223 );
224 let _ = status_clone.send(format!(
225 "[TRACE] BitTorrent DHT sent (hex): {}",
226 hex_str
227 ));
228 }
229 }
230 }
231
232 if sent_any {
233 debug!(
234 "BitTorrent DHT {} from {} decision={}",
235 query_type,
236 peer_addr,
237 if model_rejected {
238 "model_reject"
239 } else {
240 "model_answer"
241 }
242 );
243 } else {
244 // The model produced nothing that reaches the
245 // wire. Staying silent would leave the
246 // querying node blocked until its own
247 // timeout, so answer with a category.
248 tracing::warn!(
249 "BitTorrent DHT {} from {} decision=\
250 fail_closed_no_action",
251 query_type,
252 peer_addr
253 );
254 let _ = status_clone.send(format!(
255 "[WARN] BitTorrent DHT {} from {}: no action \
256 from the model, replying KRPC error",
257 query_type, peer_addr
258 ));
259 Self::send_failure_reply(
260 &socket_clone,
261 peer_addr,
262 transaction_id_hex.as_deref(),
263 WireFailure::Unavailable,
264 &status_clone,
265 )
266 .await;
267 }
268 }
269 Err(e) => {
270 // The error itself goes to the log and the status
271 // stream only — the peer gets a bare BEP 5 error
272 // code and a fixed category string.
273 let failure = WireFailure::classify(&e);
274 error!(
275 "BitTorrent DHT {} from {} \
276 decision=fail_closed_llm_error \
277 category={:?}: {}",
278 query_type, peer_addr, failure, e
279 );
280 let _ = status_clone.send(format!(
281 "[ERROR] BitTorrent DHT LLM call failed for {} \
282 from {}: {}",
283 query_type, peer_addr, e
284 ));
285 Self::send_failure_reply(
286 &socket_clone,
287 peer_addr,
288 transaction_id_hex.as_deref(),
289 failure,
290 &status_clone,
291 )
292 .await;
293 }
294 }
295 }
296 Err(e) => {
297 error!("Failed to parse KRPC message: {}", e);
298 let _ = status_clone.send(format!(
299 "[ERROR] Failed to parse KRPC message: {}",
300 e
301 ));
302 }
303 }
304 }).await;
305 }
306 Err(e) => {
307 error!("BitTorrent DHT receive error: {}", e);
308 break;
309 }
310 }
311 }
312 });
313
314 task_registrar
315 .register_server_task(server_id, accept_handle)
316 .await;
317
318 Ok(local_addr)
319 }
320
321 /// Answer a query netget cannot serve with a BEP 5 error reply carrying only a category.
322 ///
323 /// `{"t": <echoed>, "y": "e", "e": [code, text]}`. The code separates the two categories
324 /// so a querying node can back off rather than record a permanent fault here: 202
325 /// ("Server Error") for a saturated backend, 201 ("Generic Error") for everything else.
326 /// The text comes from [`WireFailure::text`], which is `&'static str` — the backend
327 /// error, the model name and any path stay in the log.
328 async fn send_failure_reply(
329 socket: &UdpSocket,
330 peer_addr: SocketAddr,
331 transaction_id_hex: Option<&str>,
332 failure: WireFailure,
333 status_tx: &mpsc::UnboundedSender<String>,
334 ) {
335 // Without `t` there is nothing for the querying node to correlate the reply with, and
336 // BEP 5 requires it on every message; an unaddressed datagram is worse than silence.
337 let Some(transaction_id) = transaction_id_hex.and_then(|t| hex::decode(t).ok()) else {
338 tracing::warn!(
339 "BitTorrent DHT: cannot send failure reply to {} — query carried no usable \
340 transaction id",
341 peer_addr
342 );
343 return;
344 };
345
346 let code = if failure.is_overloaded() {
347 KRPC_SERVER_ERROR
348 } else {
349 KRPC_GENERIC_ERROR
350 };
351
352 let mut response = std::collections::HashMap::new();
353 response.insert(
354 b"t".to_vec(),
355 serde_bencode::value::Value::Bytes(transaction_id),
356 );
357 response.insert(
358 b"y".to_vec(),
359 serde_bencode::value::Value::Bytes(b"e".to_vec()),
360 );
361 response.insert(
362 b"e".to_vec(),
363 serde_bencode::value::Value::List(vec![
364 serde_bencode::value::Value::Int(code),
365 serde_bencode::value::Value::Bytes(failure.text().as_bytes().to_vec()),
366 ]),
367 );
368
369 let encoded = match serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response)) {
370 Ok(bytes) => bytes,
371 Err(e) => {
372 error!("BitTorrent DHT: failed to encode KRPC error reply: {}", e);
373 return;
374 }
375 };
376
377 match socket.send_to(&encoded, peer_addr).await {
378 Ok(sent) => {
379 debug!(
380 "BitTorrent DHT sent KRPC error {} ({} bytes) to {}",
381 code, sent, peer_addr
382 );
383 let _ = status_tx.send(format!(
384 "[DEBUG] BitTorrent DHT sent KRPC error {} to {}",
385 code, peer_addr
386 ));
387 }
388 Err(e) => {
389 error!(
390 "BitTorrent DHT: failed to send KRPC error reply to {}: {}",
391 peer_addr, e
392 );
393 }
394 }
395 }
396
397 fn parse_krpc_message(data: &[u8]) -> Result<(String, serde_json::Value)> {
398 use serde_bencode::value::Value;
399
400 // Bound the nesting before serde_bencode sees it. `serde_bencode` recurses once per
401 // `l`/`d` with no depth counter, so a datagram of 65,000 `l` bytes — which is what
402 // fits in one UDP packet and costs an attacker one sendto() — overflows the worker
403 // thread's stack. That is a SIGSEGV, not a panic: the task this runs in cannot
404 // contain it and the whole netget process dies. Measured against 0.2.4.
405 crate::utils::bencode::check_bencode_structure(data)
406 .map_err(|e| anyhow::anyhow!("Rejected KRPC datagram: {}", e))?;
407
408 // Decode bencode
409 let value: Value = serde_bencode::from_bytes(data)?;
410
411 if let Value::Dict(dict) = value {
412 // Get message type (q = query, r = response, e = error)
413 let msg_type = dict
414 .get::<[u8]>(b"y")
415 .and_then(|v| {
416 if let Value::Bytes(bytes) = v {
417 String::from_utf8(bytes.clone()).ok()
418 } else {
419 None
420 }
421 })
422 .ok_or_else(|| anyhow::anyhow!("Missing 'y' field"))?;
423
424 if msg_type == "q" {
425 // Query message
426 let query_type = dict
427 .get::<[u8]>(b"q")
428 .and_then(|v| {
429 if let Value::Bytes(bytes) = v {
430 String::from_utf8(bytes.clone()).ok()
431 } else {
432 None
433 }
434 })
435 .ok_or_else(|| anyhow::anyhow!("Missing 'q' field"))?;
436
437 // Get transaction ID
438 let transaction_id = dict.get::<[u8]>(b"t").and_then(|v| {
439 if let Value::Bytes(bytes) = v {
440 Some(hex::encode(bytes))
441 } else {
442 None
443 }
444 });
445
446 // Get arguments.
447 //
448 // `query_type` is always present so a handler can tell an announce_peer
449 // from an unsupported method, both of which reach the ping event.
450 let mut params = serde_json::Map::new();
451 params.insert("query_type".to_string(), serde_json::json!(&query_type));
452 if let Some(transaction_id) = transaction_id {
453 params.insert(
454 "transaction_id".to_string(),
455 serde_json::json!(transaction_id),
456 );
457 }
458
459 if let Some(Value::Dict(args)) = dict.get::<[u8]>(b"a") {
460 for (k, v) in args {
461 let key = String::from_utf8_lossy(k).to_string();
462 // `id`, `target` and `info_hash` are always 20 raw bytes and are
463 // documented as hex. bencode_to_json would render them as text
464 // whenever all 20 bytes happened to be printable ASCII, so the
465 // same field arrived hex-encoded or not depending on the client's
466 // random ID — and hex::decode on the response side then failed.
467 let value = match (key.as_str(), v) {
468 ("id" | "target" | "info_hash", Value::Bytes(bytes)) => {
469 serde_json::json!(hex::encode(bytes))
470 }
471 _ => Self::bencode_to_json(v)?,
472 };
473 params.insert(key, value);
474 }
475 }
476
477 Ok((query_type, serde_json::Value::Object(params)))
478 } else {
479 Err(anyhow::anyhow!("Not a query message"))
480 }
481 } else {
482 Err(anyhow::anyhow!("Invalid KRPC message"))
483 }
484 }
485
486 /// Convert a bencode value without relying on where it was decoded or constructed.
487 /// Lists and dictionaries each count as one level; scalars consume no depth.
488 pub fn bencode_to_json(value: &serde_bencode::value::Value) -> Result<serde_json::Value> {
489 Self::bencode_to_json_bounded(value, 0)
490 }
491
492 fn bencode_to_json_bounded(
493 value: &serde_bencode::value::Value,
494 depth: usize,
495 ) -> Result<serde_json::Value> {
496 use serde_bencode::value::Value;
497
498 if matches!(value, Value::List(_) | Value::Dict(_)) && depth >= MAX_DHT_VALUE_DEPTH {
499 anyhow::bail!("DHT value nesting exceeds the {MAX_DHT_VALUE_DEPTH} level limit");
500 }
501
502 Ok(match value {
503 Value::Int(i) => serde_json::json!(i),
504 Value::Bytes(bytes) => {
505 // Try to decode as UTF-8 string, otherwise hex encode
506 if let Ok(s) = String::from_utf8(bytes.clone()) {
507 if s.chars()
508 .all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace())
509 {
510 serde_json::json!(s)
511 } else {
512 serde_json::json!(hex::encode(bytes))
513 }
514 } else {
515 serde_json::json!(hex::encode(bytes))
516 }
517 }
518 Value::List(list) => {
519 let json_list = list
520 .iter()
521 .map(|v| Self::bencode_to_json_bounded(v, depth + 1))
522 .collect::<Result<Vec<_>>>()?;
523 serde_json::Value::Array(json_list)
524 }
525 Value::Dict(dict) => {
526 let mut json_obj = serde_json::Map::new();
527 for (k, v) in dict {
528 let key = String::from_utf8_lossy(k).to_string();
529 json_obj.insert(key, Self::bencode_to_json_bounded(v, depth + 1)?);
530 }
531 serde_json::Value::Object(json_obj)
532 }
533 })
534 }
535}