Initial Commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
pub mod ping;
|
||||
pub mod ports;
|
||||
pub mod netbios;
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::models::host::NetbiosInfo;
|
||||
fn build_nbstat_query() -> Vec<u8> {
|
||||
let mut pkt = Vec::with_capacity(50);
|
||||
|
||||
// Transaction ID
|
||||
pkt.extend_from_slice(&[0x00, 0x01]);
|
||||
// Flags: standard query
|
||||
pkt.extend_from_slice(&[0x00, 0x00]);
|
||||
// Questions: 1
|
||||
pkt.extend_from_slice(&[0x00, 0x01]);
|
||||
// Answer RRs: 0
|
||||
pkt.extend_from_slice(&[0x00, 0x00]);
|
||||
// Authority RRs: 0
|
||||
pkt.extend_from_slice(&[0x00, 0x00]);
|
||||
// Additional RRs: 0
|
||||
pkt.extend_from_slice(&[0x00, 0x00]);
|
||||
// Name: encoded wildcard "*" (CKAAAAAA...) length 32
|
||||
pkt.push(0x20);
|
||||
// '*' = 0x2A -> nibbles 0x2, 0xA -> 'C', 'K'
|
||||
// Then 15 spaces (0x20) -> each is 'C', 'A'
|
||||
pkt.extend_from_slice(b"CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
// Name terminator
|
||||
pkt.push(0x00);
|
||||
// Type: NBSTAT (0x0021)
|
||||
pkt.extend_from_slice(&[0x00, 0x21]);
|
||||
// Class: IN (0x0001)
|
||||
pkt.extend_from_slice(&[0x00, 0x01]);
|
||||
|
||||
pkt
|
||||
}
|
||||
|
||||
fn parse_nbstat_response(data: &[u8]) -> Option<NetbiosInfo> {
|
||||
// Minimum: 12 byte header + answer section
|
||||
if data.len() < 57 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip header (12 bytes) + query echo name section
|
||||
// Find the name count byte. The answer starts after the
|
||||
// echoed name. We look for the NBSTAT answer by scanning
|
||||
// past the header.
|
||||
let mut pos = 12;
|
||||
|
||||
// Skip the answer name (could be compressed pointer or literal)
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if data[pos] & 0xC0 == 0xC0 {
|
||||
// Compressed pointer: 2 bytes
|
||||
pos += 2;
|
||||
} else {
|
||||
// Literal name: skip until null
|
||||
while pos < data.len() && data[pos] != 0x00 {
|
||||
pos += data[pos] as usize + 1;
|
||||
}
|
||||
pos += 1; // skip null terminator
|
||||
}
|
||||
|
||||
// Skip type (2) + class (2) + TTL (4) + data length (2)
|
||||
pos += 10;
|
||||
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Number of names
|
||||
let name_count = data[pos] as usize;
|
||||
pos += 1;
|
||||
|
||||
let mut computer_name: Option<String> = None;
|
||||
let mut workgroup: Option<String> = None;
|
||||
|
||||
for _ in 0..name_count {
|
||||
if pos + 18 > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Name: 15 bytes, right-padded with spaces
|
||||
let raw_name = &data[pos..pos + 15];
|
||||
let name = String::from_utf8_lossy(raw_name).trim().to_string();
|
||||
// Suffix byte (type)
|
||||
let suffix = data[pos + 15];
|
||||
// Flags: 2 bytes
|
||||
let flags = u16::from_be_bytes([data[pos + 16], data[pos + 17]]);
|
||||
let is_group = flags & 0x8000 != 0;
|
||||
|
||||
pos += 18;
|
||||
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match (suffix, is_group) {
|
||||
(0x00, false) => {
|
||||
if computer_name.is_none() {
|
||||
computer_name = Some(name);
|
||||
}
|
||||
}
|
||||
(0x00, true) => {
|
||||
if workgroup.is_none() {
|
||||
workgroup = Some(name);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// MAC address: 6 bytes after the name table
|
||||
let mac = if pos + 6 <= data.len() {
|
||||
let m = &data[pos..pos + 6];
|
||||
// Skip if all zeros or all FF
|
||||
if m.iter().all(|&b| b == 0) || m.iter().all(|&b| b == 0xFF) {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[0], m[1], m[2], m[3], m[4], m[5]
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Only return if we got something useful
|
||||
if computer_name.is_none() && workgroup.is_none() && mac.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(NetbiosInfo {
|
||||
computer_name,
|
||||
workgroup,
|
||||
mac_from_netbios: mac,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn query_netbios(ip: IpAddr, timeout_ms: u64) -> Option<NetbiosInfo> {
|
||||
let target = SocketAddr::new(ip, 137);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
// Bind to any available port
|
||||
let bind_addr = match ip {
|
||||
IpAddr::V4(_) => "0.0.0.0:0",
|
||||
IpAddr::V6(_) => "[::]:0",
|
||||
};
|
||||
|
||||
let socket = match UdpSocket::bind(bind_addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!("NetBIOS socket bind failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let query = build_nbstat_query();
|
||||
|
||||
if let Err(e) = socket.send_to(&query, target).await {
|
||||
tracing::debug!("NetBIOS send to {} failed: {}", ip, e);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 1024];
|
||||
match timeout(dur, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, _))) => {
|
||||
buf.truncate(len);
|
||||
parse_nbstat_response(&buf)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::models::host::{HostResult, PingResult};
|
||||
use crate::net::dns::reverse_lookup;
|
||||
use crate::scanner::netbios::query_netbios;
|
||||
use crate::scanner::ports::{scan_host_ports, DEFAULT_PORTS};
|
||||
use ping_async::IcmpEchoRequestor;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
const MAX_CONCURRENT: usize = 128;
|
||||
|
||||
async fn ping_host(ip: IpAddr, timeout: Duration) -> HostResult {
|
||||
let requestor = match IcmpEchoRequestor::new(ip, None, None, Some(timeout)) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to create ping request for {}: {}", ip, e);
|
||||
return HostResult::down(ip);
|
||||
}
|
||||
};
|
||||
match requestor.send().await {
|
||||
Ok(reply) => match reply.status() {
|
||||
ping_async::IcmpEchoStatus::Success => {
|
||||
let rtt = reply.round_trip_time();
|
||||
let hostname = reverse_lookup(ip).await;
|
||||
let mut result = HostResult::from_ping(
|
||||
ip,
|
||||
PingResult {
|
||||
rtt_ms: rtt.as_secs_f64() * 1000.0,
|
||||
ttl: None,
|
||||
loss_pct: 0.0,
|
||||
},
|
||||
);
|
||||
result.hostname = hostname;
|
||||
result
|
||||
}
|
||||
other => {
|
||||
tracing::debug!("{} returned {:?}", ip, other);
|
||||
HostResult::down(ip)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::debug!("Ping error for {}: {}", ip, e);
|
||||
HostResult::down(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ping_sweep(
|
||||
app: &AppHandle,
|
||||
ips: &[IpAddr],
|
||||
timeout_ms: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
port_scan: bool,
|
||||
ports: Option<Vec<u16>>,
|
||||
port_timeout_ms: u64,
|
||||
) {
|
||||
let total = ips.len() as u32;
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let mut done: u32 = 0;
|
||||
|
||||
for chunk in ips.chunks(MAX_CONCURRENT) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
for &ip in chunk {
|
||||
let timeout = timeout;
|
||||
join_set.spawn(async move { ping_host(ip, timeout).await });
|
||||
}
|
||||
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
done += 1;
|
||||
match result {
|
||||
Ok(mut host_result) => {
|
||||
// Emit immediately so the host shows up in the table
|
||||
let _ = app.emit("scan:host-found", &host_result);
|
||||
|
||||
// Port scan Up hosts in place
|
||||
if host_result.status == crate::models::host::HostStatus::Up {
|
||||
let app2 = app.clone();
|
||||
let ports2 = ports.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut enriched = host_result;
|
||||
|
||||
// NetBIOS and port scan concurrently
|
||||
let nb_fut = query_netbios(enriched.ip, 500);
|
||||
|
||||
if port_scan {
|
||||
let scan_ports = ports2
|
||||
.unwrap_or_else(|| DEFAULT_PORTS.to_vec());
|
||||
let (nb_result, port_results) = tokio::join!(
|
||||
nb_fut,
|
||||
scan_host_ports(enriched.ip, &scan_ports, port_timeout_ms)
|
||||
);
|
||||
enriched.netbios = nb_result;
|
||||
enriched.ports = port_results;
|
||||
} else {
|
||||
enriched.netbios = nb_fut.await;
|
||||
}
|
||||
|
||||
let _ = app2.emit("scan:host-found", &enriched);
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Ping task panicked: {}", e);
|
||||
}
|
||||
}
|
||||
let _ = app.emit(
|
||||
"scan:progress",
|
||||
serde_json::json!({
|
||||
"done": done,
|
||||
"total": total
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"scan:complete",
|
||||
serde_json::json!({
|
||||
"total_hosts": done
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::models::host::{PortResult, PortState, Protocol};
|
||||
|
||||
fn service_name(port: u16) -> Option<String> {
|
||||
let name = match port {
|
||||
21 => "ftp",
|
||||
22 => "ssh",
|
||||
23 => "telnet",
|
||||
25 => "smtp",
|
||||
53 => "dns",
|
||||
80 => "http",
|
||||
110 => "pop3",
|
||||
111 => "rpcbind",
|
||||
135 => "msrpc",
|
||||
139 => "netbios-ssn",
|
||||
143 => "imap",
|
||||
443 => "https",
|
||||
445 => "microsoft-ds",
|
||||
993 => "imaps",
|
||||
995 => "pop3s",
|
||||
1433 => "mssql",
|
||||
1521 => "oracle",
|
||||
3306 => "mysql",
|
||||
3389 => "rdp",
|
||||
5432 => "postgresql",
|
||||
5900 => "vnc",
|
||||
5985 => "winrm",
|
||||
5986 => "winrm-s",
|
||||
8080 => "http-alt",
|
||||
8088 => "http-alt",
|
||||
8443 => "https-alt",
|
||||
_ => return None,
|
||||
};
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
pub const DEFAULT_PORTS: &[u16] = &[
|
||||
21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143,
|
||||
443, 445, 993, 995, 1433, 1521, 3306, 3389,
|
||||
5432, 5900, 5985, 5986, 8080, 8088, 8443,
|
||||
];
|
||||
|
||||
async fn scan_port(ip: IpAddr, port: u16, timeout_ms: u64) -> PortResult {
|
||||
let addr = SocketAddr::new(ip, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let stream = match timeout(dur, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(_)) => {
|
||||
return PortResult {
|
||||
port,
|
||||
protocol: Protocol::Tcp,
|
||||
state: PortState::Closed,
|
||||
service: service_name(port),
|
||||
banner: None,
|
||||
};
|
||||
}
|
||||
Err(_) => {
|
||||
return PortResult {
|
||||
port,
|
||||
protocol: Protocol::Tcp,
|
||||
state: PortState::Filtered,
|
||||
service: service_name(port),
|
||||
banner: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Port is open, try to grab a banner
|
||||
let banner = grab_banner(stream, port).await;
|
||||
|
||||
PortResult {
|
||||
port,
|
||||
protocol: Protocol::Tcp,
|
||||
state: PortState::Open,
|
||||
service: service_name(port),
|
||||
banner,
|
||||
}
|
||||
}
|
||||
|
||||
async fn grab_banner(mut stream: TcpStream, port: u16) -> Option<String> {
|
||||
let banner_timeout = Duration::from_millis(800);
|
||||
|
||||
// Some services need us to send something first
|
||||
let probe = match port {
|
||||
80 | 8080 | 8088 | 8443 => Some(b"HEAD / HTTP/1.0\r\nHost: localhost\r\n\r\n".as_slice()),
|
||||
443 => None, // TLS, can't grab without handshake
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(data) = probe {
|
||||
if timeout(banner_timeout, stream.write_all(data)).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read whatever the service sends back
|
||||
let mut buf = vec![0u8; 1024];
|
||||
match timeout(banner_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
buf.truncate(n);
|
||||
// Strip to printable ASCII, replace control chars
|
||||
let banner: String = buf.iter()
|
||||
.map(|&b| if b >= 0x20 && b < 0x7f { b as char } else { ' ' })
|
||||
.collect();
|
||||
let banner = banner.trim().to_string();
|
||||
if banner.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Cap length so it doesn't blow up the UI
|
||||
Some(banner.chars().take(200).collect())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_PORT_CONCURRENT: usize = 64;
|
||||
|
||||
pub async fn scan_host_ports(
|
||||
ip: IpAddr,
|
||||
ports: &[u16],
|
||||
timeout_ms: u64,
|
||||
) -> Vec<PortResult> {
|
||||
let mut results = Vec::with_capacity(ports.len());
|
||||
|
||||
for chunk in ports.chunks(MAX_PORT_CONCURRENT) {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
for &port in chunk {
|
||||
join_set.spawn(scan_port(ip, port, timeout_ms));
|
||||
}
|
||||
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
match result {
|
||||
Ok(port_result) => results.push(port_result),
|
||||
Err(e) => tracing::warn!("Port scan task panicked: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by_key(|p| p.port);
|
||||
results
|
||||
}
|
||||
Reference in New Issue
Block a user