diff --git a/local/recipes/system/redbear-nmap/recipe.toml b/local/recipes/system/redbear-nmap/recipe.toml new file mode 100644 index 00000000..5eed952d --- /dev/null +++ b/local/recipes/system/redbear-nmap/recipe.toml @@ -0,0 +1,8 @@ +[source] +path = "source" + +[build] +template = "cargo" + +[package.files] +"/usr/bin/redbear-nmap" = "redbear-nmap" diff --git a/local/recipes/system/redbear-nmap/source/Cargo.toml b/local/recipes/system/redbear-nmap/source/Cargo.toml new file mode 100644 index 00000000..e3b2893c --- /dev/null +++ b/local/recipes/system/redbear-nmap/source/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "redbear-nmap" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-nmap" +path = "src/main.rs" diff --git a/local/recipes/system/redbear-nmap/source/src/main.rs b/local/recipes/system/redbear-nmap/source/src/main.rs new file mode 100644 index 00000000..4303ab5d --- /dev/null +++ b/local/recipes/system/redbear-nmap/source/src/main.rs @@ -0,0 +1,267 @@ +use std::env; +use std::io::{self, Read}; +use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::process; +use std::time::Duration; + +fn main() { + if let Err(err) = run() { + eprintln!("redbear-nmap: {err}"); + process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + let Some(command) = args.first() else { + return Err(usage()); + }; + + if matches!(command.as_str(), "help" | "--help" | "-h") { + println!("{}", usage()); + return Ok(()); + } + + let config = ScanConfig::parse(&args)?; + let mut targets = resolve_targets(&config.host)?; + if targets.is_empty() { + return Err(format!("no addresses resolved for {}", config.host)); + } + + targets.sort(); + targets.dedup(); + + println!( + "scan_target={} ports={}", + config.host, + format_ports(&config.ports) + ); + + for addr in targets { + println!("host {addr}"); + for port in &config.ports { + let socket_addr = SocketAddr::new(addr, *port); + let status = scan_port(socket_addr, config.timeout, config.banner_bytes); + print_status(*port, &status); + } + } + + Ok(()) +} + +fn usage() -> String { + "Usage: redbear-nmap [--timeout-ms N] [--banner-bytes N] \n\nBounded scope: TCP connect scanning with optional banner reads only.\nNot implemented: raw/SYN scans, UDP parity, OS detection, packet capture, NSE.\n\nPorts can be a comma-separated list and/or ranges, for example: 22,80,443,8000-8003".to_string() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ScanConfig { + host: String, + ports: Vec, + timeout: Duration, + banner_bytes: usize, +} + +impl ScanConfig { + fn parse(args: &[String]) -> Result { + let mut timeout_ms = 1000_u64; + let mut banner_bytes = 64_usize; + let mut positionals = Vec::new(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--timeout-ms" => { + let value = args.get(i + 1).ok_or_else(usage)?; + timeout_ms = value + .parse::() + .map_err(|err| format!("invalid --timeout-ms value {value}: {err}"))?; + i += 2; + } + "--banner-bytes" => { + let value = args.get(i + 1).ok_or_else(usage)?; + banner_bytes = value + .parse::() + .map_err(|err| format!("invalid --banner-bytes value {value}: {err}"))?; + i += 2; + } + other => { + positionals.push(other.to_string()); + i += 1; + } + } + } + + if positionals.len() != 2 { + return Err(usage()); + } + + Ok(Self { + host: positionals[0].clone(), + ports: parse_ports(&positionals[1])?, + timeout: Duration::from_millis(timeout_ms), + banner_bytes, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PortStatus { + Open { banner: Option }, + Closed, + TimedOut, + Error(String), +} + +fn scan_port(addr: SocketAddr, timeout: Duration, banner_bytes: usize) -> PortStatus { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(mut stream) => { + let _ = stream.set_read_timeout(Some(timeout)); + let banner = read_banner(&mut stream, banner_bytes); + PortStatus::Open { banner } + } + Err(err) => match err.kind() { + io::ErrorKind::ConnectionRefused => PortStatus::Closed, + io::ErrorKind::TimedOut => PortStatus::TimedOut, + _ => PortStatus::Error(err.to_string()), + }, + } +} + +fn read_banner(stream: &mut TcpStream, banner_bytes: usize) -> Option { + if banner_bytes == 0 { + return None; + } + + let mut buf = vec![0_u8; banner_bytes]; + match stream.read(&mut buf) { + Ok(0) => None, + Ok(count) => { + buf.truncate(count); + let text = String::from_utf8_lossy(&buf).trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } + } + Err(err) + if matches!( + err.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted + ) => + { + None + } + Err(_) => None, + } +} + +fn print_status(port: u16, status: &PortStatus) { + match status { + PortStatus::Open { + banner: Some(banner), + } => { + println!(" port={port} state=open banner={:?}", banner); + } + PortStatus::Open { banner: None } => { + println!(" port={port} state=open"); + } + PortStatus::Closed => println!(" port={port} state=closed"), + PortStatus::TimedOut => println!(" port={port} state=timed_out"), + PortStatus::Error(err) => println!(" port={port} state=error detail={:?}", err), + } +} + +fn resolve_targets(host: &str) -> Result, String> { + (host, 0) + .to_socket_addrs() + .map(|iter| iter.map(|addr| addr.ip()).collect()) + .map_err(|err| format!("failed to resolve {host}: {err}")) +} + +fn parse_ports(spec: &str) -> Result, String> { + let mut ports = Vec::new(); + + for part in spec + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + if let Some((start, end)) = part.split_once('-') { + let start = parse_port_value(start)?; + let end = parse_port_value(end)?; + if start > end { + return Err(format!( + "invalid port range {part}: start is greater than end" + )); + } + for port in start..=end { + ports.push(port); + } + } else { + ports.push(parse_port_value(part)?); + } + } + + if ports.is_empty() { + return Err("no ports provided".to_string()); + } + + ports.sort_unstable(); + ports.dedup(); + Ok(ports) +} + +fn parse_port_value(text: &str) -> Result { + let port = text + .parse::() + .map_err(|err| format!("invalid port {text}: {err}"))?; + if port == 0 { + return Err("port 0 is not supported".to_string()); + } + Ok(port) +} + +fn format_ports(ports: &[u16]) -> String { + ports + .iter() + .map(u16::to_string) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_port_lists_and_ranges() { + assert_eq!(parse_ports("22,80,443").unwrap(), vec![22, 80, 443]); + assert_eq!(parse_ports("22,80-82,80").unwrap(), vec![22, 80, 81, 82]); + } + + #[test] + fn rejects_invalid_ranges() { + assert!(parse_ports("90-80").is_err()); + assert!(parse_ports("0").is_err()); + assert!(parse_ports("abc").is_err()); + } + + #[test] + fn parses_scan_config_with_flags() { + let args = vec![ + "--timeout-ms".to_string(), + "250".to_string(), + "--banner-bytes".to_string(), + "16".to_string(), + "example.com".to_string(), + "22,80-81".to_string(), + ]; + + let config = ScanConfig::parse(&args).unwrap(); + assert_eq!(config.host, "example.com"); + assert_eq!(config.ports, vec![22, 80, 81]); + assert_eq!(config.timeout, Duration::from_millis(250)); + assert_eq!(config.banner_bytes, 16); + } +}