- New shell_escape() utility for safe command interpolation - Applied across all MCP tools, docker, scanner, network commands - MCP server generates random bearer token at startup - Token written to mcp-token file with 0600 permissions - All MCP HTTP requests require Authorization header - Bridge binary reads token and sends on every request Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
231 lines
7.1 KiB
Rust
231 lines
7.1 KiB
Rust
//! 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<String>,
|
|
pub hostname: Option<String>,
|
|
pub vendor: Option<String>,
|
|
pub open_ports: Vec<u16>,
|
|
pub services: Vec<String>,
|
|
}
|
|
|
|
#[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<TokioMutex<Handle<SshClient>>>,
|
|
subnet: &str,
|
|
) -> Result<Vec<DiscoveredHost>, 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<String> = 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::<Vec<_>>()
|
|
.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<TokioMutex<Handle<SshClient>>>,
|
|
target: &str,
|
|
ports: &[u16],
|
|
) -> Result<Vec<PortResult>, 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<String> = 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::<u16>() {
|
|
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<TokioMutex<Handle<SshClient>>>,
|
|
target: &str,
|
|
) -> Result<Vec<PortResult>, String> {
|
|
let common_ports: Vec<u16> = 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<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
|
|
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)
|
|
}
|