//! Network scanner tools — IP discovery, port scanning, and network mapping //! through SSH exec channels. No agent installation required. //! //! All scans run on the REMOTE host through the existing SSH connection, //! giving visibility into the remote network without direct access. use std::sync::Arc; use russh::client::Handle; use russh::ChannelMsg; use serde::Serialize; use tokio::sync::Mutex as TokioMutex; use crate::ssh::session::SshClient; use crate::utils::shell_escape; #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct DiscoveredHost { pub ip: String, pub mac: Option, pub hostname: Option, pub vendor: Option, pub open_ports: Vec, pub services: Vec, } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct PortResult { pub port: u16, pub open: bool, pub service: String, } /// Well-known port → service name mapping for common ports. fn service_name(port: u16) -> &'static str { match port { 21 => "FTP", 22 => "SSH", 23 => "Telnet", 25 => "SMTP", 53 => "DNS", 80 => "HTTP", 110 => "POP3", 135 => "RPC", 139 => "NetBIOS", 143 => "IMAP", 443 => "HTTPS", 445 => "SMB", 993 => "IMAPS", 995 => "POP3S", 1433 => "MSSQL", 1521 => "Oracle", 3306 => "MySQL", 3389 => "RDP", 5432 => "PostgreSQL", 5900 => "VNC", 6379 => "Redis", 8080 => "HTTP-Alt", 8443 => "HTTPS-Alt", 27017 => "MongoDB", _ => "unknown", } } /// Discover hosts on the remote network using ARP table and ping sweep. pub async fn scan_network( handle: &Arc>>, subnet: &str, ) -> Result, String> { // Script that works on Linux and macOS: // 1. Ping sweep the subnet to populate ARP cache // 2. Read ARP table for IP/MAC pairs // 3. Try reverse DNS for hostnames let escaped_subnet = shell_escape(subnet); let script = format!(r#" OS=$(uname -s 2>/dev/null) SUBNET={escaped_subnet} # Ping sweep (background, fast) if [ "$OS" = "Linux" ]; then for i in $(seq 1 254); do ping -c 1 -W 1 "$SUBNET.$i" > /dev/null 2>&1 & done wait elif [ "$OS" = "Darwin" ]; then for i in $(seq 1 254); do ping -c 1 -t 1 "$SUBNET.$i" > /dev/null 2>&1 & done wait fi # Read ARP table if [ "$OS" = "Linux" ]; then arp -n 2>/dev/null | grep -v incomplete | awk 'NR>1 {{printf "%s|%s\n", $1, $3}}' elif [ "$OS" = "Darwin" ]; then arp -a 2>/dev/null | grep -v incomplete | awk '{{gsub(/[()]/, ""); printf "%s|%s\n", $2, $4}}' fi "#); let output = exec_command(handle, &script).await .ok_or_else(|| "Failed to execute network scan".to_string())?; let mut hosts = Vec::new(); for line in output.lines() { let parts: Vec<&str> = line.split('|').collect(); if parts.len() >= 2 && !parts[0].is_empty() { let ip = parts[0].trim().to_string(); let mac = if parts[1].trim().is_empty() || parts[1].trim() == "(incomplete)" { None } else { Some(parts[1].trim().to_string()) }; hosts.push(DiscoveredHost { ip, mac, hostname: None, vendor: None, open_ports: Vec::new(), services: Vec::new(), }); } } // Try reverse DNS for each host if !hosts.is_empty() { let ips: Vec = hosts.iter().map(|h| h.ip.clone()).collect(); let dns_script = ips.iter() .map(|ip| format!("echo \"{}|$(host {} 2>/dev/null | awk '/domain name pointer/ {{print $NF}}' | sed 's/\\.$//')\"", ip, ip)) .collect::>() .join("\n"); if let Some(dns_output) = exec_command(handle, &dns_script).await { for line in dns_output.lines() { let parts: Vec<&str> = line.split('|').collect(); if parts.len() >= 2 && !parts[1].is_empty() { if let Some(host) = hosts.iter_mut().find(|h| h.ip == parts[0]) { host.hostname = Some(parts[1].to_string()); } } } } } Ok(hosts) } /// Scan specific ports on a target host through the SSH session. pub async fn scan_ports( handle: &Arc>>, target: &str, ports: &[u16], ) -> Result, String> { // Validate target — /dev/tcp requires a bare hostname/IP, not a shell-quoted value. // Only allow alphanumeric, dots, hyphens, and colons (for IPv6). if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':') { return Err(format!("Invalid target for port scan: {}", target)); } // Use bash /dev/tcp for port scanning — no nmap required let port_checks: Vec = ports.iter() .map(|p| format!( "(echo >/dev/tcp/{target}/{p}) 2>/dev/null && echo \"{p}|open\" || echo \"{p}|closed\"" )) .collect(); // Run in parallel batches of 20 for speed let mut results = Vec::new(); for chunk in port_checks.chunks(20) { let script = chunk.join(" &\n") + " &\nwait"; let output = exec_command(handle, &script).await .ok_or_else(|| "Port scan exec failed".to_string())?; for line in output.lines() { let parts: Vec<&str> = line.split('|').collect(); if parts.len() >= 2 { if let Ok(port) = parts[0].parse::() { results.push(PortResult { port, open: parts[1] == "open", service: service_name(port).to_string(), }); } } } } results.sort_by_key(|r| r.port); Ok(results) } /// Quick scan of common ports on a target. pub async fn quick_port_scan( handle: &Arc>>, target: &str, ) -> Result, String> { let common_ports: Vec = vec![ 21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 993, 995, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 27017, ]; scan_ports(handle, target, &common_ports).await } async fn exec_command(handle: &Arc>>, cmd: &str) -> Option { let mut channel = { let h = handle.lock().await; h.channel_open_session().await.ok()? }; channel.exec(true, cmd).await.ok()?; let mut output = String::new(); loop { match channel.wait().await { Some(ChannelMsg::Data { ref data }) => { if let Ok(text) = std::str::from_utf8(data.as_ref()) { output.push_str(text); } } Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, Some(ChannelMsg::ExitStatus { .. }) => {} _ => {} } } Some(output) }