netget/client/torrent_tracker/mod.rs
1//! BitTorrent Tracker client implementation
2pub mod actions;
3
4pub use actions::TorrentTrackerClientProtocol;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::future::Future;
9use std::net::SocketAddr;
10use std::pin::Pin;
11use std::sync::Arc;
12use tokio::sync::mpsc;
13use tracing::{error, info, trace, warn};
14
15use crate::client::http_fetch::FetchClient;
16use crate::client::llm_budget::call_llm_for_client;
17use crate::client::torrent_tracker::actions::{
18 TRACKER_ANNOUNCE_RESPONSE_EVENT, TRACKER_SCRAPE_RESPONSE_EVENT,
19};
20use crate::llm::actions::client_trait::{Client, ClientActionResult};
21use crate::llm::actions::protocol_trait::Protocol;
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ClientLlmResult;
24use crate::protocol::Event;
25use crate::state::app_state::AppState;
26use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
27use crate::state::{AccessLogOwner, ClientId, ClientStatus};
28
29/// BitTorrent tracker response (announce)
30#[derive(Debug, Deserialize, Serialize)]
31struct TrackerResponse {
32 #[serde(rename = "failure reason")]
33 failure_reason: Option<String>,
34 #[serde(rename = "warning message")]
35 warning_message: Option<String>,
36 interval: Option<i64>,
37 #[serde(rename = "min interval")]
38 min_interval: Option<i64>,
39 #[serde(rename = "tracker id")]
40 tracker_id: Option<String>,
41 complete: Option<i64>,
42 incomplete: Option<i64>,
43 peers: Option<serde_bencode::value::Value>,
44}
45
46/// BitTorrent tracker scrape response
47#[derive(Debug, Deserialize, Serialize)]
48struct ScrapeResponse {
49 files: Option<serde_bencode::value::Value>,
50}
51
52/// How many LLM turns one announce/scrape chain may take before it is cut.
53///
54/// The cycle is real: an announce produces `tracker_announce_response`, whose answer may be
55/// another announce. Executing the answer (which this client did not used to do) is what makes
56/// the recursion possible, so it is bounded rather than avoided by staying silent.
57const MAX_FOLLOWUP_DEPTH: u8 = 6;
58
59/// Ceiling on a tracker reply before it is refused, applied while the body streams in.
60///
61/// A tracker answer is a small bencoded dictionary — a compact peer list of a thousand peers
62/// is six kilobytes. `bytes()` would have read whatever the far end chose to send, with no
63/// cap at all, so a hostile or broken tracker could hand this client gigabytes and the first
64/// sign of it would be the allocator.
65const MAX_TRACKER_BODY_BYTES: usize = 1024 * 1024;
66
67/// Wall-clock bound on one announce or scrape.
68///
69/// `reqwest::get` applies no timeout of any kind, so a tracker that accepts the connection
70/// and then says nothing parks the action — and with it whatever injected `[ send ]` is
71/// waiting on its outcome — for as long as the peer cares to hold it open.
72const TRACKER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
73
74#[cfg(not(target_arch = "wasm32"))]
75use std::collections::HashMap;
76#[cfg(not(target_arch = "wasm32"))]
77use std::sync::{LazyLock, Mutex};
78
79/// One `reqwest::Client` per tracker URL, built once and reused.
80///
81/// Three things here, all of which the root `CLAUDE.md` records as having cost real debugging
82/// time:
83///
84/// * **Build it once.** `reqwest::get` built a fresh client per request.
85/// * **Build it off the runtime.** `Client::builder().build()` sets up the rustls stack and
86/// loads the platform root store, which on macOS reads the keychain through
87/// Security.framework — synchronously, and serialised across processes. On an async worker
88/// that parks the whole thread, so it goes through `spawn_blocking`.
89/// * **Key it by URL.** `reqwest` hands even a dotted quad to `getaddrinfo`, which serialises
90/// through mDNSResponder on macOS and was measured at 8.25s under load.
91/// [`client_for_endpoint_with_timeout`] installs the literal-IP bypass and needs the URL to
92/// decide whether to, so a single global client would not do.
93///
94/// The lock is **never** held across the build. A first version did exactly that — the
95/// blocking keychain read happened inside `or_insert_with` while holding this `Mutex` — which
96/// parked a worker *and* serialised every tracker client in the process behind it. Two
97/// callers racing for the same URL may now both build, and the loser's client is dropped;
98/// that is far cheaper than what it replaces.
99#[cfg(not(target_arch = "wasm32"))]
100static TRACKER_CLIENTS: LazyLock<Mutex<HashMap<String, reqwest::Client>>> =
101 LazyLock::new(|| Mutex::new(HashMap::new()));
102
103/// Lock, look, clone, drop. A poisoned mutex means another thread panicked mid-insert; the map
104/// is still structurally sound and building a fresh client is always correct, so it is taken
105/// rather than propagated.
106#[cfg(not(target_arch = "wasm32"))]
107fn cached_tracker_client(tracker_url: &str) -> Option<reqwest::Client> {
108 match TRACKER_CLIENTS.lock() {
109 Ok(cache) => cache.get(tracker_url).cloned(),
110 Err(poisoned) => poisoned.into_inner().get(tracker_url).cloned(),
111 }
112}
113
114#[cfg(not(target_arch = "wasm32"))]
115async fn tracker_reqwest_client(tracker_url: &str) -> reqwest::Client {
116 if let Some(client) = cached_tracker_client(tracker_url) {
117 return client;
118 }
119
120 let url = tracker_url.to_string();
121 let timeout = TRACKER_REQUEST_TIMEOUT;
122 let built = tokio::task::spawn_blocking(move || {
123 crate::llm::ollama_client::client_for_endpoint_with_timeout(&url, timeout)
124 })
125 .await
126 // The closure cannot panic and the task is never aborted, but a default client is a
127 // correct answer rather than a reason to fail the announce.
128 .unwrap_or_else(|_| reqwest::Client::new());
129
130 let mut cache = match TRACKER_CLIENTS.lock() {
131 Ok(guard) => guard,
132 Err(poisoned) => poisoned.into_inner(),
133 };
134 // Whoever inserted first wins, so every later request shares one client.
135 cache
136 .entry(tracker_url.to_string())
137 .or_insert(built)
138 .clone()
139}
140
141/// The HTTP client for one tracker: the cached reqwest client natively
142/// ([`tracker_reqwest_client`]); in the browser build the hyper HTTP/1.1 transport over the
143/// page's virtual loopback ([`crate::client::http_fetch`]), bounded by the same
144/// [`TRACKER_REQUEST_TIMEOUT`] and reading at most [`MAX_TRACKER_BODY_BYTES`].
145async fn tracker_http_client(tracker_url: &str) -> FetchClient {
146 #[cfg(not(target_arch = "wasm32"))]
147 let client = FetchClient::from_reqwest(tracker_reqwest_client(tracker_url).await);
148 #[cfg(target_arch = "wasm32")]
149 let client = {
150 let _ = tracker_url;
151 FetchClient::transport(TRACKER_REQUEST_TIMEOUT).with_max_body(MAX_TRACKER_BODY_BYTES)
152 };
153 client
154}
155
156/// A tracker URL the requests can be built on. A bare `host:port` — what the dashboard's
157/// `[ + BitTorrent Tracker client ]` and a model asked to "announce to 127.0.0.1:6969" produce —
158/// becomes `http://host:port/announce`: HTTP trackers are plain HTTP by convention, and the
159/// announce path is what BEP 3 tracker URLs name. Anything with a scheme is left as given.
160/// Without this, the announce URL was `127.0.0.1:6969?info_hash=…`, which is not an absolute
161/// URL and failed every request.
162pub fn resolve_tracker_url(remote_addr: &str) -> String {
163 let trimmed = remote_addr.trim();
164 if trimmed.contains("://") {
165 return trimmed.to_string();
166 }
167 if trimmed.contains('/') {
168 format!("http://{trimmed}")
169 } else {
170 format!("http://{trimmed}/announce")
171 }
172}
173
174/// GET `url` and return at most [`MAX_TRACKER_BODY_BYTES`] of body, refusing anything longer
175/// *while it arrives* rather than after it has all been buffered.
176async fn fetch_tracker_body(tracker_url: &str, url: &str) -> Result<Vec<u8>> {
177 let mut response = tracker_http_client(tracker_url)
178 .await
179 .get(url)
180 .send()
181 .await?;
182
183 // Content-Length is a hint, not a promise — it may be absent, or a lie — so it is used
184 // only to refuse early, and the streaming check below is what actually holds.
185 if let Some(len) = response.content_length() {
186 if len > MAX_TRACKER_BODY_BYTES as u64 {
187 return Err(anyhow::anyhow!(
188 "tracker declared a {} byte reply, over the {} byte limit",
189 len,
190 MAX_TRACKER_BODY_BYTES
191 ));
192 }
193 }
194
195 let mut body = Vec::new();
196 while let Some(chunk) = response.chunk().await? {
197 if body.len() + chunk.len() > MAX_TRACKER_BODY_BYTES {
198 return Err(anyhow::anyhow!(
199 "tracker reply exceeded the {} byte limit",
200 MAX_TRACKER_BODY_BYTES
201 ));
202 }
203 body.extend_from_slice(&chunk);
204 }
205 Ok(body)
206}
207
208/// Percent-encode bytes for a query-parameter value, escaping everything but RFC 3986's
209/// unreserved set.
210fn percent_encode(bytes: &[u8]) -> String {
211 let mut out = String::with_capacity(bytes.len() * 3);
212 for byte in bytes {
213 match byte {
214 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
215 out.push(*byte as char)
216 }
217 other => out.push_str(&format!("%{:02X}", other)),
218 }
219 }
220 out
221}
222
223/// Undo percent-encoding. A stray `%` that is not followed by two hex digits is kept as a
224/// literal `%`, which is what every lenient decoder does and what re-encoding then escapes.
225/// Works on bytes throughout rather than slicing the `&str`: `&value[i + 1..i + 3]` would
226/// panic if a `%` were followed by a multi-byte UTF-8 character, and this value comes from
227/// the model. That is the byte-index-slicing defect `crate::utils::truncate` exists for.
228fn percent_decode(value: &str) -> Vec<u8> {
229 let bytes = value.as_bytes();
230 let mut out = Vec::with_capacity(bytes.len());
231 let mut i = 0;
232 while i < bytes.len() {
233 if bytes[i] == b'%' && i + 2 < bytes.len() {
234 let hi = (bytes[i + 1] as char).to_digit(16);
235 let lo = (bytes[i + 2] as char).to_digit(16);
236 if let (Some(hi), Some(lo)) = (hi, lo) {
237 out.push((hi * 16 + lo) as u8);
238 i += 3;
239 continue;
240 }
241 }
242 out.push(bytes[i]);
243 i += 1;
244 }
245 out
246}
247
248/// Normalise a model-supplied `info_hash` or `peer_id` into the percent-encoded form BEP 3
249/// carries in the query string.
250///
251/// These are twenty raw bytes, and their value comes from the model. They used to be
252/// interpolated into the URL with `format!` and nothing else, so an `&` or a `#` in one
253/// inserted **extra query parameters** into the announce: a model told to announce one
254/// info_hash could append its own `&event=completed`, overwrite `port`, or truncate the whole
255/// query with a fragment. The parameter description told the model to send the value already
256/// URL-encoded — which is not a thing to leave to the component being constrained.
257///
258/// Three input spellings are accepted because all three are what a model actually produces,
259/// and each is reduced to the same twenty bytes before being encoded exactly once:
260///
261/// * 40 hex characters — the form NetGet's own tracker *server* reports an inbound
262/// `info_hash`/`peer_id` in, so it is what a model echoing an event will send.
263/// * already percent-encoded (`%12%34…`) — decoded and re-encoded, which normalises it and
264/// strips any separator that was sitting in it unescaped.
265/// * anything else — raw text, e.g. a literal peer id like `-TR2940-abcdefghijkl`.
266///
267/// Blind escaping would have been wrong for the second case: `%12` would become `%2512` and
268/// the tracker would read the three characters `%12` rather than the byte `0x12`.
269fn encode_binary_query_value(value: &str) -> String {
270 let bytes = if value.len() == 40 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
271 hex::decode(value).unwrap_or_else(|_| value.as_bytes().to_vec())
272 } else if value.contains('%') {
273 percent_decode(value)
274 } else {
275 value.as_bytes().to_vec()
276 };
277 percent_encode(&bytes)
278}
279
280/// Refuse a bencoded tracker reply whose nesting would recurse `serde_bencode` off the stack.
281///
282/// `serde_bencode` counts no depth, and a derived struct is no safer than a raw `Value`
283/// because serde skips unknown fields through `IgnoredAny`, which lands back in
284/// `deserialize_any`. A reply body of `l` bytes therefore recurses once per byte; a stack
285/// overflow is `SIGSEGV`, so it aborts the whole netget process rather than failing this one
286/// action. The tracker is whatever address the operator or the model pointed this client at.
287fn screen_tracker_body(body: &[u8]) -> Result<()> {
288 crate::utils::bencode::check_bencode_structure(body)
289 .map_err(|e| anyhow::anyhow!("tracker reply refused before decoding: {}", e))
290}
291
292/// BitTorrent Tracker client
293pub struct TorrentTrackerClient;
294
295impl TorrentTrackerClient {
296 /// Connect to a BitTorrent tracker with integrated LLM actions
297 pub async fn connect_with_llm_actions(
298 remote_addr: String,
299 llm_client: OllamaClient,
300 app_state: Arc<AppState>,
301 status_tx: mpsc::UnboundedSender<String>,
302 client_id: ClientId,
303 ) -> Result<SocketAddr> {
304 // BitTorrent tracker is HTTP-based, so we don't maintain a persistent connection
305 // We'll just track the tracker URL and make HTTP requests as needed
306 let remote_addr = resolve_tracker_url(&remote_addr);
307 // In the browser build the transport has no TLS: an `https://` tracker is refused
308 // here, with the reason, rather than on the first announce.
309 crate::client::http_fetch::check_url(&remote_addr)?;
310
311 info!(
312 "BitTorrent Tracker client {} initialized for {}",
313 client_id, remote_addr
314 );
315
316 // Update client state
317 app_state
318 .update_client_status(client_id, ClientStatus::Connected)
319 .await;
320 let _ = status_tx.send(format!(
321 "[CLIENT] BitTorrent Tracker client {} connected to {}",
322 client_id, remote_addr
323 ));
324 let _ = status_tx.send("__UPDATE_UI__".to_string());
325
326 // Command channel for injected actions (the dashboard's [ send ] row).
327 // Registered - and already being drained by its own task - BEFORE the
328 // connected-event LLM call, which a manual `*` rule can park for minutes: the
329 // operator must be able to reach the client while it waits. This is also what
330 // keeps the client reachable at all, since a tracker client has no read loop.
331 let command_rx =
332 crate::client::command_support::register_command_channel(&app_state, client_id).await;
333 let cmd_task = tokio::spawn(Self::command_loop(
334 command_rx,
335 client_id,
336 remote_addr.clone(),
337 app_state.clone(),
338 llm_client.clone(),
339 status_tx.clone(),
340 ));
341 app_state.register_client_task(client_id, cmd_task).await;
342
343 // Call LLM with connected event
344 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
345 let protocol = Arc::new(
346 crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new(),
347 );
348 let event = Event::new(
349 &TRACKER_ANNOUNCE_RESPONSE_EVENT,
350 serde_json::json!({
351 "tracker_url": remote_addr,
352 "status": "connected",
353 }),
354 );
355
356 let memory = app_state
357 .get_memory_for_client(client_id)
358 .await
359 .unwrap_or_default();
360
361 // Execute LLM call in background
362 let app_state_clone = app_state.clone();
363 let status_tx_clone = status_tx.clone();
364 // Registered with AppState so stop_client can abort this task —
365 // dropping a JoinHandle only detaches it in Tokio.
366 let task_registrar = app_state.clone();
367 let task_handle = tokio::spawn(async move {
368 match call_llm_for_client(
369 &llm_client,
370 &app_state_clone,
371 client_id.to_string(),
372 &instruction,
373 &memory,
374 Some(&event),
375 protocol.as_ref(),
376 &status_tx_clone,
377 )
378 .await
379 {
380 Ok(ClientLlmResult {
381 actions,
382 memory_updates,
383 }) => {
384 // Update memory
385 if let Some(mem) = memory_updates {
386 app_state_clone.set_memory_for_client(client_id, mem).await;
387 }
388
389 // Execute actions
390 for action in actions {
391 match protocol.as_ref().execute_action(action.clone()) {
392 Ok(result) => {
393 if let Err(e) = Self::apply_action(
394 client_id,
395 result,
396 Notify::Inline,
397 0,
398 &remote_addr,
399 &app_state_clone,
400 &llm_client,
401 &status_tx_clone,
402 )
403 .await
404 {
405 error!("Failed to execute tracker action: {}", e);
406 }
407 }
408 Err(e) => {
409 error!("Tracker action execution error: {}", e);
410 }
411 }
412 }
413 }
414 Err(e) => {
415 error!("LLM error for Tracker client {}: {}", client_id, e);
416 }
417 }
418 });
419 task_registrar
420 .register_client_task(client_id, task_handle)
421 .await;
422 }
423
424 // Return a dummy local address (tracker is HTTP-based)
425 Ok("0.0.0.0:0".parse()?)
426 }
427
428 /// Drain injected commands until the channel closes (client removed) or an injected
429 /// `disconnect` ends the session.
430 ///
431 /// `command_support::handle_stream_client_command` cannot serve this client: it writes
432 /// `SendData` to a socket, and both tracker verbs yield `ClientActionResult::Custom`
433 /// that has to become an HTTP GET against the tracker. So the action goes through
434 /// [`Self::apply_action`] - the same function the connected-event path uses - and the
435 /// outcome is recorded and replied exactly the way the generic arm does it.
436 async fn command_loop(
437 mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
438 client_id: ClientId,
439 tracker_url: String,
440 app_state: Arc<AppState>,
441 llm_client: OllamaClient,
442 status_tx: mpsc::UnboundedSender<String>,
443 ) {
444 let protocol = crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new();
445
446 while let Some(command) = command_rx.recv().await {
447 let action = command.action.clone();
448 let outcome = match protocol.execute_action(action.clone()) {
449 Err(e) => Ok(ClientSendOutcome::Rejected {
450 error: e.to_string(),
451 }),
452 // The HTTP GET is awaited, so the reported outcome describes a request
453 // that has actually completed. Notify::Deferred delivers the tracker
454 // response event from its own registered task, so a manual handler parked
455 // for a human's think time cannot wedge this loop or time out the
456 // dashboard's [ send ].
457 Ok(result) => Self::apply_action(
458 client_id,
459 result,
460 Notify::Deferred,
461 0,
462 &tracker_url,
463 &app_state,
464 &llm_client,
465 &status_tx,
466 )
467 .await
468 .map(|applied| match applied {
469 Applied::Disconnect => ClientSendOutcome::Disconnected,
470 Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
471 }),
472 };
473
474 let outcome_json = match &outcome {
475 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
476 Err(e) => serde_json::json!({"error": e.to_string()}),
477 };
478 app_state
479 .record_access_log(
480 AccessLogOwner::Client(client_id.as_u32()),
481 protocol.protocol_name(),
482 None,
483 "injected_action",
484 action,
485 vec![outcome_json],
486 )
487 .await;
488
489 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
490 if let Err(e) = &outcome {
491 error!("Tracker client {} injected action failed: {}", client_id, e);
492 let _ = status_tx.send(format!(
493 "[WARN] Client {} injected action failed: {}",
494 client_id, e
495 ));
496 }
497 let _ = status_tx.send("__UPDATE_UI__".to_string());
498 crate::client::command_support::reply(command, outcome);
499
500 if disconnect {
501 break;
502 }
503 }
504
505 info!("Tracker client {} command loop stopped", client_id);
506 app_state.remove_client_handle(client_id).await;
507 let _ = status_tx.send("__UPDATE_UI__".to_string());
508 }
509
510 /// Deliver a tracker response event to the LLM, inline or from its own registered
511 /// task depending on `notify`.
512 #[allow(clippy::too_many_arguments)]
513 #[allow(clippy::too_many_arguments)]
514 async fn deliver(
515 notify: Notify,
516 client_id: ClientId,
517 event_type: &'static crate::protocol::EventType,
518 event_data: serde_json::Value,
519 depth: u8,
520 tracker_url: &str,
521 app_state: &Arc<AppState>,
522 llm_client: &OllamaClient,
523 status_tx: &mpsc::UnboundedSender<String>,
524 ) {
525 match notify {
526 Notify::Inline => {
527 Self::notify_response(
528 client_id,
529 event_type,
530 event_data,
531 depth,
532 tracker_url.to_string(),
533 app_state,
534 llm_client,
535 status_tx,
536 )
537 .await
538 }
539 Notify::Deferred => {
540 let state_clone = app_state.clone();
541 let llm_clone = llm_client.clone();
542 let status_clone = status_tx.clone();
543 let tracker_url = tracker_url.to_string();
544 let notify_handle = tokio::spawn(async move {
545 TorrentTrackerClient::notify_response(
546 client_id,
547 event_type,
548 event_data,
549 depth,
550 tracker_url,
551 &state_clone,
552 &llm_clone,
553 &status_clone,
554 )
555 .await;
556 });
557 // Registered so the notification - and the LLM call it makes - is aborted
558 // when the client is stopped.
559 app_state
560 .register_client_task(client_id, notify_handle)
561 .await;
562 }
563 }
564 }
565
566 /// Fire one tracker response event at the LLM, apply any memory update, and **carry out
567 /// what it answered with**.
568 ///
569 /// The answer used to be destructured as `Ok(ClientLlmResult { memory_updates, .. })` —
570 /// the actions dropped by the `..`. That cut every chain at one step: the model asked for
571 /// an announce, the tracker's peer list came back, the model was told about it, chose what
572 /// to do next, and was ignored. A tracker client could make exactly one request per
573 /// instruction and then went deaf.
574 ///
575 /// Executing here makes the cycle real — announce → `tracker_announce_response` →
576 /// announce — so it is bounded rather than cut. `depth` counts LLM turns in one chain and
577 /// [`MAX_FOLLOWUP_DEPTH`] stops it; [`Self::apply_action`] returns an explicitly boxed
578 /// future so the three-function cycle's opaque types can be inferred at all.
579 #[allow(clippy::too_many_arguments)]
580 async fn notify_response(
581 client_id: ClientId,
582 event_type: &'static crate::protocol::EventType,
583 event_data: serde_json::Value,
584 depth: u8,
585 tracker_url: String,
586 app_state: &Arc<AppState>,
587 llm_client: &OllamaClient,
588 status_tx: &mpsc::UnboundedSender<String>,
589 ) {
590 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
591 return;
592 };
593 let event = Event::new(event_type, event_data);
594 let memory = app_state
595 .get_memory_for_client(client_id)
596 .await
597 .unwrap_or_default();
598 let protocol =
599 Arc::new(crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new());
600
601 match call_llm_for_client(
602 llm_client,
603 app_state,
604 client_id.to_string(),
605 &instruction,
606 &memory,
607 Some(&event),
608 protocol.as_ref(),
609 status_tx,
610 )
611 .await
612 {
613 Ok(ClientLlmResult {
614 actions,
615 memory_updates,
616 }) => {
617 if let Some(mem) = memory_updates {
618 app_state.set_memory_for_client(client_id, mem).await;
619 }
620
621 if actions.is_empty() {
622 return;
623 }
624 if depth >= MAX_FOLLOWUP_DEPTH {
625 warn!(
626 "Tracker client {} stopped a follow-up chain at depth {}: {} action(s) \
627 not executed",
628 client_id,
629 depth,
630 actions.len()
631 );
632 let _ = status_tx.send(format!(
633 "[CLIENT] ⚠ tracker client {} hit the follow-up depth limit ({}); \
634 {} action(s) were not executed",
635 client_id,
636 MAX_FOLLOWUP_DEPTH,
637 actions.len()
638 ));
639 return;
640 }
641
642 for action in actions {
643 match protocol.as_ref().execute_action(action) {
644 Ok(result) => {
645 match Self::apply_action(
646 client_id,
647 result,
648 Notify::Inline,
649 depth + 1,
650 &tracker_url,
651 app_state,
652 llm_client,
653 status_tx,
654 )
655 .await
656 {
657 Ok(Applied::Disconnect) => {
658 app_state
659 .update_client_status(client_id, ClientStatus::Disconnected)
660 .await;
661 let _ = status_tx.send("__UPDATE_UI__".to_string());
662 return;
663 }
664 Ok(Applied::Executed(detail)) => {
665 trace!("Tracker client {} follow-up: {}", client_id, detail);
666 }
667 Err(e) => {
668 error!("Tracker client {} follow-up failed: {}", client_id, e);
669 }
670 }
671 }
672 Err(e) => {
673 error!(
674 "Tracker client {} rejected follow-up action: {}",
675 client_id, e
676 );
677 }
678 }
679 }
680 }
681 Err(e) => {
682 error!("LLM error: {}", e);
683 }
684 }
685 }
686
687 /// Apply one already-executed action result. The single place a tracker announce or
688 /// scrape is issued from, so an injected action behaves exactly like an LLM-produced
689 /// one.
690 #[allow(clippy::too_many_arguments)]
691 /// Returns an explicitly boxed future rather than being an `async fn`, and that is load-
692 /// bearing: the chain is `apply_action` -> `deliver` -> `notify_response` ->
693 /// `apply_action`, and three `async fn`s in a cycle cannot have their opaque return types
694 /// inferred (E0391). Boxing at the *call* site does not help -- coercing to
695 /// `dyn Future` still needs the callee's opaque type. Naming the type here breaks the
696 /// cycle at the definition. `+ Send` is explicit because `Notify::Deferred` awaits this
697 /// inside a `tokio::spawn`.
698 #[allow(clippy::too_many_arguments)]
699 fn apply_action<'a>(
700 client_id: ClientId,
701 result: ClientActionResult,
702 notify: Notify,
703 depth: u8,
704 tracker_url: &'a str,
705 app_state: &'a Arc<AppState>,
706 llm_client: &'a OllamaClient,
707 status_tx: &'a mpsc::UnboundedSender<String>,
708 ) -> Pin<Box<dyn Future<Output = Result<Applied>> + Send + 'a>> {
709 Box::pin(async move {
710 match result {
711 ClientActionResult::Custom { name, data } if name == "tracker_announce" => {
712 let info_hash = data
713 .get("info_hash")
714 .and_then(|v| v.as_str())
715 .context("Missing info_hash")?;
716 let peer_id = data
717 .get("peer_id")
718 .and_then(|v| v.as_str())
719 .context("Missing peer_id")?;
720 let port = data
721 .get("port")
722 .and_then(|v| v.as_u64())
723 .context("Missing port")?;
724 let port = u16::try_from(port).context("tracker port exceeds u16")?;
725 let uploaded = data.get("uploaded").and_then(|v| v.as_u64()).unwrap_or(0);
726 let downloaded = data.get("downloaded").and_then(|v| v.as_u64()).unwrap_or(0);
727 // `left` is not a neutral counter: BEP 3 defines `left=0` as "I have the
728 // complete torrent", and trackers use it to decide who is a seeder. A
729 // model that says nothing about it should not be announcing itself as
730 // having everything, so the default is the opposite claim. An explicit 0
731 // still means seeder, which is the point.
732 let left = data
733 .get("left")
734 .and_then(|v| v.as_u64())
735 .unwrap_or(u64::MAX);
736 let event_type = data
737 .get("event")
738 .and_then(|v| v.as_str())
739 .unwrap_or("started");
740
741 // Build announce URL.
742 //
743 // `info_hash`, `peer_id` and `event` come from the model and are
744 // percent-encoded rather than interpolated raw: an `&` in any of them
745 // used to insert extra query parameters into the announce, so a model
746 // told to announce one info_hash could append its own `event=completed`
747 // or overwrite `port`. The numeric fields are `u64` already and cannot
748 // carry a separator.
749 let announce_url = format!(
750 "{}?info_hash={}&peer_id={}&port={}&uploaded={}&downloaded={}&left={}&event={}",
751 tracker_url,
752 encode_binary_query_value(info_hash),
753 encode_binary_query_value(peer_id),
754 port,
755 uploaded,
756 downloaded,
757 left,
758 percent_encode(event_type.as_bytes())
759 );
760
761 trace!(
762 "Tracker client {} announcing to: {}",
763 client_id,
764 announce_url
765 );
766
767 // Bounded fetch: a shared client with a timeout, a body cap applied as
768 // the bytes arrive, and a nesting screen before serde_bencode sees them.
769 let body = fetch_tracker_body(tracker_url, &announce_url).await?;
770 if let Err(e) = screen_tracker_body(&body) {
771 warn!("Tracker client {} refused announce reply: {}", client_id, e);
772 return Ok(Applied::Executed(format!(
773 "tracker_announce sent; the tracker's reply was refused: {e}"
774 )));
775 }
776
777 // Parse bencode response
778 match serde_bencode::from_bytes::<TrackerResponse>(&body) {
779 Ok(tracker_resp) => {
780 trace!("Tracker response: {:?}", tracker_resp);
781
782 Self::deliver(
783 notify,
784 client_id,
785 &TRACKER_ANNOUNCE_RESPONSE_EVENT,
786 serde_json::json!({
787 "interval": tracker_resp.interval,
788 "complete": tracker_resp.complete,
789 "incomplete": tracker_resp.incomplete,
790 "peers": format!("{:?}", tracker_resp.peers),
791 }),
792 depth,
793 tracker_url,
794 app_state,
795 llm_client,
796 status_tx,
797 )
798 .await;
799 }
800 Err(e) => {
801 error!("Failed to parse tracker response: {}", e);
802 return Ok(Applied::Executed(format!(
803 "tracker_announce sent; the tracker's reply could not be \
804 bdecoded: {e}"
805 )));
806 }
807 }
808 Ok(Applied::Executed("tracker_announce completed".to_string()))
809 }
810 ClientActionResult::Custom { name, data } if name == "tracker_scrape" => {
811 let info_hash = data
812 .get("info_hash")
813 .and_then(|v| v.as_str())
814 .context("Missing info_hash")?;
815
816 // Build scrape URL. Percent-encoded for the same reason as the announce
817 // above: an `&` in a model-supplied info_hash is otherwise a separator.
818 let scrape_url = format!(
819 "{}?info_hash={}",
820 tracker_url,
821 encode_binary_query_value(info_hash)
822 );
823
824 trace!("Tracker client {} scraping: {}", client_id, scrape_url);
825
826 // Same bounded fetch and nesting screen as the announce path above.
827 let body = fetch_tracker_body(tracker_url, &scrape_url).await?;
828 if let Err(e) = screen_tracker_body(&body) {
829 warn!("Tracker client {} refused scrape reply: {}", client_id, e);
830 return Ok(Applied::Executed(format!(
831 "tracker_scrape sent; the tracker's reply was refused: {e}"
832 )));
833 }
834
835 // Parse bencode response
836 match serde_bencode::from_bytes::<ScrapeResponse>(&body) {
837 Ok(scrape_resp) => {
838 trace!("Scrape response: {:?}", scrape_resp);
839
840 Self::deliver(
841 notify,
842 client_id,
843 &TRACKER_SCRAPE_RESPONSE_EVENT,
844 serde_json::json!({
845 "files": format!("{:?}", scrape_resp.files),
846 }),
847 depth,
848 tracker_url,
849 app_state,
850 llm_client,
851 status_tx,
852 )
853 .await;
854 }
855 Err(e) => {
856 error!("Failed to parse scrape response: {}", e);
857 return Ok(Applied::Executed(format!(
858 "tracker_scrape sent; the tracker's reply could not be \
859 bdecoded: {e}"
860 )));
861 }
862 }
863 Ok(Applied::Executed("tracker_scrape completed".to_string()))
864 }
865 ClientActionResult::Disconnect => {
866 info!("Tracker client {} disconnecting", client_id);
867 app_state
868 .update_client_status(client_id, ClientStatus::Disconnected)
869 .await;
870 // Every exit path drops the handle so the dashboard stops offering
871 // [ send ] into a dead client.
872 app_state.remove_client_handle(client_id).await;
873 let _ = status_tx.send("__UPDATE_UI__".to_string());
874 Ok(Applied::Disconnect)
875 }
876 other => Ok(Applied::Executed(format!(
877 "{other:?} produced no tracker request"
878 ))),
879 }
880 })
881 }
882}
883
884/// When an action's tracker response event is delivered to the LLM.
885///
886/// The announce/scrape GET itself is always awaited; only the notification moves. The
887/// request is already inside a spawned task on the LLM-driven path, so unlike `http` there
888/// is no second "spawn the request" mode here.
889#[derive(Clone, Copy)]
890enum Notify {
891 /// Fire the event before returning. The LLM-driven path.
892 Inline,
893 /// Fire the event from its own registered task and return at once. The
894 /// injected-command path, which must reply to the operator first.
895 Deferred,
896}
897
898/// What [`TorrentTrackerClient::apply_action`] did with one action. A tracker client owns
899/// no socket - each announce/scrape is a one-shot HTTP GET - so there is no honest byte
900/// count to report, only "the request ran" or "the session should end".
901enum Applied {
902 /// The action ran; the string says what, for the injected action's outcome detail.
903 Executed(String),
904 /// The session should end.
905 Disconnect,
906}