Initial Commit

This commit is contained in:
2026-09-23 09:16:09 -04:00
commit 0cd2a516bb
49 changed files with 10134 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
pub mod ping;
pub mod ports;
pub mod netbios;
+176
View File
@@ -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,
}
}
+130
View File
@@ -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
}),
);
}
+151
View File
@@ -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
}