diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3ff2089 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - run: rustup update + - run: cargo update + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets + - run: cargo build --verbose + - run: cargo test --verbose \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..869df07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..749e163 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "btracker-scrape" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "Shared BitTorrent scrape API for the βtracker project components" +keywords = ["btracker", "bittorrent", "scrape", "udp", "tcp"] +categories = ["network-programming"] +repository = "https://github.com/YGGverse/btracker-scrape" + +[dependencies] +rand = "0.9.2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7865f1 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# btracker-scrape + +![Build](https://github.com/YGGverse/btracker-scrape/actions/workflows/build.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/YGGverse/btracker-scrape/status.svg)](https://deps.rs/repo/github/YGGverse/btracker-scrape) +[![crates.io](https://img.shields.io/crates/v/btracker-scrape.svg)](https://crates.io/crates/btracker-scrape) + +Shared BitTorrent scrape API for the βtracker project components + +## Install + +``` bash +cargo add btracker-scrape +``` \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fc8aca3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,36 @@ +mod udp; + +use std::net::SocketAddr; +use udp::Udp; + +#[derive(Default)] +pub struct Result { + pub leechers: u32, + pub peers: u32, + pub seeders: u32, +} + +pub struct Scrape { + udp: Option, + // tcp: @TODO +} + +impl Scrape { + pub fn init(udp: Option<(Vec, Vec)>) -> Self { + Self { + udp: udp.map(|(local, remote)| Udp::init(local, remote)), + } + } + + pub fn scrape(&self, info_hash: [u8; 20]) -> Option { + self.udp.as_ref()?; + let mut t = Result::default(); + if let Some(ref u) = self.udp { + let r = u.scrape(info_hash).ok()?; // @TODO handle + t.leechers += r.leechers; + t.peers += r.peers; + t.seeders += r.seeders; + } + Some(t) + } +} diff --git a/src/udp.rs b/src/udp.rs new file mode 100644 index 0000000..8ee6963 --- /dev/null +++ b/src/udp.rs @@ -0,0 +1,94 @@ +use rand::Rng; +use std::{ + io::Error, + net::{SocketAddr, UdpSocket}, + time::Duration, +}; + +struct Route { + socket: UdpSocket, + remote: Vec, +} + +pub struct Udp(Vec); + +impl Udp { + pub fn init(local: Vec, remote: Vec) -> Self { + Self( + local + .into_iter() + .map(|l| { + let socket = UdpSocket::bind(l).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + Route { + socket, + remote: if l.is_ipv4() { + remote.iter().filter(|r| r.is_ipv4()).cloned().collect() + } else { + remote.iter().filter(|r| r.is_ipv6()).cloned().collect() + }, + } + }) + .collect(), + ) + } + + pub fn scrape(&self, info_hash: [u8; 20]) -> Result { + let mut t = super::Result::default(); + for route in &self.0 { + for remote in &route.remote { + route.socket.send_to(&connection_request(), remote)?; + + let mut b = [0u8; 16]; + if route.socket.recv(&mut b)? < 16 { + todo!() + } + route.socket.send_to( + &scrape_request( + u64::from_be_bytes(b[8..16].try_into().unwrap()), + rand::rng().random::(), + &[info_hash], + ), + remote, + )?; + + let mut b = [0u8; 1024]; + let l = route.socket.recv(&mut b)?; + if l < 20 { + todo!() + } + + t.seeders += u32::from_be_bytes(b[8..12].try_into().unwrap()); + t.leechers += u32::from_be_bytes(b[12..16].try_into().unwrap()); + t.peers += u32::from_be_bytes(b[16..20].try_into().unwrap()); + } + } + Ok(t) + } +} + +fn connection_request() -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&0x41727101980u64.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); + b.extend_from_slice(&rand::rng().random::().to_be_bytes()); + b +} + +fn scrape_request(connection_id: u64, transaction_id: u32, info_hashes: &[[u8; 20]]) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&connection_id.to_be_bytes()); + b.extend_from_slice(&2u32.to_be_bytes()); + b.extend_from_slice(&transaction_id.to_be_bytes()); + // * up to about 74 torrents can be scraped at once + // https://www.bittorrent.org/beps/bep_0015.html + if info_hashes.len() > 74 { + todo!() + } + for hash in info_hashes { + b.extend_from_slice(hash); + } + b +}