1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct TorrentTrackerProtocol;
15
16impl TorrentTrackerProtocol {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl Protocol for TorrentTrackerProtocol {
24 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
25 Vec::new()
26 }
27 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
28 vec![
29 SEND_ANNOUNCE_RESPONSE_ACTION.clone(),
30 SEND_SCRAPE_RESPONSE_ACTION.clone(),
31 SEND_ERROR_RESPONSE_ACTION.clone(),
32 ]
33 }
34 fn protocol_name(&self) -> &'static str {
35 "Torrent-Tracker"
36 }
37 fn get_event_types(&self) -> Vec<EventType> {
38 vec![
39 TRACKER_ANNOUNCE_REQUEST_EVENT.clone(),
40 TRACKER_SCRAPE_REQUEST_EVENT.clone(),
41 ]
42 }
43 fn stack_name(&self) -> &'static str {
44 "ETH>IP>TCP>HTTP>BitTorrent-Tracker"
45 }
46 fn keywords(&self) -> Vec<&'static str> {
47 vec!["torrent-tracker", "tracker", "bittorrent-tracker"]
48 }
49 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
50 use crate::protocol::metadata::{
51 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
52 };
53
54 ProtocolMetadataV2::builder()
55 .state(DevelopmentState::Beta)
56 .privilege_requirement(PrivilegeRequirement::None)
57 .well_known_port(6969)
58 .implementation("HTTP server with bencode response encoding (serde_bencode)")
59 .llm_control("Peer list generation, announce/scrape responses")
60 .e2e_testing(
61 "REAL THIRD-PARTY CLIENT: aria2c 1.37.0 (a C++ BitTorrent implementation) in \
62 tests/server/torrent_tracker/real_client_test.rs. NOT #[ignore]d and NOT \
63 skip-gated - both tests FAIL, naming `brew install aria2`, when the binary is \
64 absent. Not circular: the server hand-rolls its HTTP on a raw TcpListener and \
65 encodes with serde_bencode; aria2 links neither. aria2 announces with its own \
66 info_hash and peer_id (the mock rule MATCHES ON BOTH, so a percent-decode or \
67 hex-encode error means the rule never fires and the test fails in both halves), \
68 and then DECODES OUR COMPACT PEER LIST - a bencode byte string, six bytes per \
69 peer, four address octets plus a big-endian port, with nothing self-describing \
70 in it - which the test asserts by requiring aria2 to dial 10.0.0.1:6881 AND \
71 10.0.0.2:6882, so a wrong byte order, stride or length loses a peer and fails. \
72 `compact=1` is HARDCODED in aria2 and is the branch a real swarm always takes; \
73 no earlier test drove it against a real client. The second test asserts aria2 \
74 printing `Tracker returned failure reason: <our text>`, i.e. its own parse of \
75 our bencoded refusal dict inside an HTTP 200. \
76 This field previously claimed 'Real BitTorrent clients (transmission, aria2)' \
77 when nothing in the tree referenced either, and was then corrected to say no \
78 real client existed; aria2 is now genuinely driven. Alongside: e2e_test, \
79 llm_failure_test and peer_inject_test cover announce/scrape round trips, a \
80 send_error_response refusal, connection stats and the LLM-failure reply \
81 (503/500 + bencoded `failure reason`, asserted to leak nothing internal). \
82 NOT PROVEN: the DICTIONARY peer form against a real client (aria2 can parse it \
83 but never requests it, so only hand-written tests reach that branch); IPv6 and \
84 BEP 7 `peers6`, which are NOT IMPLEMENTED; multi-info_hash scrape, and /scrape \
85 against a real client at all, since aria2 does not issue one on its own; a \
86 completed download, there being no real swarm; and the 400/408 paths."
87 )
88 .notes("Bencode<->JSON conversion, compact peer format. Stores nothing: peers, swarm counts and scrape statistics all come from the model, so successive announces are not correlated unless the model correlates them.")
89 .build()
90 }
91 fn description(&self) -> &'static str {
92 "BitTorrent Tracker server for coordinating peers (announce/scrape)"
93 }
94 fn example_prompt(&self) -> &'static str {
95 "start a bittorrent tracker on port 6969"
96 }
97 fn group_name(&self) -> &'static str {
98 "P2P"
99 }
100
101 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
102 use crate::llm::actions::StartupExamples;
103 use serde_json::json;
104
105 let script = r#"import json, sys
108data = json.load(sys.stdin)
109event = data["event"]
110if data["event_type_id"] == "tracker_announce_request":
111 actions = [{"type": "send_announce_response", "interval": 1800,
112 "complete": 0, "incomplete": 0, "peers": []}]
113else:
114 actions = []
115print(json.dumps({"actions": actions}))"#;
116
117 StartupExamples::new(
118 json!({
120 "type": "open_server",
121 "port": 6969,
122 "base_stack": "torrent-tracker",
123 "instruction": "Act as BitTorrent tracker. Track peers and respond to announce/scrape requests"
124 }),
125 json!({
127 "type": "open_server",
128 "port": 6969,
129 "base_stack": "torrent-tracker",
130 "event_handlers": [{
131 "event_pattern": "tracker_announce_request",
132 "handler": {
133 "type": "script",
134 "language": "python",
135 "code": script
136 }
137 }]
138 }),
139 json!({
141 "type": "open_server",
142 "port": 6969,
143 "base_stack": "torrent-tracker",
144 "event_handlers": [{
145 "event_pattern": "tracker_announce_request",
146 "handler": {
147 "type": "static",
148 "actions": [{
149 "type": "send_announce_response",
150 "interval": 1800,
151 "complete": 10,
152 "incomplete": 5,
153 "compact": "{{event.compact}}",
154 "peers": [{"ip": "127.0.0.1", "port": 51413}]
155 }]
156 }
157 }]
158 }),
159 )
160 }
161}
162
163impl Server for TorrentTrackerProtocol {
165 fn spawn(
166 &self,
167 ctx: crate::protocol::SpawnContext,
168 ) -> std::pin::Pin<
169 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
170 > {
171 Box::pin(async move {
172 use crate::server::torrent_tracker::TorrentTrackerServer;
173 TorrentTrackerServer::spawn_with_llm_actions(
174 ctx.legacy_listen_addr(),
175 ctx.llm_client,
176 ctx.state,
177 ctx.status_tx,
178 ctx.server_id,
179 )
180 .await
181 })
182 }
183 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
184 let action_type = action
185 .get("type")
186 .and_then(|v| v.as_str())
187 .context("Missing 'type' field in action")?;
188
189 match action_type {
190 "send_announce_response" => self.execute_send_announce_response(action),
191 "send_scrape_response" => self.execute_send_scrape_response(action),
192 "send_error_response" => self.execute_send_error_response(action),
193 "close_connection" => Ok(ActionResult::CloseConnection),
200 _ => Err(anyhow::anyhow!(
201 "Unknown BitTorrent Tracker action: {}",
202 action_type
203 )),
204 }
205 }
206}
207
208fn is_compact(value: Option<&serde_json::Value>) -> bool {
215 match value {
216 Some(serde_json::Value::Bool(b)) => *b,
217 Some(serde_json::Value::Number(n)) => n.as_u64().is_some_and(|n| n != 0),
218 Some(serde_json::Value::String(s)) => {
219 matches!(s.as_str(), "1" | "true" | "yes" | "on")
220 }
221 _ => false,
222 }
223}
224
225impl TorrentTrackerProtocol {
226 fn execute_send_announce_response(&self, action: serde_json::Value) -> Result<ActionResult> {
227 let interval = action
228 .get("interval")
229 .and_then(|v| v.as_u64())
230 .unwrap_or(1800) as i64;
231 let complete = action.get("complete").and_then(|v| v.as_u64()).unwrap_or(0) as i64;
232 let incomplete = action
233 .get("incomplete")
234 .and_then(|v| v.as_u64())
235 .unwrap_or(0) as i64;
236 let compact = is_compact(action.get("compact"));
237
238 let peer_entries = action
239 .get("peers")
240 .and_then(|v| v.as_array())
241 .cloned()
242 .unwrap_or_default();
243
244 let peers_value = if compact {
250 let mut bytes = Vec::with_capacity(peer_entries.len() * 6);
251 for peer in &peer_entries {
252 let Some(ip) = peer.get("ip").and_then(|v| v.as_str()) else {
253 continue;
254 };
255 let Some(port) = peer.get("port").and_then(|v| v.as_u64()) else {
256 continue;
257 };
258 let Ok(std::net::IpAddr::V4(addr)) = ip.parse::<std::net::IpAddr>() else {
259 continue;
262 };
263 bytes.extend_from_slice(&addr.octets());
264 bytes.extend_from_slice(&(port as u16).to_be_bytes());
265 }
266 serde_bencode::value::Value::Bytes(bytes)
267 } else {
268 let peers = peer_entries
269 .iter()
270 .filter_map(|peer| {
271 let ip = peer.get("ip").and_then(|v| v.as_str())?.to_string();
272 let port = peer.get("port").and_then(|v| v.as_u64())? as i64;
273
274 let mut dict = std::collections::HashMap::new();
275 if let Some(peer_id) = peer.get("peer_id").and_then(|v| v.as_str()) {
288 let bytes = match hex::decode(peer_id) {
289 Ok(decoded) if peer_id.len() == 40 => decoded,
290 _ => peer_id.as_bytes().to_vec(),
291 };
292 dict.insert(
293 b"peer id".to_vec(),
294 serde_bencode::value::Value::Bytes(bytes),
295 );
296 }
297 dict.insert(
298 b"ip".to_vec(),
299 serde_bencode::value::Value::Bytes(ip.into_bytes()),
300 );
301 dict.insert(b"port".to_vec(), serde_bencode::value::Value::Int(port));
302 Some(serde_bencode::value::Value::Dict(dict))
303 })
304 .collect::<Vec<_>>();
305 serde_bencode::value::Value::List(peers)
306 };
307
308 let mut response_dict = std::collections::HashMap::new();
309 response_dict.insert(
310 b"interval".to_vec(),
311 serde_bencode::value::Value::Int(interval),
312 );
313 response_dict.insert(
314 b"complete".to_vec(),
315 serde_bencode::value::Value::Int(complete),
316 );
317 response_dict.insert(
318 b"incomplete".to_vec(),
319 serde_bencode::value::Value::Int(incomplete),
320 );
321 response_dict.insert(b"peers".to_vec(), peers_value);
322
323 let bencode_data =
324 serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response_dict))?;
325 let http_response = format!(
326 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
327 bencode_data.len()
328 );
329 let mut full_response = http_response.into_bytes();
330 full_response.extend_from_slice(&bencode_data);
331
332 Ok(ActionResult::Output(full_response))
333 }
334
335 fn execute_send_scrape_response(&self, action: serde_json::Value) -> Result<ActionResult> {
336 let entries: Vec<(String, &serde_json::Value)> = match action.get("files") {
342 Some(serde_json::Value::Object(obj)) => {
343 obj.iter().map(|(k, v)| (k.clone(), v)).collect()
344 }
345 Some(serde_json::Value::Array(arr)) => arr
346 .iter()
347 .filter_map(|entry| {
348 let hash = entry.get("info_hash").and_then(|v| v.as_str())?;
349 Some((hash.to_string(), entry))
350 })
351 .collect(),
352 _ => Vec::new(),
353 };
354
355 let mut files = std::collections::HashMap::new();
356 for (info_hash_hex, stats) in entries {
357 let Ok(info_hash) = hex::decode(&info_hash_hex) else {
358 continue;
359 };
360 let complete = stats.get("complete").and_then(|v| v.as_u64()).unwrap_or(0) as i64;
361 let downloaded = stats
362 .get("downloaded")
363 .and_then(|v| v.as_u64())
364 .unwrap_or(0) as i64;
365 let incomplete = stats
366 .get("incomplete")
367 .and_then(|v| v.as_u64())
368 .unwrap_or(0) as i64;
369
370 let mut stats_dict = std::collections::HashMap::new();
371 stats_dict.insert(
372 b"complete".to_vec(),
373 serde_bencode::value::Value::Int(complete),
374 );
375 stats_dict.insert(
376 b"downloaded".to_vec(),
377 serde_bencode::value::Value::Int(downloaded),
378 );
379 stats_dict.insert(
380 b"incomplete".to_vec(),
381 serde_bencode::value::Value::Int(incomplete),
382 );
383 files.insert(info_hash, serde_bencode::value::Value::Dict(stats_dict));
384 }
385
386 let mut response_dict = std::collections::HashMap::new();
387 response_dict.insert(b"files".to_vec(), serde_bencode::value::Value::Dict(files));
388
389 let bencode_data =
390 serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response_dict))?;
391 let http_response = format!(
392 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
393 bencode_data.len()
394 );
395 let mut full_response = http_response.into_bytes();
396 full_response.extend_from_slice(&bencode_data);
397
398 Ok(ActionResult::Output(full_response))
399 }
400
401 fn execute_send_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
402 let error_message = action
411 .get("failure_reason")
412 .or_else(|| action.get("error"))
413 .and_then(|v| v.as_str())
414 .context(
415 "send_error_response requires 'failure_reason' (alias 'error'): the text the \
416 client displays before it stops announcing to this tracker",
417 )?;
418
419 let mut response_dict = std::collections::HashMap::new();
420 response_dict.insert(
421 b"failure reason".to_vec(),
422 serde_bencode::value::Value::Bytes(error_message.as_bytes().to_vec()),
423 );
424
425 let bencode_data =
426 serde_bencode::to_bytes(&serde_bencode::value::Value::Dict(response_dict))?;
427 let http_response = format!(
428 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
429 bencode_data.len()
430 );
431 let mut full_response = http_response.into_bytes();
432 full_response.extend_from_slice(&bencode_data);
433
434 Ok(ActionResult::Output(full_response))
435 }
436}
437
438fn common_request_parameters() -> Vec<Parameter> {
444 vec![
445 Parameter {
446 name: "request_type".to_string(),
447 type_hint: "string".to_string(),
448 description: "\"announce\", \"scrape\" or \"unknown\"".to_string(),
449 required: true,
450 },
451 Parameter {
452 name: "path".to_string(),
453 type_hint: "string".to_string(),
454 description: "Request path including the query string".to_string(),
455 required: true,
456 },
457 Parameter {
458 name: "info_hash".to_string(),
459 type_hint: "string".to_string(),
460 description: "Torrent info hash, hex-encoded (40 chars). Echo it back as the \
461 key of a scrape `files` entry."
462 .to_string(),
463 required: false,
464 },
465 Parameter {
466 name: "compact".to_string(),
467 type_hint: "number".to_string(),
468 description: "1 if the client wants BEP 23 compact peers, 0 otherwise. \
469 Always present; pass it straight through to the response action."
470 .to_string(),
471 required: true,
472 },
473 ]
474}
475
476pub static TRACKER_ANNOUNCE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
477 let mut parameters = common_request_parameters();
478 parameters.extend(vec![
479 Parameter {
480 name: "peer_id".to_string(),
481 type_hint: "string".to_string(),
482 description: "Announcing peer's ID, hex-encoded (40 chars)".to_string(),
483 required: false,
484 },
485 Parameter {
486 name: "port".to_string(),
487 type_hint: "number".to_string(),
488 description: "Port the announcing peer listens on".to_string(),
489 required: false,
490 },
491 Parameter {
492 name: "uploaded".to_string(),
493 type_hint: "number".to_string(),
494 description: "Bytes uploaded so far".to_string(),
495 required: false,
496 },
497 Parameter {
498 name: "downloaded".to_string(),
499 type_hint: "number".to_string(),
500 description: "Bytes downloaded so far".to_string(),
501 required: false,
502 },
503 Parameter {
504 name: "left".to_string(),
505 type_hint: "number".to_string(),
506 description: "Bytes still needed. 0 means the peer is a seeder.".to_string(),
507 required: false,
508 },
509 Parameter {
510 name: "event".to_string(),
511 type_hint: "string".to_string(),
512 description: "\"started\", \"completed\", \"stopped\" or absent".to_string(),
513 required: false,
514 },
515 Parameter {
516 name: "numwant".to_string(),
517 type_hint: "number".to_string(),
518 description: "How many peers the client would like back".to_string(),
519 required: false,
520 },
521 ]);
522
523 EventType::new(
524 "tracker_announce_request",
525 "BitTorrent client announced itself and is asking for peers",
526 json!({
532 "type": "send_announce_response",
533 "interval": 1800,
534 "complete": 1,
535 "incomplete": 0,
536 "compact": 1,
537 "peers": [{"ip": "127.0.0.1", "port": 51413}]
538 }),
539 )
540 .with_parameters(parameters)
541 .with_actions(vec![
542 SEND_ANNOUNCE_RESPONSE_ACTION.clone(),
543 SEND_ERROR_RESPONSE_ACTION.clone(),
544 ])
545 .with_alternative_example(json!({
546 "type": "send_error_response",
547 "failure_reason": "This tracker does not serve that torrent"
548 }))
549 .with_log_template(
550 LogTemplate::new()
551 .with_info("{client_ip} BT announce {event} ({duration_ms}ms)")
552 .with_debug("BT tracker announce from {client_ip}: event={event}")
553 .with_trace("BT announce: {json_pretty(.)}"),
554 )
555});
556
557pub static TRACKER_SCRAPE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
558 EventType::new(
559 "tracker_scrape_request",
560 "BitTorrent client asked for torrent statistics",
561 json!({
567 "type": "send_scrape_response",
568 "files": [{
569 "info_hash": "1111111111111111111111111111111111111111",
570 "complete": 1, "downloaded": 1, "incomplete": 0
571 }]
572 }),
573 )
574 .with_parameters(common_request_parameters())
575 .with_actions(vec![
576 SEND_SCRAPE_RESPONSE_ACTION.clone(),
577 SEND_ERROR_RESPONSE_ACTION.clone(),
578 ])
579 .with_log_template(
580 LogTemplate::new()
581 .with_info("{client_ip} BT scrape ({duration_ms}ms)")
582 .with_debug("BT tracker scrape from {client_ip}")
583 .with_trace("BT scrape: {json_pretty(.)}"),
584 )
585});
586
587pub static SEND_ANNOUNCE_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
588 ActionDefinition {
589 name: "send_announce_response".to_string(),
590 description: "Send announce response with peer list".to_string(),
591 parameters: vec![
592 Parameter {
593 name: "interval".to_string(),
594 type_hint: "number".to_string(),
595 description: "Announce interval in seconds (default: 1800)".to_string(),
596 required: false,
597 },
598 Parameter {
599 name: "complete".to_string(),
600 type_hint: "number".to_string(),
601 description: "Number of seeders (default: 0)".to_string(),
602 required: false,
603 },
604 Parameter {
605 name: "incomplete".to_string(),
606 type_hint: "number".to_string(),
607 description: "Number of leechers (default: 0)".to_string(),
608 required: false,
609 },
610 Parameter {
611 name: "compact".to_string(),
612 type_hint: "boolean|number".to_string(),
619 description: "Encode peers in BEP 23 compact form (4-byte IPv4 + 2-byte \
620 port). Send 1 or 0 (`true`/`false` also work). Most real \
621 clients require compact form, which carries no peer_id and \
622 drops IPv6 peers, so 1 is usually right. In a *static* handler \
623 — and only there — \"{{event.compact}}\" passes the request's \
624 own value through; on this path that placeholder is not \
625 interpolated and would be read as text."
626 .to_string(),
627 required: false,
628 },
629 Parameter {
630 name: "peers".to_string(),
631 type_hint: "array".to_string(),
632 description: "Array of peer objects with `ip` and `port`, plus an optional \
633 `peer_id` used only in non-compact form. A `peer_id` of 40 hex \
634 characters is decoded to the 20 bytes BEP 3 wants — that is \
635 the form the announce event reports it in — and anything else \
636 is sent as literal text."
637 .to_string(),
638 required: false,
639 },
640 ],
641 example: json!({"type": "send_announce_response", "interval": 1800, "complete": 10, "incomplete": 5, "compact": 1, "peers": [{"peer_id": "2d5452303030312d787878787878787878787878", "ip": "192.168.1.100", "port": 51413}]}),
649 log_template: Some(
650 LogTemplate::new()
651 .with_info("-> BT announce: {peers_len} peers, interval={interval}s")
652 .with_debug("BT announce response: {peers_len} peers, complete={complete}, incomplete={incomplete}"),
653 ),
654 }
655});
656
657pub static SEND_SCRAPE_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
658 ActionDefinition {
659 name: "send_scrape_response".to_string(),
660 description: "Send scrape response with torrent statistics".to_string(),
661 parameters: vec![Parameter {
662 name: "files".to_string(),
663 type_hint: "object".to_string(),
664 description: "Either an object keyed by hex info_hash -> \
665 {complete, downloaded, incomplete}, or an array of objects each \
666 carrying an `info_hash` field plus those counts. Keys that are \
667 not valid hex are dropped."
668 .to_string(),
669 required: false,
670 }],
671 example: json!({"type": "send_scrape_response", "files": [{"info_hash": "1111111111111111111111111111111111111111", "complete": 10, "downloaded": 100, "incomplete": 5}]}),
678 log_template: Some(
679 LogTemplate::new()
680 .with_info("-> BT scrape: {files_len} torrents")
681 .with_debug("BT scrape response: {files_len} torrents"),
682 ),
683 }
684});
685
686pub static SEND_ERROR_RESPONSE_ACTION: LazyLock<ActionDefinition> =
687 LazyLock::new(|| ActionDefinition {
688 name: "send_error_response".to_string(),
689 description: "Refuse the request with a bencode `failure reason` (BEP 3). Clients \
690 display this and stop announcing to this tracker."
691 .to_string(),
692 parameters: vec![Parameter {
693 name: "failure_reason".to_string(),
694 type_hint: "string".to_string(),
695 description: "Message shown to the client, which then stops announcing to this \
696 tracker (alias: `error`). Genuinely required — omitting it is an \
697 error, not a generic refusal."
698 .to_string(),
699 required: true,
700 }],
701 example: json!({"type": "send_error_response", "failure_reason": "Torrent not found"}),
702 log_template: Some(
703 LogTemplate::new()
704 .with_info("-> BT error: {failure_reason}")
705 .with_debug("BT tracker error: {failure_reason}"),
706 ),
707 });