netget/server/torrent_peer/mod.rs
1//! BitTorrent Peer Wire Protocol server implementation
2//!
3//! TCP-based protocol for peer-to-peer data transfer between BitTorrent clients
4
5pub mod actions;
6
7use anyhow::Result;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::net::TcpListener;
11use tokio::sync::mpsc;
12use tracing::error;
13
14use crate::llm::action_helper::call_llm;
15use crate::llm::ollama_client::OllamaClient;
16use crate::logging::emit::Log;
17use crate::protocol::Event;
18use crate::server::connection::ConnectionId;
19use crate::state::app_state::AppState;
20use crate::utils::WireFailure;
21use actions::TorrentPeerProtocol;
22
23/// What the connection loop should do after one event has been dispatched.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25enum Disposition {
26 /// Keep reading from the peer.
27 Continue,
28 /// The peer has been refused and its write half half-closed; stop reading.
29 Close,
30}
31
32/// `<len=0001><id=0>` — the peer wire protocol's choke message.
33///
34/// The only refusal the base protocol has. There is no error frame and no free-text field
35/// anywhere in BEP 3, so a failure category can be expressed by *which* refusal is sent and
36/// whether the connection survives it — never by text, which has nowhere to go.
37const CHOKE_FRAME: [u8; 5] = [0x00, 0x00, 0x00, 0x01, 0x00];
38
39/// How long to wait for a peer's handshake after it has connected.
40///
41/// **This was 30 seconds, and the argument for 30 described a peer this server rarely has.**
42/// It read: BEP 3 has the initiating peer send its 68-byte handshake immediately — it is the
43/// first thing on the wire and nothing precedes it — so a peer that has connected and sent
44/// nothing is not mid-handshake, it is holding a socket. That is true of every mainline
45/// client. It is false of NetGet's own peer client, and there the two ends deadlock.
46///
47/// `src/client/torrent_peer/mod.rs` connects and its read loop's **first** act is
48/// `read_exact` on the *other* peer's 68-byte handshake. Sending its own is only the
49/// `send_handshake` action, which needs a model turn or a person at `[ send message ]`: a
50/// client created from the dashboard's `[ + torrent_peer client ]` is routed
51/// `torrent_peer_connected` → static-with-no-actions and then `*` → manual
52/// (`src/tui/modal/form.rs`). So both ends sit in `read`, and at 30 seconds this server is the
53/// one that gives up — while the operator is still reading the parked event that would have
54/// unblocked it.
55///
56/// 300 seconds is the window a `manual` rule gives a human to answer one event
57/// (`src/state/intercepts.rs`), which is the number this product already uses for how long
58/// someone might take. Nothing in BEP 3 argues against it: the specification says when a
59/// handshake is sent, not how long a listener must wait for one.
60///
61/// What it costs: a stranger holding a socket, a task, an `AppState` row and one of
62/// [`MAX_CONNECTIONS`] slots while saying nothing now gets 300 seconds rather than 30 — a
63/// tenfold rise in how long one idle slot is held, not a removal of the bound. The cap still
64/// holds, and a peer over it still gets [`CONNECTION_CAP_REFUSAL`]: a plain close. A listener
65/// genuinely exposed to a public swarm should set `first_byte_timeout_secs` low; 30 is the old
66/// value and remains a sound choice for one.
67const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
68
69/// The two read deadlines for one connection, resolved from the server's startup parameters.
70///
71/// Carried as one value so threading them from `spawn_with_llm_actions` into the connection
72/// task costs one argument rather than two.
73#[derive(Clone, Copy)]
74struct ReadDeadlines {
75 /// [`HANDSHAKE_READ_TIMEOUT`], or this server's `first_byte_timeout_secs`.
76 first_byte: std::time::Duration,
77 /// [`IDLE_AFTER_HANDSHAKE_TIMEOUT`], or this server's `idle_timeout_secs`.
78 idle: std::time::Duration,
79}
80
81impl ReadDeadlines {
82 fn resolve(first_byte_secs: Option<u64>, idle_secs: Option<u64>) -> Self {
83 Self {
84 first_byte: first_byte_secs
85 .map(std::time::Duration::from_secs)
86 .unwrap_or(HANDSHAKE_READ_TIMEOUT),
87 idle: idle_secs
88 .map(std::time::Duration::from_secs)
89 .unwrap_or(IDLE_AFTER_HANDSHAKE_TIMEOUT),
90 }
91 }
92}
93
94/// How long to wait for a *further* message once the handshake is done.
95///
96/// The peer wire protocol's own answer to this question is the keep-alive: a zero-length
97/// message sent roughly every two minutes precisely so an idle-but-live peer can be told from
98/// a dead one, and every mainline client drops a connection that goes quiet for about that
99/// long. Three minutes gives a conforming peer a full missed keep-alive of slack before this
100/// server does what the convention already expects it to do.
101///
102/// Overridable per server with `idle_timeout_secs`, for the same reason the first bound is:
103/// the right value is a property of who is on the other end, and only the operator knows that.
104const IDLE_AFTER_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
105
106/// Concurrent connections this server admits.
107///
108/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Real swarms are far smaller
109/// than this — mainline clients cap global peers in the low hundreds and per-torrent peers at
110/// a few dozen — so 256 refuses nothing a real swarm produces.
111const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
112
113/// A peer over [`MAX_CONNECTIONS`] gets a plain close and no bytes, and that is deliberate.
114///
115/// BEP 3 has no busy, error or free-text message of any kind, and the one refusal it does
116/// define — [`CHOKE_FRAME`] — is only legal *after* a handshake this peer has not sent. Any
117/// bytes written here would be read as the first 5 of the 68 handshake bytes, so a client
118/// would report a malformed handshake rather than a full server: strictly worse than silence,
119/// which is the same reason twenty protocols in this tree stay silent on an LLM failure. The
120/// refusal is in the log instead, and the log line is the diagnosis.
121const CONNECTION_CAP_REFUSAL: &[u8] = b"";
122
123/// Largest the inbound frame accumulator may grow before the peer is closed.
124///
125/// A peer that never completes a frame would otherwise grow this buffer without bound. The
126/// largest legitimate frame is a piece message, capped well under this by every client in use.
127/// Note the peer-supplied length prefix is never used to allocate — it only gates whether a
128/// whole frame has arrived — so this cap is what a `0xFFFFFFFF` prefix actually runs into.
129///
130/// Module level rather than inside the read loop, so `metadata()` can declare the constant the
131/// loop enforces instead of repeating the number.
132pub const MAX_PENDING: usize = 2 * 1024 * 1024;
133
134/// BitTorrent Peer Wire Protocol server
135pub struct TorrentPeerServer;
136
137impl TorrentPeerServer {
138 /// Spawn BitTorrent Peer server with LLM actions
139 #[allow(clippy::too_many_arguments)]
140 pub async fn spawn_with_llm_actions(
141 listen_addr: SocketAddr,
142 llm_client: OllamaClient,
143 app_state: Arc<AppState>,
144 status_tx: mpsc::UnboundedSender<String>,
145 server_id: crate::state::ServerId,
146 first_byte_timeout_secs: Option<u64>,
147 idle_timeout_secs: Option<u64>,
148 ) -> Result<SocketAddr> {
149 // Both bounds are tunable because their right value is a property of who is on the
150 // other end, which only the operator knows. The defaults serve NetGet's own peer
151 // client, which waits for our handshake before it sends its own; a listener on a
152 // public swarm wants the first one much lower.
153 let deadlines = ReadDeadlines::resolve(first_byte_timeout_secs, idle_timeout_secs);
154 let listener = TcpListener::bind(listen_addr).await?;
155 let local_addr = listener.local_addr()?;
156 Log::new(Some(&status_tx)).info(format!(
157 "BitTorrent Peer server (action-based) listening on {}",
158 local_addr
159 ));
160
161 let protocol = Arc::new(TorrentPeerProtocol::new());
162
163 let task_registrar = app_state.clone();
164 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
165 let accept_handle = tokio::spawn(async move {
166 loop {
167 match crate::server::accept_bounded::accept_bounded(
168 &listener,
169 &limiter,
170 CONNECTION_CAP_REFUSAL,
171 "BitTorrent Peer",
172 Some(&status_tx),
173 )
174 .await
175 {
176 Ok((stream, peer_addr, permit)) => {
177 let connection_id =
178 ConnectionId::new(app_state.get_next_unified_id().await);
179 let llm_clone = llm_client.clone();
180 let state_clone = app_state.clone();
181 let status_clone = status_tx.clone();
182 let protocol_clone = protocol.clone();
183
184 Log::new(Some(&status_clone)).debug(format!(
185 "BitTorrent Peer accepted connection from {}",
186 peer_addr
187 ));
188
189 // Split stream for read/write
190 let (read_half, write_half) = tokio::io::split(stream);
191 let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
192
193 // Add connection to ServerInstance
194 use crate::state::server::{
195 ConnectionState as ServerConnectionState, ConnectionStatus,
196 ProtocolConnectionInfo,
197 };
198 let now = crate::utils::clock::Instant::now();
199 let conn_state = ServerConnectionState {
200 id: connection_id,
201 remote_addr: peer_addr,
202 local_addr,
203 bytes_sent: 0,
204 bytes_received: 0,
205 packets_sent: 0,
206 packets_received: 0,
207 last_activity: now,
208 status: ConnectionStatus::Active,
209 status_changed_at: now,
210 protocol_info: ProtocolConnectionInfo::empty(),
211 };
212 state_clone
213 .add_connection_to_server(server_id, conn_state)
214 .await;
215 let _ = status_clone.send("__UPDATE_UI__".to_string());
216
217 // Tracked, not detached: stop_server must abort this task too.
218 let task_owner = app_state.clone();
219 task_owner
220 .spawn_server_task(server_id, async move {
221 // Held for the life of the connection, so the cap counts live
222 // peers rather than accepts.
223 let _permit = permit;
224 if let Err(e) = Self::handle_connection(
225 read_half,
226 write_half,
227 peer_addr,
228 local_addr,
229 connection_id,
230 llm_clone,
231 state_clone,
232 status_clone,
233 server_id,
234 protocol_clone,
235 deadlines,
236 )
237 .await
238 {
239 error!("BitTorrent Peer connection error: {}", e);
240 }
241 })
242 .await;
243 }
244 Err(e) => {
245 Log::new(Some(&status_tx))
246 .error(format!("BitTorrent Peer accept error: {}", e));
247 }
248 }
249 }
250 });
251
252 task_registrar
253 .register_server_task(server_id, accept_handle)
254 .await;
255
256 Ok(local_addr)
257 }
258
259 #[allow(clippy::too_many_arguments)]
260 async fn handle_connection(
261 mut read_half: tokio::io::ReadHalf<tokio::net::TcpStream>,
262 write_half: Arc<tokio::sync::Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
263 peer_addr: SocketAddr,
264 _local_addr: SocketAddr,
265 connection_id: ConnectionId,
266 llm_client: OllamaClient,
267 app_state: Arc<AppState>,
268 status_tx: mpsc::UnboundedSender<String>,
269 server_id: crate::state::ServerId,
270 protocol: Arc<TorrentPeerProtocol>,
271 deadlines: ReadDeadlines,
272 ) -> Result<()> {
273 use tokio::io::AsyncReadExt;
274
275 // Register the peer handle so the dashboard can "message this peer" /
276 // "disconnect this peer" through the same write half the reader uses. All
277 // wire verbs return `ActionResult::Output`, so the generic peer command
278 // task covers the full vocabulary with nothing protocol-specific. The
279 // handle is removed on every exit path via the single cleanup below.
280 let peer_rx = crate::server::peer_support::register_peer_channel(
281 &app_state,
282 server_id,
283 connection_id.as_u32(),
284 )
285 .await;
286 crate::server::peer_support::spawn_peer_command_task(
287 peer_rx,
288 protocol.clone(),
289 app_state.clone(),
290 server_id,
291 connection_id.as_u32(),
292 write_half.clone(),
293 status_tx.clone(),
294 );
295
296 let result: Result<()> = async {
297 let mut chunk = vec![0u8; 16384];
298 // The peer wire protocol is length-prefixed over a byte stream, so a `read()` is
299 // not a message. Previously each read was parsed as exactly one frame, which meant
300 // the bitfield a client sends in the same segment as its handshake was discarded,
301 // two coalesced messages became one, and a message split across two reads was
302 // dropped and left the stream misaligned for everything after it. Accumulate and
303 // drain complete frames instead.
304 let mut pending: Vec<u8> = Vec::new();
305 let mut handshake_complete = false;
306
307 'read: loop {
308 // The deadline wraps this `read()` alone. The LLM round-trip and a `manual`
309 // rule parking a message for a human (`src/state/intercepts.rs`, 300s by
310 // default) both run further down this loop body, after a read has already
311 // returned, so neither can be cut short by it — what is bounded is only a peer
312 // holding the connection while sending nothing.
313 let read_timeout = if handshake_complete {
314 deadlines.idle
315 } else {
316 deadlines.first_byte
317 };
318 let n = match tokio::time::timeout(read_timeout, read_half.read(&mut chunk)).await {
319 Ok(read) => read?,
320 Err(_) => {
321 Log::new(Some(&status_tx)).info(format!(
322 "BitTorrent Peer {} sent nothing for {}s; closing idle connection",
323 peer_addr,
324 read_timeout.as_secs()
325 ));
326 break;
327 }
328 };
329 if n == 0 {
330 Log::new(Some(&status_tx)).debug("BitTorrent Peer connection closed by peer");
331 break;
332 }
333
334 // Refresh inbound counters (and last_activity) so the rail shows ↓ move.
335 app_state
336 .update_connection_stats(
337 server_id,
338 connection_id,
339 Some(n as u64),
340 None,
341 Some(1),
342 None,
343 )
344 .await;
345
346 Log::new(Some(&status_tx)).debug(format!(
347 "BitTorrent Peer received {} bytes from {}",
348 n, peer_addr
349 ));
350
351 // TRACE: Log full payload
352 Log::new(Some(&status_tx)).trace(format!(
353 "BitTorrent Peer data (hex): {}",
354 hex::encode(&chunk[..n])
355 ));
356
357 pending.extend_from_slice(&chunk[..n]);
358
359 if pending.len() > MAX_PENDING {
360 Log::new(Some(&status_tx)).warn(format!(
361 "BitTorrent Peer {} buffered {} bytes without a complete message, closing",
362 peer_addr,
363 pending.len()
364 ));
365 break;
366 }
367
368 loop {
369 if !handshake_complete {
370 if pending.len() < 68 {
371 break;
372 }
373 let handshake: Vec<u8> = pending.drain(..68).collect();
374 match Self::parse_handshake(&handshake) {
375 Ok((info_hash, peer_id, peer_id_hex)) => {
376 Log::new(Some(&status_tx)).debug(format!(
377 "BitTorrent Peer handshake: info_hash={}, peer_id={}",
378 info_hash, peer_id
379 ));
380
381 handshake_complete = true;
382
383 let event = Event::new(
384 &actions::PEER_HANDSHAKE_EVENT,
385 serde_json::json!({
386 "info_hash": info_hash,
387 "peer_id": peer_id,
388 "peer_id_hex": peer_id_hex,
389 }),
390 );
391
392 let disposition = Self::dispatch_event(
393 event,
394 &write_half,
395 peer_addr,
396 connection_id,
397 &llm_client,
398 &app_state,
399 &status_tx,
400 server_id,
401 &protocol,
402 )
403 .await?;
404 if disposition == Disposition::Close {
405 break 'read;
406 }
407 }
408 Err(e) => {
409 Log::new(Some(&status_tx))
410 .error(format!("Failed to parse handshake: {}", e));
411 break 'read;
412 }
413 }
414 continue;
415 }
416
417 // Keep-alive and regular messages are both `<4-byte length><body>`.
418 if pending.len() < 4 {
419 break;
420 }
421 let length =
422 u32::from_be_bytes([pending[0], pending[1], pending[2], pending[3]])
423 as usize;
424 if pending.len() < 4 + length {
425 break;
426 }
427 let frame: Vec<u8> = pending.drain(..4 + length).collect();
428
429 match Self::parse_message(&frame) {
430 Ok((message_type, message_data)) => {
431 Log::new(Some(&status_tx))
432 .debug(format!("BitTorrent Peer message type: {}", message_type));
433
434 let event_type = match message_type.as_str() {
435 // One event covers the four payload-free state messages; they
436 // share a reply vocabulary and carry `message_type` so a
437 // handler can still tell them apart.
438 "choke" | "unchoke" | "interested" | "not_interested" => {
439 &actions::PEER_CHOKE_MESSAGE_EVENT
440 }
441 "request" => &actions::PEER_REQUEST_MESSAGE_EVENT,
442 "bitfield" => &actions::PEER_BITFIELD_MESSAGE_EVENT,
443 // have / piece / cancel / keepalive / unrecognised ids used to
444 // be announced to the model as "peer_choke_message", which is
445 // simply false.
446 _ => &actions::PEER_MESSAGE_EVENT,
447 };
448 let event = Event::new(event_type, message_data);
449
450 Log::new(Some(&status_tx)).debug(format!(
451 "BitTorrent Peer calling LLM for {} message",
452 message_type
453 ));
454
455 let disposition = Self::dispatch_event(
456 event,
457 &write_half,
458 peer_addr,
459 connection_id,
460 &llm_client,
461 &app_state,
462 &status_tx,
463 server_id,
464 &protocol,
465 )
466 .await?;
467 if disposition == Disposition::Close {
468 break 'read;
469 }
470 }
471 Err(e) => {
472 Log::new(Some(&status_tx))
473 .warn(format!("Failed to parse peer message: {}", e));
474 }
475 }
476 }
477 }
478
479 Ok(())
480 }
481 .await;
482
483 // Every exit path (EOF, read error, handshake/parse abort, buffer cap) lands
484 // here: drop the peer handle so the dashboard stops offering to message or
485 // disconnect a dead connection.
486 app_state
487 .remove_peer_handle(server_id, connection_id.as_u32())
488 .await;
489 result
490 }
491
492 /// Call the LLM for one event and write whatever it produced back to the peer.
493 ///
494 /// On a backend failure the peer is *answered*, not left hanging. BitTorrent has no error
495 /// message, so the answer is a `choke` — the protocol's own "I will not serve you right
496 /// now" — and the two failure categories are told apart by what follows it:
497 ///
498 /// * [`WireFailure::Overloaded`] — choke alone. The connection stays up, so a peer that
499 /// honours choke simply stops requesting and waits for an `unchoke`; nothing is
500 /// recorded as a permanent fault and a retry costs it no new handshake.
501 /// * [`WireFailure::Unavailable`] — choke, then half-close the write half so the peer's
502 /// next read returns EOF and it moves on to another peer.
503 ///
504 /// Nothing derived from the error reaches the socket; a choke frame is five fixed bytes
505 /// and has nowhere to put text even if it were wanted. The error goes to the log and the
506 /// status stream, tagged `decision=fail_closed_llm_error`.
507 #[allow(clippy::too_many_arguments)]
508 async fn dispatch_event(
509 event: Event,
510 write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
511 peer_addr: SocketAddr,
512 connection_id: ConnectionId,
513 llm_client: &OllamaClient,
514 app_state: &Arc<AppState>,
515 status_tx: &mpsc::UnboundedSender<String>,
516 server_id: crate::state::ServerId,
517 protocol: &Arc<TorrentPeerProtocol>,
518 ) -> Result<Disposition> {
519 match call_llm(
520 llm_client,
521 app_state,
522 server_id,
523 Some(connection_id),
524 &event,
525 protocol.as_ref(),
526 )
527 .await
528 {
529 Ok(execution_result) => {
530 Self::process_llm_response(
531 execution_result,
532 write_half,
533 peer_addr,
534 connection_id,
535 app_state,
536 server_id,
537 status_tx,
538 )
539 .await?;
540 Ok(Disposition::Continue)
541 }
542 Err(e) => {
543 use tokio::io::AsyncWriteExt;
544 let failure = WireFailure::classify(&e);
545 let log = Log::new(Some(&status_tx));
546 // The full error belongs here and only here.
547 log.error(format!(
548 "BitTorrent Peer {} conn={} decision=fail_closed_llm_error category={} LLM error: {}",
549 peer_addr,
550 connection_id.as_u32(),
551 if failure.is_overloaded() { "overloaded" } else { "unavailable" },
552 e
553 ));
554
555 let write_result = {
556 let mut write = write_half.lock().await;
557 let sent = write.write_all(&CHOKE_FRAME).await;
558 if sent.is_ok() && !failure.is_overloaded() {
559 // No text to send and nothing more to say: FIN is the honest signal.
560 let _ = write.shutdown().await;
561 }
562 sent
563 };
564
565 match write_result {
566 Ok(()) => {
567 app_state
568 .update_connection_stats(
569 server_id,
570 connection_id,
571 None,
572 Some(CHOKE_FRAME.len() as u64),
573 None,
574 Some(1),
575 )
576 .await;
577 }
578 Err(write_err) => {
579 log.warn(format!(
580 "BitTorrent Peer could not send choke to {}: {}",
581 peer_addr, write_err
582 ));
583 return Ok(Disposition::Close);
584 }
585 }
586
587 if failure.is_overloaded() {
588 log.warn(format!(
589 "BitTorrent Peer choked {} (transient); connection kept open",
590 peer_addr
591 ));
592 Ok(Disposition::Continue)
593 } else {
594 log.info(format!(
595 "BitTorrent Peer choked and half-closed {} after LLM error",
596 peer_addr
597 ));
598 Ok(Disposition::Close)
599 }
600 }
601 }
602 }
603
604 #[allow(clippy::too_many_arguments)]
605 async fn process_llm_response(
606 execution_result: crate::llm::actions::executor::ExecutionResult,
607 write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
608 peer_addr: SocketAddr,
609 connection_id: ConnectionId,
610 app_state: &Arc<AppState>,
611 server_id: crate::state::ServerId,
612 status_tx: &mpsc::UnboundedSender<String>,
613 ) -> Result<()> {
614 use tokio::io::AsyncWriteExt;
615
616 // Display messages from LLM
617 for message in &execution_result.messages {
618 Log::new(Some(&status_tx)).info(format!("{}", message));
619 }
620
621 Log::new(Some(&status_tx)).debug(format!(
622 "BitTorrent Peer got {} protocol results",
623 execution_result.protocol_results.len()
624 ));
625
626 // Three outcomes must stay apart in the log: the model refused, the model said
627 // nothing, and the backend failed (tagged `decision=fail_closed_llm_error` in
628 // `dispatch_event`). Silence here is not a refusal and is not a grant — the peer
629 // simply gets no frame — so it must not be recorded as either.
630 let model_refused = execution_result
631 .raw_actions
632 .iter()
633 .any(|a| a.get("type").and_then(|t| t.as_str()) == Some("send_choke"));
634 let decision = if model_refused {
635 "model_reject"
636 } else if execution_result.protocol_results.is_empty() {
637 "model_no_action"
638 } else {
639 "model_answer"
640 };
641 Log::new(Some(&status_tx)).debug(format!(
642 "BitTorrent Peer {} conn={} decision={}",
643 peer_addr,
644 connection_id.as_u32(),
645 decision
646 ));
647
648 // Send responses. Every output is written, in order: a handshake reply is
649 // routinely followed by a bitfield and an unchoke, and dropping all but the first
650 // would leave the peer waiting.
651 for protocol_result in execution_result.protocol_results {
652 for output_data in protocol_result.get_all_output() {
653 let mut write = write_half.lock().await;
654 write.write_all(&output_data).await?;
655 drop(write);
656
657 // Refresh outbound counters (and last_activity) so the rail shows ↑ move.
658 app_state
659 .update_connection_stats(
660 server_id,
661 connection_id,
662 None,
663 Some(output_data.len() as u64),
664 None,
665 Some(1),
666 )
667 .await;
668
669 Log::new(Some(&status_tx)).debug(format!(
670 "BitTorrent Peer sent {} bytes to {}",
671 output_data.len(),
672 peer_addr
673 ));
674
675 // TRACE: Log full response
676 let hex_str = hex::encode(&output_data);
677 Log::new(Some(&status_tx))
678 .trace(format!("BitTorrent Peer sent (hex): {}", hex_str));
679 }
680 }
681
682 Ok(())
683 }
684
685 /// Parse the fixed 68-byte handshake.
686 ///
687 /// Returns `(info_hash_hex, peer_id_text, peer_id_hex)`. The peer ID is 20 arbitrary
688 /// bytes: most clients use printable ASCII but the trailing random section frequently
689 /// is not, so the lossy text form can contain replacement characters and must not be
690 /// echoed back. `peer_id_hex` is the faithful form.
691 fn parse_handshake(data: &[u8]) -> Result<(String, String, String)> {
692 // Handshake format: <pstrlen><pstr><reserved><info_hash><peer_id>
693 // pstrlen = 19, pstr = "BitTorrent protocol"
694
695 if data.len() < 68 {
696 return Err(anyhow::anyhow!("Handshake too short"));
697 }
698
699 let pstrlen = data[0] as usize;
700 if pstrlen != 19 {
701 return Err(anyhow::anyhow!("Invalid pstrlen"));
702 }
703
704 let pstr = &data[1..20];
705 if pstr != b"BitTorrent protocol" {
706 return Err(anyhow::anyhow!("Invalid protocol string"));
707 }
708
709 // reserved = 8 bytes (bytes 20-27)
710 let info_hash = hex::encode(&data[28..48]);
711 let peer_id = String::from_utf8_lossy(&data[48..68]).to_string();
712 let peer_id_hex = hex::encode(&data[48..68]);
713
714 Ok((info_hash, peer_id, peer_id_hex))
715 }
716
717 fn parse_message(data: &[u8]) -> Result<(String, serde_json::Value)> {
718 if data.is_empty() {
719 return Ok((
720 "keepalive".to_string(),
721 serde_json::json!({"message_type": "keepalive"}),
722 ));
723 }
724
725 if data.len() < 4 {
726 return Err(anyhow::anyhow!("Message too short"));
727 }
728
729 let length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
730
731 if length == 0 {
732 return Ok((
733 "keepalive".to_string(),
734 serde_json::json!({"message_type": "keepalive"}),
735 ));
736 }
737
738 if data.len() < 4 + length {
739 return Err(anyhow::anyhow!("Incomplete message"));
740 }
741
742 let message_id = data[4];
743 let payload = &data[5..4 + length];
744
745 let (message_type, message_data) = match message_id {
746 0 => ("choke", serde_json::json!({})),
747 1 => ("unchoke", serde_json::json!({})),
748 2 => ("interested", serde_json::json!({})),
749 3 => ("not_interested", serde_json::json!({})),
750 4 => {
751 if payload.len() >= 4 {
752 let piece_index =
753 u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
754 ("have", serde_json::json!({"piece_index": piece_index}))
755 } else {
756 ("have", serde_json::json!({}))
757 }
758 }
759 5 => {
760 // Bitfield message
761 (
762 "bitfield",
763 serde_json::json!({"bitfield": hex::encode(payload)}),
764 )
765 }
766 6 => {
767 if payload.len() >= 12 {
768 let index =
769 u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
770 let begin =
771 u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
772 let length =
773 u32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]);
774 (
775 "request",
776 serde_json::json!({"index": index, "begin": begin, "length": length}),
777 )
778 } else {
779 ("request", serde_json::json!({}))
780 }
781 }
782 7 => {
783 if payload.len() >= 8 {
784 let index =
785 u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
786 let begin =
787 u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
788 let block = hex::encode(&payload[8..]);
789 (
790 "piece",
791 serde_json::json!({"index": index, "begin": begin, "block_hex": block}),
792 )
793 } else {
794 ("piece", serde_json::json!({}))
795 }
796 }
797 8 => {
798 if payload.len() >= 12 {
799 let index =
800 u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
801 let begin =
802 u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
803 let length =
804 u32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]);
805 (
806 "cancel",
807 serde_json::json!({"index": index, "begin": begin, "length": length}),
808 )
809 } else {
810 ("cancel", serde_json::json!({}))
811 }
812 }
813 _ => (
814 "unknown",
815 serde_json::json!({"id": message_id, "payload_hex": hex::encode(payload)}),
816 ),
817 };
818
819 // Several message types share one event, so the event payload has to say which
820 // one actually arrived; without it a handler on peer_choke_message cannot tell a
821 // choke from an "interested".
822 let mut message_data = message_data;
823 if let Some(obj) = message_data.as_object_mut() {
824 obj.insert("message_type".to_string(), serde_json::json!(message_type));
825 }
826
827 Ok((message_type.to_string(), message_data))
828 }
829}