netget/server/torrent_tracker/mod.rs
1//! BitTorrent Tracker server implementation
2//!
3//! HTTP-based tracker for coordinating BitTorrent peers. Handles announce and scrape requests.
4
5pub mod actions;
6
7use anyhow::Result;
8use std::collections::HashMap;
9use std::net::SocketAddr;
10use std::sync::Arc;
11use tokio::net::TcpListener;
12use tokio::sync::{mpsc, Mutex};
13use tracing::error;
14
15use crate::llm::action_helper::call_llm;
16use crate::llm::ollama_client::OllamaClient;
17use crate::logging::emit::Log;
18use crate::protocol::Event;
19use crate::server::connection::ConnectionId;
20use crate::state::app_state::AppState;
21use crate::utils::WireFailure;
22use actions::TorrentTrackerProtocol;
23
24/// How long a connected peer may take to send its request line before it is hung up on.
25///
26/// An announce is one small GET, and every real client sends it in the first segment. There
27/// was no bound at all, so a peer that connected and said nothing held a task and a
28/// connection-map entry indefinitely — which is a slow-loris for free.
29const REQUEST_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
30
31/// Concurrent connections this tracker admits before it starts refusing.
32///
33/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`REQUEST_READ_TIMEOUT`] bounds
34/// how long one silent peer holds a slot; only a cap bounds how many of them there can be at
35/// once, and a tracker is the most openly-addressed thing in a swarm — its address is in every
36/// `.torrent` file, so every peer in the world that has the file knows where to find it. One
37/// announce is one short GET on its own connection, so a real swarm's concurrency is set by
38/// arrival rate rather than by seeders, and the house default is far above it.
39const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
40
41/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
42///
43/// The same shape [`TorrentTrackerServer::write_failure_response`] already sends for a
44/// saturated backend: **HTTP 503 + `Retry-After`**, carrying a bencoded `failure reason`
45/// because that is the one refusal BEP 3 defines and the only thing a BitTorrent client will
46/// display. The status code is the part that does the work — a peer over the cap has not sent
47/// its announce, so the refusal belongs to the transport rather than to a request nobody read
48/// — and the bencoded body is there so a client that parses the body anyway gets a sentence
49/// instead of nothing. Built rather than written out so the `Content-Length` cannot drift.
50static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
51 let mut dict = HashMap::new();
52 dict.insert(
53 b"failure reason".to_vec(),
54 serde_bencode::value::Value::Bytes(b"too many connections, try again later".to_vec()),
55 );
56 let body =
57 serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(dict)).unwrap_or_default();
58 let mut out = format!(
59 "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 30\r\nContent-Type: \
60 text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
61 body.len()
62 )
63 .into_bytes();
64 out.extend_from_slice(&body);
65 out
66});
67
68/// The connection's write half, shared between the session and the peer-command task.
69///
70/// Both write to the same socket, so the lock is what keeps an injected `[ send message ]`
71/// from interleaving its bytes with the announce reply the session is in the middle of.
72type SharedWrite = Arc<Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>;
73
74/// BitTorrent Tracker server
75pub struct TorrentTrackerServer;
76
77impl TorrentTrackerServer {
78 /// Spawn BitTorrent Tracker server with LLM actions
79 pub async fn spawn_with_llm_actions(
80 listen_addr: SocketAddr,
81 llm_client: OllamaClient,
82 app_state: Arc<AppState>,
83 status_tx: mpsc::UnboundedSender<String>,
84 server_id: crate::state::ServerId,
85 ) -> Result<SocketAddr> {
86 let listener = TcpListener::bind(listen_addr).await?;
87 let local_addr = listener.local_addr()?;
88 Log::new(Some(&status_tx)).info(format!(
89 "BitTorrent Tracker server (action-based) listening on {}",
90 local_addr
91 ));
92
93 let protocol = Arc::new(TorrentTrackerProtocol::new());
94
95 let task_registrar = app_state.clone();
96 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
97 let accept_handle = tokio::spawn(async move {
98 loop {
99 match crate::server::accept_bounded::accept_bounded(
100 &listener,
101 &limiter,
102 &CONNECTION_CAP_REFUSAL,
103 "BitTorrent Tracker",
104 Some(&status_tx),
105 )
106 .await
107 {
108 Ok((stream, peer_addr, permit)) => {
109 let connection_id =
110 ConnectionId::new(app_state.get_next_unified_id().await);
111 let llm_clone = llm_client.clone();
112 let state_clone = app_state.clone();
113 let status_clone = status_tx.clone();
114 let protocol_clone = protocol.clone();
115
116 Log::new(Some(&status_clone)).debug(format!(
117 "BitTorrent Tracker accepted connection from {}",
118 peer_addr
119 ));
120
121 // Add connection to ServerInstance
122 use crate::state::server::{
123 ConnectionState as ServerConnectionState, ConnectionStatus,
124 ProtocolConnectionInfo,
125 };
126 let now = crate::utils::clock::Instant::now();
127 let conn_state = ServerConnectionState {
128 id: connection_id,
129 remote_addr: peer_addr,
130 local_addr,
131 bytes_sent: 0,
132 bytes_received: 0,
133 packets_sent: 0,
134 packets_received: 0,
135 last_activity: now,
136 status: ConnectionStatus::Active,
137 status_changed_at: now,
138 protocol_info: ProtocolConnectionInfo::empty(),
139 };
140 state_clone
141 .add_connection_to_server(server_id, conn_state)
142 .await;
143 let _ = status_clone.send("__UPDATE_UI__".to_string());
144
145 // Tracked, not detached: stop_server must abort this task too.
146 let task_owner = app_state.clone();
147 task_owner
148 .spawn_server_task(server_id, async move {
149 // Released when this task ends. `handle_connection` owns the
150 // whole session including its teardown — the peer-command
151 // channel shares the write half rather than running in a task
152 // of its own — so the slot covers the connection's real life.
153 let _permit = permit;
154 if let Err(e) = Self::handle_connection(
155 stream,
156 peer_addr,
157 local_addr,
158 connection_id,
159 llm_clone,
160 state_clone,
161 status_clone,
162 server_id,
163 protocol_clone,
164 )
165 .await
166 {
167 error!("BitTorrent Tracker connection error: {}", e);
168 }
169 })
170 .await;
171 }
172 Err(e) => {
173 Log::new(Some(&status_tx))
174 .error(format!("BitTorrent Tracker accept error: {}", e));
175 }
176 }
177 }
178 });
179
180 task_registrar
181 .register_server_task(server_id, accept_handle)
182 .await;
183
184 Ok(local_addr)
185 }
186
187 /// Set up the connection, run one announce/scrape exchange, then tear everything down.
188 ///
189 /// The session body lives in [`Self::run_session`] so that **every** exit path — a read
190 /// timeout, a malformed request, a write error, an LLM failure, an injected disconnect —
191 /// comes back through this function's teardown. An early `?` out of the session would
192 /// otherwise leave a peer handle registered against a socket that is gone.
193 #[allow(clippy::too_many_arguments)]
194 async fn handle_connection(
195 stream: tokio::net::TcpStream,
196 peer_addr: SocketAddr,
197 _local_addr: SocketAddr,
198 connection_id: ConnectionId,
199 llm_client: OllamaClient,
200 app_state: Arc<AppState>,
201 status_tx: mpsc::UnboundedSender<String>,
202 server_id: crate::state::ServerId,
203 protocol: Arc<TorrentTrackerProtocol>,
204 ) -> Result<()> {
205 use tokio::io::AsyncWriteExt;
206
207 let (read_half, write_half) = tokio::io::split(stream);
208 let write_half: SharedWrite = Arc::new(Mutex::new(write_half));
209
210 // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
211 // actions into THIS connection through the same executor the LLM path uses.
212 //
213 // Registered before the first read, because that is the window that matters here. A
214 // tracker says nothing until the announce arrives, and a `manual` rule parks that
215 // announce for a human (`src/state/intercepts.rs`, 300s by default) — so for almost
216 // the whole life of a parked connection the peer has spoken and this server has not.
217 // Registering after the exchange would offer the affordance only once it was useless.
218 let peer_rx = crate::server::peer_support::register_peer_channel(
219 &app_state,
220 server_id,
221 connection_id.as_u32(),
222 )
223 .await;
224 crate::server::peer_support::spawn_peer_command_task(
225 peer_rx,
226 protocol.clone(),
227 app_state.clone(),
228 server_id,
229 connection_id.as_u32(),
230 write_half.clone(),
231 status_tx.clone(),
232 );
233
234 let result = Self::run_session(
235 read_half,
236 &write_half,
237 peer_addr,
238 connection_id,
239 llm_client,
240 &app_state,
241 &status_tx,
242 server_id,
243 &protocol,
244 )
245 .await;
246
247 // Dropping the handle also ends the peer command task, which releases its clone of
248 // the write half; the explicit shutdown makes the FIN immediate rather than waiting
249 // on it.
250 app_state
251 .remove_peer_handle(server_id, connection_id.as_u32())
252 .await;
253 let _ = write_half.lock().await.shutdown().await;
254
255 // The connection entry was never closed at all before: a tracker connection serves
256 // one request and ends, but the row stayed Active for the life of the server, and
257 // torrent_tracker is not `.connectionless()`, so the idle sweep never collected it
258 // either. Every peer this server ever served accumulated as a live row.
259 app_state
260 .close_connection_on_server(server_id, connection_id)
261 .await;
262 let _ = status_tx.send("__UPDATE_UI__".to_string());
263
264 result
265 }
266
267 /// Write to the connection's shared write half and count the bytes.
268 ///
269 /// The guard is dropped before the stats update so nothing awaits `AppState`'s lock while
270 /// holding the socket.
271 async fn write_counted(
272 write_half: &SharedWrite,
273 data: &[u8],
274 app_state: &AppState,
275 server_id: crate::state::ServerId,
276 connection_id: ConnectionId,
277 ) -> std::io::Result<()> {
278 use tokio::io::AsyncWriteExt;
279 {
280 let mut writer = write_half.lock().await;
281 writer.write_all(data).await?;
282 writer.flush().await?;
283 }
284 app_state
285 .update_connection_stats(
286 server_id,
287 connection_id,
288 None,
289 Some(data.len() as u64),
290 None,
291 Some(1),
292 )
293 .await;
294 Ok(())
295 }
296
297 #[allow(clippy::too_many_arguments)]
298 async fn run_session(
299 mut read_half: tokio::io::ReadHalf<tokio::net::TcpStream>,
300 write_half: &SharedWrite,
301 peer_addr: SocketAddr,
302 connection_id: ConnectionId,
303 llm_client: OllamaClient,
304 app_state: &Arc<AppState>,
305 status_tx: &mpsc::UnboundedSender<String>,
306 server_id: crate::state::ServerId,
307 protocol: &Arc<TorrentTrackerProtocol>,
308 ) -> Result<()> {
309 use tokio::io::AsyncReadExt;
310
311 let mut buffer = vec![0u8; 8192];
312
313 // Read HTTP request, bounded.
314 //
315 // There was no timeout here at all, so a peer that connected and then said nothing
316 // held this task — and its entry in the connection map — until it chose to hang up.
317 // Opening connections and never writing is the whole of a slow-loris, and it costs
318 // the other end nothing. An announce is one small GET that a real client sends in
319 // its first segment.
320 let n = match tokio::time::timeout(REQUEST_READ_TIMEOUT, read_half.read(&mut buffer)).await
321 {
322 Ok(result) => result?,
323 Err(_) => {
324 Log::new(Some(status_tx)).warn(format!(
325 "BitTorrent Tracker {} sent no request within {}s, closing \
326 decision=fail_closed_read_timeout",
327 peer_addr,
328 REQUEST_READ_TIMEOUT.as_secs()
329 ));
330 let body = b"HTTP/1.1 408 Request Timeout\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
331 let _ = Self::write_counted(write_half, body, app_state, server_id, connection_id)
332 .await;
333 return Ok(());
334 }
335 };
336 if n == 0 {
337 Log::new(Some(status_tx)).debug("BitTorrent Tracker connection closed by peer");
338 return Ok(());
339 }
340
341 let request_data = buffer[..n].to_vec();
342
343 // Refresh connection stats (bytes/packets in) so the rail shows real
344 // traffic and last_activity rather than ↓0 ↑0. This is a one-shot HTTP
345 // request/response connection, so there is exactly one read and one write.
346 app_state
347 .update_connection_stats(
348 server_id,
349 connection_id,
350 Some(n as u64),
351 None,
352 Some(1),
353 None,
354 )
355 .await;
356
357 // DEBUG: Log summary
358 Log::new(Some(status_tx)).debug(format!(
359 "BitTorrent Tracker received {} bytes from {}",
360 n, peer_addr
361 ));
362
363 // TRACE: Log full request
364 if let Ok(request_str) = std::str::from_utf8(&request_data) {
365 Log::new(Some(status_tx)).trace(format!("BitTorrent Tracker request: {}", request_str));
366 }
367
368 // Parse HTTP request. A malformed request used to propagate out of this function
369 // with `?`, so the connection closed without writing anything and the client saw
370 // an empty reply rather than an error.
371 let request_str = String::from_utf8_lossy(&request_data);
372 let (request_type, request_params) = match Self::parse_http_request(&request_str) {
373 Ok(parsed) => parsed,
374 Err(e) => {
375 Log::new(Some(status_tx)).warn(format!("BitTorrent Tracker bad request: {}", e));
376 let body =
377 b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
378 Self::write_counted(write_half, body, app_state, server_id, connection_id).await?;
379 return Ok(());
380 }
381 };
382
383 Log::new(Some(status_tx))
384 .debug(format!("BitTorrent Tracker request type: {}", request_type));
385
386 // Create event for LLM
387 let event_type = match request_type.as_str() {
388 "announce" => &actions::TRACKER_ANNOUNCE_REQUEST_EVENT,
389 "scrape" => &actions::TRACKER_SCRAPE_REQUEST_EVENT,
390 // Anything that is neither /announce nor /scrape still reaches the announce
391 // handler, because a tracker has no other reply shape to offer. The event data
392 // carries `request_type` and `path` so a handler can tell the difference and
393 // answer with send_error_response instead.
394 other => {
395 tracing::warn!(
396 "BitTorrent Tracker: unrecognised path type '{}', routing to \
397 tracker_announce_request",
398 other
399 );
400 &actions::TRACKER_ANNOUNCE_REQUEST_EVENT
401 }
402 };
403 let event = Event::new(event_type, serde_json::json!(request_params));
404
405 Log::new(Some(status_tx)).debug(format!(
406 "BitTorrent Tracker calling LLM for {} request",
407 request_type
408 ));
409
410 // Call LLM
411 match call_llm(
412 &llm_client,
413 app_state,
414 server_id,
415 Some(connection_id),
416 &event,
417 protocol.as_ref(),
418 )
419 .await
420 {
421 Ok(execution_result) => {
422 // Display messages from LLM
423 for message in &execution_result.messages {
424 Log::new(Some(status_tx)).info(format!("{}", message));
425 }
426
427 Log::new(Some(status_tx)).debug(format!(
428 "BitTorrent Tracker got {} protocol results",
429 execution_result.protocol_results.len()
430 ));
431
432 // An explicit refusal by the model is a `failure reason` it asked for
433 // itself; keep it distinguishable in the log from our own fail-closed
434 // reply, which says nothing about what the model thought.
435 let model_rejected = execution_result
436 .raw_actions
437 .iter()
438 .any(|a| a.get("type").and_then(|t| t.as_str()) == Some("send_error_response"));
439 let mut sent_any = false;
440
441 // Send responses
442 for protocol_result in execution_result.protocol_results {
443 if let Some(output_data) = protocol_result.get_all_output().first() {
444 sent_any = true;
445 Self::write_counted(
446 write_half,
447 output_data,
448 app_state,
449 server_id,
450 connection_id,
451 )
452 .await?;
453
454 Log::new(Some(status_tx)).debug(format!(
455 "BitTorrent Tracker sent {} bytes to {}",
456 output_data.len(),
457 peer_addr
458 ));
459
460 // TRACE: Log full response
461 if let Ok(response_str) = std::str::from_utf8(output_data) {
462 Log::new(Some(status_tx))
463 .trace(format!("BitTorrent Tracker response: {}", response_str));
464 }
465 }
466 }
467
468 if sent_any {
469 Log::new(Some(status_tx)).debug(format!(
470 "BitTorrent Tracker {} from {} decision={}",
471 request_type,
472 peer_addr,
473 if model_rejected {
474 "model_reject"
475 } else {
476 "model_answer"
477 }
478 ));
479 } else {
480 // The model produced nothing that reaches the wire. An announce is a
481 // request/response exchange, so silence leaves the client waiting out
482 // its own timeout and then marking this tracker dead — answer with a
483 // category instead.
484 Log::new(Some(status_tx)).warn(format!(
485 "BitTorrent Tracker {} from {} decision=fail_closed_no_action",
486 request_type, peer_addr
487 ));
488 Self::write_failure_response(
489 write_half,
490 WireFailure::Unavailable,
491 connection_id,
492 server_id,
493 app_state,
494 )
495 .await?;
496 }
497 }
498 Err(e) => {
499 // The error itself goes to the log and the status stream only. The client
500 // gets a status code and a fixed category string — never the backend URL,
501 // the model name or an `anyhow` context chain.
502 let failure = WireFailure::classify(&e);
503 Log::new(Some(status_tx)).error(format!(
504 "BitTorrent Tracker {} from {} decision=fail_closed_llm_error \
505 category={:?}: {}",
506 request_type, peer_addr, failure, e
507 ));
508 Self::write_failure_response(
509 write_half,
510 failure,
511 connection_id,
512 server_id,
513 app_state,
514 )
515 .await?;
516 }
517 }
518
519 Ok(())
520 }
521
522 /// Answer a request netget cannot serve, with a category and nothing more.
523 ///
524 /// The two categories map onto different status codes so a client backs off rather than
525 /// recording a permanent fault: **503 + `Retry-After`** for a saturated backend, **500**
526 /// for everything else. This used to be a bare
527 /// `HTTP/1.1 500 Internal Server Error\r\n\r\n` for both — no `Content-Length`, no
528 /// `Connection: close`, and no way for a client to tell "come back later" from "this
529 /// tracker is broken".
530 ///
531 /// The body is a bencoded `failure reason`, because that is the one refusal BEP 3
532 /// defines and the only thing a BitTorrent client will actually display. Its text is
533 /// [`WireFailure::text`], a `&'static str`.
534 async fn write_failure_response(
535 write_half: &SharedWrite,
536 failure: WireFailure,
537 connection_id: ConnectionId,
538 server_id: crate::state::ServerId,
539 app_state: &Arc<AppState>,
540 ) -> Result<()> {
541 let mut body_dict = std::collections::HashMap::new();
542 body_dict.insert(
543 b"failure reason".to_vec(),
544 serde_bencode::value::Value::Bytes(failure.text().as_bytes().to_vec()),
545 );
546 let body = serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(body_dict))
547 .unwrap_or_default();
548
549 let head = if failure.is_overloaded() {
550 format!(
551 "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 30\r\nContent-Type: \
552 text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
553 body.len()
554 )
555 } else {
556 format!(
557 "HTTP/1.1 500 Internal Server Error\r\nContent-Type: \
558 text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
559 body.len()
560 )
561 };
562
563 let mut response = head.into_bytes();
564 response.extend_from_slice(&body);
565 Self::write_counted(write_half, &response, app_state, server_id, connection_id).await?;
566 Ok(())
567 }
568
569 fn parse_http_request(request: &str) -> Result<(String, HashMap<String, serde_json::Value>)> {
570 // Parse HTTP request line
571 let first_line = request
572 .lines()
573 .next()
574 .ok_or_else(|| anyhow::anyhow!("Empty request"))?;
575
576 let parts: Vec<&str> = first_line.split_whitespace().collect();
577 if parts.len() < 2 {
578 return Err(anyhow::anyhow!("Invalid HTTP request"));
579 }
580
581 let path = parts[1];
582
583 // Determine request type (announce or scrape)
584 let request_type = if path.starts_with("/announce") {
585 "announce"
586 } else if path.starts_with("/scrape") {
587 "scrape"
588 } else {
589 "unknown"
590 };
591
592 // Parse query parameters.
593 //
594 // `request_type`, `path` and `compact` are always inserted: they are declared as
595 // event parameters, and a static handler referencing `{{event.compact}}` is a hard
596 // error if the field is missing, which it would be for any client that omits the
597 // query parameter.
598 let mut params = HashMap::new();
599 params.insert(
600 "request_type".to_string(),
601 serde_json::json!(request_type.to_string()),
602 );
603 params.insert("path".to_string(), serde_json::json!(path.to_string()));
604 params.insert("compact".to_string(), serde_json::json!(0u64));
605 if let Some(query_start) = path.find('?') {
606 let query = &path[query_start + 1..];
607 for param in query.split('&') {
608 if let Some(eq_pos) = param.find('=') {
609 // Decode key (always UTF-8)
610 let key = urlencoding::decode(¶m[..eq_pos])
611 .unwrap_or_else(|_| std::borrow::Cow::Borrowed(¶m[..eq_pos]))
612 .into_owned();
613
614 // For binary fields (info_hash, peer_id), manually percent-decode without UTF-8 validation
615 if key == "info_hash" || key == "peer_id" {
616 let value_str = ¶m[eq_pos + 1..];
617 let bytes = percent_decode_bytes(value_str);
618 params.insert(key, serde_json::json!(hex::encode(&bytes)));
619 } else {
620 // For other fields, decode as UTF-8
621 let value = urlencoding::decode(¶m[eq_pos + 1..])
622 .unwrap_or_else(|_| std::borrow::Cow::Borrowed(¶m[eq_pos + 1..]))
623 .into_owned();
624
625 if key == "port"
626 || key == "uploaded"
627 || key == "downloaded"
628 || key == "left"
629 || key == "numwant"
630 || key == "compact"
631 {
632 // Numeric fields
633 if let Ok(num) = value.parse::<u64>() {
634 params.insert(key, serde_json::json!(num));
635 } else {
636 params.insert(key, serde_json::json!(value));
637 }
638 } else {
639 params.insert(key, serde_json::json!(value));
640 }
641 }
642 }
643 }
644 }
645
646 Ok((request_type.to_string(), params))
647 }
648}
649
650/// Percent-decode bytes without UTF-8 validation (for binary fields like info_hash)
651fn percent_decode_bytes(s: &str) -> Vec<u8> {
652 let mut result = Vec::new();
653 let mut chars = s.chars();
654
655 while let Some(ch) = chars.next() {
656 if ch == '%' {
657 // Read next two hex digits
658 let hex: String = chars.by_ref().take(2).collect();
659 if hex.len() == 2 {
660 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
661 result.push(byte);
662 continue;
663 }
664 }
665 // If parsing failed, just add the '%' and hex chars as-is
666 result.push(b'%');
667 result.extend(hex.bytes());
668 } else {
669 // Non-escaped character
670 result.extend(ch.to_string().bytes());
671 }
672 }
673
674 result
675}