diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ed46da1..50d2229 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -6,7 +6,8 @@ on: paths: - "go.mod" - "go.sum" - - "tools/**" + - "tools/banner-grabber/**" + - "tools/port-knocking-scanner/**" - "libs/netutil/**" - ".github/workflows/go.yml" - ".golangci.yml" @@ -15,7 +16,8 @@ on: paths: - "go.mod" - "go.sum" - - "tools/**" + - "tools/banner-grabber/**" + - "tools/port-knocking-scanner/**" - "libs/netutil/**" - ".github/workflows/go.yml" - ".golangci.yml" @@ -81,6 +83,8 @@ jobs: - name: Build all commands run: | for tool in tools/*/; do + # Skip Rust tools (identified by Cargo.toml) + [ -f "${tool}/Cargo.toml" ] && continue TOOL_NAME=$(basename "$tool") echo "=== Building $TOOL_NAME ===" CGO_ENABLED=1 go build -ldflags="-s -w" -o "bin/${TOOL_NAME}" "./${tool}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5e0cb8..0bfbc20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,9 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" # Detect language by checking where the tool lives - if [ -d "tools/$TOOL" ]; then + if [ -d "tools/$TOOL" ] && [ -f "tools/$TOOL/Cargo.toml" ]; then + echo "lang=rust" >> "$GITHUB_OUTPUT" + elif [ -d "tools/$TOOL" ]; then echo "lang=go" >> "$GITHUB_OUTPUT" elif [ -d "libs/$TOOL" ]; then echo "lang=rust" >> "$GITHUB_OUTPUT" @@ -51,6 +53,8 @@ jobs: if [ "$LANG" = "go" ]; then VERSION_FILE="tools/${TOOL}/VERSION" + elif [ -f "tools/${TOOL}/VERSION" ]; then + VERSION_FILE="tools/${TOOL}/VERSION" else VERSION_FILE="libs/${TOOL}/VERSION" fi diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 7359e17..1038e86 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -7,6 +7,7 @@ on: - "Cargo.toml" - "Cargo.lock" - "libs/sectools-common/**" + - "tools/subnet-scanner/**" - ".github/workflows/rust.yml" pull_request: branches: [main] @@ -14,6 +15,7 @@ on: - "Cargo.toml" - "Cargo.lock" - "libs/sectools-common/**" + - "tools/subnet-scanner/**" - ".github/workflows/rust.yml" permissions: diff --git a/Cargo.toml b/Cargo.toml index c1e5059..c125abd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "libs/sectools-common", + "tools/subnet-scanner", ] [workspace.package] diff --git a/Makefile b/Makefile index 3a902b9..45f13a8 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ test-go: ## Run Go tests with race detector build-go: ## Build all Go tools (version from VERSION file) @for tool in tools/*/; do \ name=$$(basename "$$tool"); \ + [ -f "$$tool/Cargo.toml" ] && continue; \ ver="dev"; \ if [ -f "$$tool/VERSION" ]; then ver=$$(cat "$$tool/VERSION" | tr -d '\n'); fi; \ echo "=== Building $$name v$$ver ==="; \ diff --git a/README.md b/README.md index 3db4e23..ec9a0a6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Each tool is independently versioned and released as a standalone binary. |------|----------|-------------| | **banner-grabber** | Go | TCP banner grabbing — probes open ports and captures service banners | | **port-knocking-scanner** | Go | Detects port-knocking sequences using raw packet capture (gopacket/pcap) | +| **subnet-scanner** | Rust | Fast async TCP port scanner for hosts and CIDR subnets (tokio-powered) | | **sectools-common** | Rust | Shared library with network utilities (IP validation, port parsing, banner grab) | ## Project Structure @@ -24,7 +25,8 @@ Each tool is independently versioned and released as a standalone binary. sectools/ ├── tools/ │ ├── banner-grabber/ # Go CLI tool -│ └── port-knocking-scanner/ # Go CLI tool +│ ├── port-knocking-scanner/ # Go CLI tool +│ └── subnet-scanner/ # Rust CLI tool ├── libs/ │ ├── netutil/ # Shared Go library │ └── sectools-common/ # Shared Rust library @@ -49,6 +51,12 @@ go install github.com/flaviomilan/sectools/tools/banner-grabber@latest go install github.com/flaviomilan/sectools/tools/port-knocking-scanner@latest ``` +### From source (Rust tools) + +```bash +cargo install --git https://github.com/flaviomilan/sectools -p subnet-scanner +``` + ### Pre-built binaries Download from [Releases](https://github.com/flaviomilan/sectools/releases). @@ -90,6 +98,15 @@ sudo port-knocking-scanner -target 10.0.0.1 -ports 7000,8000,9000 -timeout 10s port-knocking-scanner -version ``` +### subnet-scanner + +```bash +subnet-scanner --target 192.168.1.1 --ports 22,80,443 +subnet-scanner --target 10.0.0.0/24 --ports 22,80,443 --concurrency 1000 +subnet-scanner --target 172.16.0.0/16 --timeout 500 --output results.txt +subnet-scanner --version +``` + ## Development ### Prerequisites @@ -142,6 +159,7 @@ Tags follow the convention `/v..`: |-------------|--------| | `banner-grabber/v1.0.0` | Releases banner-grabber v1.0.0 | | `port-knocking-scanner/v0.2.0` | Releases port-knocking-scanner v0.2.0 | +| `subnet-scanner/v0.1.0` | Releases subnet-scanner v0.1.0 | Each tool's version is fully independent — releasing one tool does **not** affect others. @@ -150,7 +168,7 @@ Each tool's version is fully independent — releasing one tool does **not** aff | Workflow | Trigger | What it does | |----------|---------|--------------| | **Go** | Push/PR touching `tools/`, `libs/netutil/`, `go.mod` | golangci-lint → tests (race + coverage) → build | -| **Rust** | Push/PR touching `libs/sectools-common/`, `Cargo.toml` | clippy + fmt → tests → release build | +| **Rust** | Push/PR touching `libs/sectools-common/`, `tools/subnet-scanner/`, `Cargo.toml` | clippy + fmt → tests → release build | | **Security** | Push/PR to main + weekly cron | govulncheck, cargo-audit, Trivy, CodeQL | | **Release** | Tag `/v*` | Cross-compile, checksum, GitHub Release | diff --git a/tools/subnet-scanner/Cargo.toml b/tools/subnet-scanner/Cargo.toml new file mode 100644 index 0000000..1a6f7e7 --- /dev/null +++ b/tools/subnet-scanner/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "subnet-scanner" +description = "Fast asynchronous TCP port scanner for hosts and subnets" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[[bin]] +name = "subnet-scanner" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +sectools-common = { path = "../../libs/sectools-common" } +tokio = { version = "1", features = ["full"] } diff --git a/tools/subnet-scanner/VERSION b/tools/subnet-scanner/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/tools/subnet-scanner/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/tools/subnet-scanner/src/main.rs b/tools/subnet-scanner/src/main.rs new file mode 100644 index 0000000..0d55526 --- /dev/null +++ b/tools/subnet-scanner/src/main.rs @@ -0,0 +1,226 @@ +//! Fast asynchronous TCP port scanner for hosts and subnets. + +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; +use sectools_common::{is_valid_ip, parse_ports}; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; +use tokio::sync::Semaphore; +use tokio::time::timeout; + +/// Fast asynchronous TCP port scanner for hosts and subnets. +#[derive(Parser)] +#[command(name = "subnet-scanner", version, about)] +struct Cli { + /// Target host or CIDR (e.g. 192.168.1.1 or 192.168.1.0/24) + #[arg(short, long)] + target: String, + + /// Comma-separated ports to scan (e.g. 22,80,443) + #[arg( + short, + long, + default_value = "21,22,23,25,53,80,110,143,443,993,995,3306,3389,5432,8080,8443" + )] + ports: String, + + /// Connection timeout in milliseconds + #[arg(long, default_value = "1000")] + timeout: u64, + + /// Maximum concurrent connections + #[arg(short, long, default_value = "500")] + concurrency: usize, + + /// Output file path (default: stdout) + #[arg(short, long)] + output: Option, +} + +/// Expand a target string (single IP or CIDR) into a list of host addresses. +fn expand_cidr(cidr: &str) -> Result, String> { + if let Some((ip_str, prefix_str)) = cidr.split_once('/') { + let ip: IpAddr = ip_str.parse().map_err(|e| format!("invalid IP: {e}"))?; + let prefix: u32 = prefix_str + .parse() + .map_err(|e| format!("invalid prefix: {e}"))?; + + match ip { + IpAddr::V4(v4) => { + if prefix > 32 { + return Err("prefix must be <= 32".to_string()); + } + if prefix < 16 { + return Err("prefix must be >= 16 to avoid scanning too many hosts".to_string()); + } + + let base = u32::from(v4); + let mask = if prefix == 32 { + u32::MAX + } else { + !((1u32 << (32 - prefix)) - 1) + }; + let network = base & mask; + let broadcast = network | !mask; + + if prefix == 32 { + Ok(vec![IpAddr::V4(v4)]) + } else { + let mut addrs = Vec::with_capacity((broadcast - network - 1) as usize); + for i in (network + 1)..broadcast { + addrs.push(IpAddr::V4(Ipv4Addr::from(i))); + } + Ok(addrs) + } + } + IpAddr::V6(_) => Err("IPv6 CIDR scanning is not supported yet".to_string()), + } + } else { + if !is_valid_ip(cidr) { + return Err(format!("invalid target: {cidr}")); + } + let ip: IpAddr = cidr.parse().map_err(|e| format!("invalid IP: {e}"))?; + Ok(vec![ip]) + } +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + let hosts = match expand_cidr(&cli.target) { + Ok(h) => h, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + let ports = match parse_ports(&cli.ports) { + Ok(p) => p, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + let total = hosts.len() * ports.len(); + eprintln!( + "Scanning {} host(s) x {} port(s) = {} probes (concurrency: {})", + hosts.len(), + ports.len(), + total, + cli.concurrency + ); + + let timeout_dur = Duration::from_millis(cli.timeout); + let sem = Arc::new(Semaphore::new(cli.concurrency)); + let (tx, mut rx) = tokio::sync::mpsc::channel::(1024); + + for host in &hosts { + for &port in &ports { + let sem = Arc::clone(&sem); + let tx = tx.clone(); + let host = *host; + + tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + let addr = format!("{host}:{port}"); + if let Ok(Ok(_)) = timeout(timeout_dur, TcpStream::connect(&addr)).await { + let _ = tx.send(format!("{host}:{port} open")).await; + } + }); + } + } + + drop(tx); + + let mut results = Vec::new(); + while let Some(line) = rx.recv().await { + results.push(line); + } + results.sort(); + + if results.is_empty() { + eprintln!("No open ports found."); + return; + } + + let output_text = results.join("\n"); + + match &cli.output { + Some(path) => { + let mut file = tokio::fs::File::create(path) + .await + .expect("cannot create output file"); + file.write_all(output_text.as_bytes()) + .await + .expect("write failed"); + file.write_all(b"\n").await.expect("write failed"); + eprintln!("Results written to {path}"); + } + None => { + println!("{output_text}"); + } + } + + eprintln!("Scan complete: {} open port(s) found.", results.len()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_cidr_single_host() { + let hosts = expand_cidr("192.168.1.1").unwrap(); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].to_string(), "192.168.1.1"); + } + + #[test] + fn test_expand_cidr_slash_32() { + let hosts = expand_cidr("10.0.0.5/32").unwrap(); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].to_string(), "10.0.0.5"); + } + + #[test] + fn test_expand_cidr_slash_24() { + let hosts = expand_cidr("192.168.1.0/24").unwrap(); + assert_eq!(hosts.len(), 254); + assert_eq!(hosts[0].to_string(), "192.168.1.1"); + assert_eq!(hosts[253].to_string(), "192.168.1.254"); + } + + #[test] + fn test_expand_cidr_slash_30() { + let hosts = expand_cidr("10.0.0.0/30").unwrap(); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].to_string(), "10.0.0.1"); + assert_eq!(hosts[1].to_string(), "10.0.0.2"); + } + + #[test] + fn test_expand_cidr_invalid_ip() { + assert!(expand_cidr("not-an-ip").is_err()); + } + + #[test] + fn test_expand_cidr_prefix_too_large() { + assert!(expand_cidr("10.0.0.0/33").is_err()); + } + + #[test] + fn test_expand_cidr_prefix_too_small() { + assert!(expand_cidr("10.0.0.0/15").is_err()); + } + + #[test] + fn test_expand_cidr_ipv6_unsupported() { + assert!(expand_cidr("::1/128").is_err()); + } +}