#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "M175: HTTP tracker — BEP 7/12 wire format uses signed-i64 for counters; field widths fixed by spec"
)]
use std::collections::HashMap;
use std::fmt::Write;
use serde::Deserialize;
use irontide_core::Id20;
use crate::compact::{parse_compact_peers, parse_compact_peers6};
use crate::error::{Error, Result};
use crate::{AnnounceEvent, AnnounceRequest, AnnounceResponse, ScrapeInfo};
#[derive(Clone)]
pub struct HttpTracker {
client: reqwest::Client,
}
#[derive(Debug, Clone)]
pub struct HttpAnnounceResponse {
pub response: AnnounceResponse,
pub tracker_id: Option<String>,
pub warning: Option<String>,
pub min_interval: Option<u32>,
}
#[derive(Deserialize)]
struct RawHttpResponse {
interval: u32,
#[serde(default)]
complete: Option<u32>,
#[serde(default)]
incomplete: Option<u32>,
#[serde(with = "serde_bytes")]
peers: Vec<u8>,
#[serde(with = "serde_bytes", default)]
peers6: Vec<u8>,
#[serde(default, rename = "failure reason")]
failure_reason: Option<String>,
#[serde(default, rename = "warning message")]
warning_message: Option<String>,
#[serde(default, rename = "tracker id")]
tracker_id: Option<String>,
#[serde(default, rename = "min interval")]
min_interval: Option<u32>,
#[serde(default, rename = "retry in")]
retry_in: Option<u32>,
}
impl HttpTracker {
#[must_use]
pub fn new() -> Self {
Self {
client: reqwest::Client::builder()
.user_agent("Torrent/0.60.0")
.build()
.expect("failed to build HTTP client"),
}
}
#[must_use]
pub fn with_anonymous() -> Self {
Self {
client: reqwest::Client::builder()
.user_agent("")
.build()
.expect("failed to build HTTP client"),
}
}
#[must_use]
pub fn with_proxy(proxy_url: Option<&str>) -> Self {
let mut builder = reqwest::Client::builder().user_agent("Torrent/0.60.0");
if let Some(url) = proxy_url
&& let Ok(proxy) = reqwest::Proxy::all(url)
{
builder = builder.proxy(proxy);
}
Self {
client: builder.build().expect("failed to build HTTP client"),
}
}
#[must_use]
pub fn with_security(
proxy_url: Option<&str>,
validate_tls: bool,
ssrf_mitigation: bool,
) -> Self {
let mut builder = reqwest::Client::builder().user_agent("Torrent/0.60.0");
if ssrf_mitigation {
let policy = reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 10 {
return attempt.error(std::io::Error::other("too many redirects"));
}
let original = &attempt.previous()[0];
let redirect = attempt.url();
let orig_local = match original.host() {
Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(d)) => d == "localhost",
None => false,
};
if !orig_local {
let redirect_local = match redirect.host() {
Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(d)) => d == "localhost",
None => false,
};
if redirect_local {
return attempt.error(std::io::Error::other(
"redirect from public to private IP blocked (SSRF)",
));
}
}
attempt.follow()
});
builder = builder.redirect(policy);
}
if !validate_tls {
builder = builder.danger_accept_invalid_certs(true);
}
if let Some(url) = proxy_url
&& let Ok(proxy) = reqwest::Proxy::all(url)
{
builder = builder.proxy(proxy);
}
Self {
client: builder.build().expect("failed to build HTTP client"),
}
}
pub fn build_announce_url(base_url: &str, req: &AnnounceRequest) -> Result<String> {
let mut url = base_url.to_string();
let info_hash_encoded = url_encode_bytes(req.info_hash.as_bytes());
let peer_id_encoded = url_encode_bytes(req.peer_id.as_bytes());
let separator = if url.contains('?') { '&' } else { '?' };
url.push(separator);
let _ = write!(
url,
"info_hash={info_hash_encoded}&peer_id={peer_id_encoded}&port={}&uploaded={}&downloaded={}&left={}&compact=1",
req.port, req.uploaded, req.downloaded, req.left
);
match req.event {
AnnounceEvent::None => {}
AnnounceEvent::Started => url.push_str("&event=started"),
AnnounceEvent::Completed => url.push_str("&event=completed"),
AnnounceEvent::Stopped => url.push_str("&event=stopped"),
}
if let Some(n) = req.num_want {
let _ = write!(url, "&numwant={n}");
}
if let Some(ref dest) = req.i2p_destination {
url.push_str("&i2p=");
url.push_str(dest.trim_end_matches('='));
}
Ok(url)
}
pub async fn announce(
&self,
base_url: &str,
req: &AnnounceRequest,
) -> Result<HttpAnnounceResponse> {
let url = Self::build_announce_url(base_url, req)?;
let response = self.client.get(&url).send().await?.bytes().await?;
let raw: RawHttpResponse = irontide_bencode::from_bytes(&response)?;
if let Some(failure) = raw.failure_reason {
return Err(Error::TrackerError {
message: failure,
retry_in: raw.retry_in,
});
}
let interval = raw.interval.max(raw.min_interval.unwrap_or(0));
let mut peers = parse_compact_peers(&raw.peers)?;
if let Ok(peers6) = parse_compact_peers6(&raw.peers6) {
peers.extend(peers6);
}
Ok(HttpAnnounceResponse {
response: AnnounceResponse {
interval,
seeders: raw.complete,
leechers: raw.incomplete,
peers,
},
tracker_id: raw.tracker_id,
warning: raw.warning_message,
min_interval: raw.min_interval,
})
}
}
#[derive(Debug, Clone)]
pub struct HttpScrapeResponse {
pub files: HashMap<Id20, ScrapeInfo>,
}
impl HttpTracker {
pub fn build_scrape_url(announce_url: &str, info_hashes: &[Id20]) -> Result<String> {
let base = crate::announce_url_to_scrape(announce_url)
.ok_or_else(|| Error::InvalidUrl("no 'announce' in URL to convert to scrape".into()))?;
let mut url = base;
for (i, hash) in info_hashes.iter().enumerate() {
let encoded = url_encode_bytes(hash.as_bytes());
url.push(if i == 0 { '?' } else { '&' });
url.push_str("info_hash=");
url.push_str(&encoded);
}
Ok(url)
}
pub async fn scrape(
&self,
announce_url: &str,
info_hashes: &[Id20],
) -> Result<HttpScrapeResponse> {
let url = Self::build_scrape_url(announce_url, info_hashes)?;
let response = self.client.get(&url).send().await?.bytes().await?;
let value: irontide_bencode::BencodeValue = irontide_bencode::from_bytes(&response)?;
let root = value
.as_dict()
.ok_or_else(|| Error::InvalidResponse("scrape response is not a dict".into()))?;
let files_val = root
.get(b"files".as_slice())
.and_then(|v| v.as_dict())
.ok_or_else(|| Error::InvalidResponse("scrape response missing 'files' dict".into()))?;
let mut files = HashMap::new();
for (key, val) in files_val {
if key.len() != 20 {
continue;
}
let hash = Id20::from_bytes(key).map_err(|_| {
Error::InvalidResponse("invalid info_hash in scrape response".into())
})?;
let entry = val
.as_dict()
.ok_or_else(|| Error::InvalidResponse("scrape file entry is not a dict".into()))?;
let complete = entry
.get(b"complete".as_slice())
.and_then(irontide_bencode::BencodeValue::as_int)
.unwrap_or(0) as u32;
let incomplete = entry
.get(b"incomplete".as_slice())
.and_then(irontide_bencode::BencodeValue::as_int)
.unwrap_or(0) as u32;
let downloaded = entry
.get(b"downloaded".as_slice())
.and_then(irontide_bencode::BencodeValue::as_int)
.unwrap_or(0) as u32;
files.insert(
hash,
ScrapeInfo {
complete,
incomplete,
downloaded,
},
);
}
Ok(HttpScrapeResponse { files })
}
}
impl Default for HttpTracker {
fn default() -> Self {
Self::new()
}
}
fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
ip.is_loopback() || ip.is_private() || ip.is_link_local()
}
fn url_encode_bytes(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len() * 3);
for &b in bytes {
match b {
b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'-' | b'_' | b'~' => {
encoded.push(b as char);
}
_ => {
let _ = write!(encoded, "%{b:02X}");
}
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
use irontide_core::Id20;
#[test]
fn build_announce_url_basic() {
let req = AnnounceRequest {
info_hash: Id20::ZERO,
peer_id: Id20::ZERO,
port: 6881,
uploaded: 0,
downloaded: 0,
left: 1000,
event: AnnounceEvent::Started,
num_want: Some(50),
compact: true,
i2p_destination: None,
};
let url =
HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
assert!(url.starts_with("http://tracker.example.com/announce?"));
assert!(url.contains("info_hash="));
assert!(url.contains("port=6881"));
assert!(url.contains("event=started"));
assert!(url.contains("numwant=50"));
assert!(url.contains("compact=1"));
}
#[test]
fn build_scrape_url_basic() {
let hash = Id20::ZERO;
let url =
HttpTracker::build_scrape_url("http://tracker.example.com/announce", &[hash]).unwrap();
assert!(url.starts_with("http://tracker.example.com/scrape?info_hash="));
}
#[test]
fn build_scrape_url_no_announce_in_url() {
let hash = Id20::ZERO;
let result = HttpTracker::build_scrape_url("http://tracker.example.com/track", &[hash]);
assert!(result.is_err());
}
#[test]
fn url_encode_bytes_simple() {
assert_eq!(url_encode_bytes(b"abc"), "abc");
assert_eq!(url_encode_bytes(&[0xFF, 0x00]), "%FF%00");
}
#[test]
fn url_encode_preserves_unreserved() {
let unreserved = b"abcXYZ019.-_~";
let encoded = url_encode_bytes(unreserved);
assert_eq!(encoded, "abcXYZ019.-_~");
}
#[test]
fn parse_response_with_peers6() {
use std::net::Ipv6Addr;
let mut peers = Vec::new();
peers.extend_from_slice(&[192, 168, 1, 1, 0x1A, 0xE1]);
let ip6: Ipv6Addr = "2001:db8::1".parse().unwrap();
let mut peers6 = Vec::new();
peers6.extend_from_slice(&ip6.octets());
peers6.extend_from_slice(&8080u16.to_be_bytes());
let raw = RawHttpResponse {
interval: 1800,
complete: Some(10),
incomplete: Some(5),
peers,
peers6,
failure_reason: None,
warning_message: None,
tracker_id: None,
min_interval: None,
retry_in: None,
};
let mut result = parse_compact_peers(&raw.peers).unwrap();
if !raw.peers6.is_empty()
&& let Ok(v6) = parse_compact_peers6(&raw.peers6)
{
result.extend(v6);
}
assert_eq!(result.len(), 2);
assert_eq!(result[0].to_string(), "192.168.1.1:6881");
assert_eq!(
result[1],
"[2001:db8::1]:8080"
.parse::<std::net::SocketAddr>()
.unwrap()
);
}
#[test]
fn http_tracker_anonymous_builds() {
let tracker = HttpTracker::with_anonymous();
drop(tracker);
}
#[test]
fn http_tracker_with_security_builds() {
let tracker = HttpTracker::with_security(None, true, true);
drop(tracker);
}
#[test]
fn http_tracker_with_security_no_tls_validation() {
let tracker = HttpTracker::with_security(None, false, false);
drop(tracker);
}
#[test]
fn build_announce_url_includes_i2p_destination() {
let req = AnnounceRequest {
info_hash: Id20::ZERO,
peer_id: Id20::ZERO,
port: 6881,
uploaded: 0,
downloaded: 0,
left: 1000,
event: AnnounceEvent::None,
num_want: None,
compact: true,
i2p_destination: Some("AAAA-BBB~CCC==".into()),
};
let url =
HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
assert!(
url.contains("&i2p=AAAA-BBB~CCC"),
"URL should contain I2P destination with padding stripped: {url}"
);
let i2p_start = url.find("&i2p=").unwrap() + 5;
let i2p_value = &url[i2p_start..];
assert!(
!i2p_value.contains('='),
"I2P destination should not contain '=' padding in URL: {i2p_value}"
);
}
#[test]
fn build_announce_url_omits_i2p_when_none() {
let req = AnnounceRequest {
info_hash: Id20::ZERO,
peer_id: Id20::ZERO,
port: 6881,
uploaded: 0,
downloaded: 0,
left: 1000,
event: AnnounceEvent::None,
num_want: None,
compact: true,
i2p_destination: None,
};
let url =
HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
assert!(
!url.contains("&i2p="),
"URL should not contain &i2p= when None: {url}"
);
}
#[test]
fn deserialize_response_with_min_interval() {
let raw = RawHttpResponse {
interval: 900,
complete: Some(10),
incomplete: Some(5),
peers: vec![192, 168, 1, 1, 0x1A, 0xE1],
peers6: Vec::new(),
failure_reason: None,
warning_message: None,
tracker_id: None,
min_interval: Some(1800),
retry_in: None,
};
assert_eq!(raw.min_interval, Some(1800));
assert_eq!(raw.interval.max(raw.min_interval.unwrap_or(0)), 1800);
}
#[test]
fn deserialize_response_with_retry_in() {
let raw = RawHttpResponse {
interval: 900,
complete: None,
incomplete: None,
peers: Vec::new(),
peers6: Vec::new(),
failure_reason: Some("rate limited".into()),
warning_message: None,
tracker_id: None,
min_interval: None,
retry_in: Some(120),
};
assert_eq!(raw.retry_in, Some(120));
assert_eq!(raw.failure_reason.as_deref(), Some("rate limited"));
}
#[test]
fn tracker_error_carries_retry_in() {
let err = Error::TrackerError {
message: "rate limited".into(),
retry_in: Some(60),
};
assert_eq!(err.to_string(), "tracker returned error: rate limited");
if let Error::TrackerError { retry_in, .. } = &err {
assert_eq!(*retry_in, Some(60));
} else {
panic!("wrong variant");
}
}
}