Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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}"
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "libs/sectools-common/**"
- "tools/subnet-scanner/**"
- ".github/workflows/rust.yml"
pull_request:
branches: [main]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "libs/sectools-common/**"
- "tools/subnet-scanner/**"
- ".github/workflows/rust.yml"

permissions:
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"libs/sectools-common",
"tools/subnet-scanner",
]

[workspace.package]
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==="; \
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -142,6 +159,7 @@ Tags follow the convention `<tool>/v<major>.<minor>.<patch>`:
|-------------|--------|
| `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.

Expand All @@ -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 `<tool>/v*` | Cross-compile, checksum, GitHub Release |

Expand Down
17 changes: 17 additions & 0 deletions tools/subnet-scanner/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
1 change: 1 addition & 0 deletions tools/subnet-scanner/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.0
226 changes: 226 additions & 0 deletions tools/subnet-scanner/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// Expand a target string (single IP or CIDR) into a list of host addresses.
fn expand_cidr(cidr: &str) -> Result<Vec<IpAddr>, 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::<String>(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());
}
}
Loading