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/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..3dbc639 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,27 @@ +name: Linux + +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 + - name: Run rustfmt + run: cargo fmt --all -- --check + - name: Run clippy + run: cargo clippy --all-targets + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose 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..c0c0a74 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "yps" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "YPS - Yggdrasil Port Scanner" +keywords = ["yggdrasil", "tcp", "udp", "search", "crawler"] +categories = ["network-programming"] +repository = "https://github.com/YGGverse/yps" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..338dd11 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# YPS - Yggdrasil Port Scanner + +![Linux](https://github.com/yggverse/yps/actions/workflows/linux.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/yggverse/yps/status.svg)](https://deps.rs/repo/github/yggverse/yps) +[![crates.io](https://img.shields.io/crates/v/yps)](https://crates.io/crates/yps) + +A simple crawler tool to find open ports for specific services, +such as web servers or Bitcoin nodes, to connect with [Yggdrasil](https://yggdrasil-network.github.io/). + +## Install + +* `git clone https://github.com/yggverse/yps.git && cd yps` +* `cargo build --release` +* `sudo install target/release/yps /usr/local/bin/yps` + +## Usage + +``` bash +sudo yps --tcp -p 80 -p 443 +``` + +### Options + +``` bash +-d, --debug + Print debug information +-s, --socket + Yggdrasil socket path (unix only) [default: /var/run/yggdrasil.sock] +-p, --port + Port(s) to scan +-u, --udp + Crawl UDP ports (requires `[host]:port` binding as the value) +-t, --tcp + Crawl TCP ports +-l, --latency + Pause in seconds before make new request + --index-capacity + Optimize memory usage by providing expecting index capacity, according to the current network size [default: 1000] +-h, --help + Print help +-V, --version + Print version +``` \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..86d8d5b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,34 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Config { + /// Print debug information + #[arg(short, long, default_value_t = false)] + pub debug: bool, + + /// Yggdrasil socket path (unix only) + #[arg(short, long, default_value_t = String::from("/var/run/yggdrasil.sock"))] + pub socket: String, + + /// Port(s) to scan + #[arg(short, long)] + pub port: Vec, + + /// Crawl UDP ports (requires `[host]:port` binding as the value) + #[arg(short, long)] + pub udp: Option, + + /// Crawl TCP ports + #[arg(short, long, default_value_t = false)] + pub tcp: bool, + + /// Pause in seconds before make new request + #[arg(short, long)] + pub latency: Option, + + /// Optimize memory usage by providing expecting index capacity, + /// according to the current network size + #[arg(long, default_value_t = 1000)] + pub index_capacity: usize, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c84d851 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,101 @@ +mod config; +mod udp; +mod yggdrasil; + +use anyhow::Result; +use config::Config; +use std::{ + net::{IpAddr, SocketAddr, TcpStream}, + time::Duration, +}; +use udp::Udp; +use yggdrasil::Yggdrasil; + +fn main() -> Result<()> { + use clap::Parser; + let config = Config::parse(); + if config.port.is_empty() { + panic!("at least one port must be specified for scan!") + } + if !config.tcp && config.udp.is_none() { + panic!("at least one TCP or UDP protocol is required for scan!") + } + let latency = config.latency.map(Duration::from_secs); // parse once + let mut ygg = Yggdrasil::init(&config.socket)?; + let mut tcp: Option> = if config.tcp { + Some(Vec::with_capacity(config.index_capacity)) + } else { + None + }; + let mut udp = config.udp.as_ref().map(|bind| { + let server = Udp::init(bind).unwrap(); + let index: Vec = Vec::with_capacity(config.index_capacity); + (index, server) + }); + // get initial peers to crawl + if config.debug { + println!("get initial peers to crawl..."); + } + let p = ygg.peers()?; + if p.status != "success" { + todo!() + } + // start crawler + for peer in p.response.unwrap().peers { + crawl(peer.key, latency, &config, &mut ygg, &mut tcp, &mut udp)?; + } + Ok(()) +} + +fn crawl( + key: String, + latency: Option, + config: &Config, + ygg: &mut Yggdrasil, + tcp: &mut Option>, + udp: &mut Option<(Vec, Udp)>, +) -> Result<()> { + if config.debug { + println!("get peers for `{}`...", &key); + } + let p = ygg.remote_peers(key)?; + if p.status != "success" { + todo!() + } + for (host, peers) in p.response.unwrap() { + for port in &config.port { + let address = SocketAddr::new(IpAddr::V6(host), *port); + if let Some(index) = tcp { + if !index.contains(&address) { + let url = format!("tcp://{address}"); + if config.debug { + println!("try `{url}`..."); + } + if TcpStream::connect_timeout(&address, Duration::from_secs(1)).is_ok() { + println!("{url}"); + index.push(address) + } + } + } + if let Some((index, server)) = udp { + let url = format!("udp://{address}"); + if !index.contains(&address) { + if config.debug { + println!("try `{url}`..."); + } + if server.check(address) { + println!("{url}"); + index.push(address) + } + } + } + } + for k in peers.keys { + if let Some(l) = latency { + std::thread::sleep(l); + } + crawl(k, latency, config, ygg, tcp, udp)?; + } + } + Ok(()) +} diff --git a/src/udp.rs b/src/udp.rs new file mode 100644 index 0000000..2ffe263 --- /dev/null +++ b/src/udp.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use std::{ + net::{SocketAddr, SocketAddrV6, UdpSocket}, + str::FromStr, + time::Duration, +}; + +/// Simple UDP server to test peers connection +pub struct Udp(UdpSocket); + +impl Udp { + pub fn init(bind: &str) -> Result { + const T: u64 = 1; // @TODO optional + + let this = UdpSocket::bind(SocketAddrV6::from_str(bind)?)?; + this.set_write_timeout(Some(Duration::from_secs(T)))?; + this.set_read_timeout(Some(Duration::from_secs(T)))?; + Ok(Self(this)) + } + + pub fn check(&self, remote: SocketAddr) -> bool { + if self.0.connect(remote).is_err() { + return false; + } + match self.0.send_to(b"test", remote) { + Ok(_) => { + let mut b = [0; 1]; + self.0.recv_from(&mut b).is_ok() // @TODO + } + Err(_) => false, + } + } +} diff --git a/src/yggdrasil.rs b/src/yggdrasil.rs new file mode 100644 index 0000000..89bab8b --- /dev/null +++ b/src/yggdrasil.rs @@ -0,0 +1,50 @@ +mod api; +use api::*; + +use anyhow::Result; +use std::{ + collections::HashMap, + io::{Read, Write}, + net::Ipv6Addr, + os::unix::net::UnixStream, +}; + +pub struct Yggdrasil(UnixStream); + +impl Yggdrasil { + pub fn init(path: &str) -> Result { + Ok(Self(UnixStream::connect(path)?)) + } + + pub fn peers(&mut self) -> Result> { + let r: Response = serde_json::from_slice( + &self.request(b"{\"keepalive\":true,\"request\":\"getpeers\"}")?, + )?; + Ok(r) + } + + pub fn remote_peers( + &mut self, + public_key: String, + ) -> Result>> { + let r: Response> = serde_json::from_slice(&self.request( + format!("{{\"keepalive\":true,\"request\":\"debug_remotegetpeers\",\"arguments\":{{\"key\":\"{public_key}\"}}}}") + .as_bytes(), + )?)?; + Ok(r) + } + + fn request(&mut self, bytes: &[u8]) -> Result> { + const L: usize = 1024; // chunk len + self.0.write_all(bytes)?; + let mut r = Vec::with_capacity(L); + loop { + let mut b = vec![0; L]; + let l = self.0.read(&mut b)?; // can't `read_to_end` as no EOF + r.extend(&b[..l]); + if l < L { + return Ok(r); + } + } + } +} diff --git a/src/yggdrasil/api.rs b/src/yggdrasil/api.rs new file mode 100644 index 0000000..e1bcbfb --- /dev/null +++ b/src/yggdrasil/api.rs @@ -0,0 +1,41 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct Response { + pub response: Option, + pub status: String, +} + +#[derive(Deserialize, Debug)] +pub struct LocalPeers { + pub peers: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct LocalPeer { + pub key: String, + // pub address: std::net::Ipv6Addr, + // pub bytes_recvd: Option, + // pub bytes_sent: Option, + // pub inbound: bool, + // #[serde(default, deserialize_with = "time")] + // pub last_error_time: Option, + // pub last_error: Option, + // #[serde(default, deserialize_with = "time")] + // pub latency: Option, + // pub port: u64, + // pub priority: Option, + // pub remote: Option, + // pub up: bool, + // pub uptime: Option, +} + +#[derive(Deserialize, Debug)] +pub struct RemotePeers { + pub keys: Vec, +} + +// use std::time::Duration; +// fn time<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { +// u64::deserialize(d).map(|t| Some(Duration::from_nanos(t))) +// }