Skip to main content

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(&param[..eq_pos])
611                        .unwrap_or_else(|_| std::borrow::Cow::Borrowed(&param[..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 = &param[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(&param[eq_pos + 1..])
622                            .unwrap_or_else(|_| std::borrow::Cow::Borrowed(&param[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}