Initial Commit
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "argus"
|
||||
version = "0.1.0"
|
||||
description = "An All in One Network Utility"
|
||||
authors = ["James Brotosky <james.brotosky@racooncity.org"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "argus_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
ping-async = "1.0"
|
||||
ipnetwork = "0.20"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dns-lookup = "4.0.1"
|
||||
tauri-plugin-dialog = "2"
|
||||
csv = "1"
|
||||
open = "5.4.4"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
libc = "0.2.189"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,19 @@
|
||||
#[tauri::command]
|
||||
pub async fn launch_url(url: String) -> Result<(), String> {
|
||||
open::that(&url).map_err(|e| format!("Failed to open URL: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_privileges() -> Result<bool, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Check if effective user ID is 0 (root)
|
||||
Ok(unsafe { libc::geteuid() } == 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// On Windows, check if running as administrator
|
||||
Ok(is_elevated::is_elevated())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs::File;
|
||||
|
||||
use crate::models::host::HostResult;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExportRequest {
|
||||
pub path: String,
|
||||
pub hosts: Vec<HostResult>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_csv(request: ExportRequest) -> Result<(), String> {
|
||||
let file = File::create(&request.path)
|
||||
.map_err(|e| format!("Failed to create file: {}", e))?;
|
||||
|
||||
let mut writer = csv::Writer::from_writer(file);
|
||||
|
||||
// Header
|
||||
writer
|
||||
.write_record(["IP Address", "Status", "Latency (ms)", "Hostname"])
|
||||
.map_err(|e| format!("Failed to write header: {}", e))?;
|
||||
|
||||
// Rows
|
||||
for host in &request.hosts {
|
||||
let ip = host.ip.to_string();
|
||||
let status = format!("{:?}", host.status);
|
||||
let rtt = match &host.ping {
|
||||
Some(p) => format!("{:.1}", p.rtt_ms),
|
||||
None => String::from("-"),
|
||||
};
|
||||
let hostname = host.hostname.clone().unwrap_or_default();
|
||||
|
||||
writer
|
||||
.write_record([&ip, &status, &rtt, &hostname])
|
||||
.map_err(|e| format!("Failed to write row: {}", e))?;
|
||||
}
|
||||
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush: {}", e))?;
|
||||
|
||||
tracing::info!("Exported {} hosts to {}", request.hosts.len(), request.path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod actions;
|
||||
pub mod export;
|
||||
pub mod monitor;
|
||||
pub mod nmap;
|
||||
pub mod scan;
|
||||
pub mod traceroute;
|
||||
pub mod updater;
|
||||
@@ -0,0 +1,182 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use ping_async::IcmpEchoRequestor;
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::models::monitor::MonitorEntry;
|
||||
|
||||
struct MonitorState {
|
||||
entries: HashMap<IpAddr, MonitorEntry>,
|
||||
task: Option<JoinHandle<()>>,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
static MONITOR: std::sync::OnceLock<Arc<Mutex<MonitorState>>> = std::sync::OnceLock::new();
|
||||
|
||||
fn get_monitor() -> Arc<Mutex<MonitorState>> {
|
||||
MONITOR
|
||||
.get_or_init(|| {
|
||||
Arc::new(Mutex::new(MonitorState {
|
||||
entries: HashMap::new(),
|
||||
task: None,
|
||||
running: false,
|
||||
}))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MonitorHost {
|
||||
pub ip: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_add_hosts(hosts: Vec<MonitorHost>) -> Result<(), String> {
|
||||
let state = get_monitor();
|
||||
let mut guard = state.lock().await;
|
||||
|
||||
for host in hosts {
|
||||
let ip: IpAddr = host
|
||||
.ip
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid IP '{}': {}", host.ip, e))?;
|
||||
guard
|
||||
.entries
|
||||
.entry(ip)
|
||||
.or_insert_with(|| MonitorEntry::new(ip, host.label));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_remove_host(ip: String) -> Result<(), String> {
|
||||
let parsed: IpAddr = ip.parse().map_err(|e| format!("Invalid IP: {}", e))?;
|
||||
let state = get_monitor();
|
||||
let mut guard = state.lock().await;
|
||||
guard.entries.remove(&parsed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_clear() -> Result<(), String> {
|
||||
let state = get_monitor();
|
||||
let mut guard = state.lock().await;
|
||||
guard.entries.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_get_entries() -> Result<Vec<MonitorEntry>, String> {
|
||||
let state = get_monitor();
|
||||
let guard = state.lock().await;
|
||||
let mut entries: Vec<MonitorEntry> = guard.entries.values().cloned().collect();
|
||||
entries.sort_by(|a, b| a.ip.to_string().cmp(&b.ip.to_string()));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_start(app: AppHandle, interval_ms: Option<u64>) -> Result<(), String> {
|
||||
let state = get_monitor();
|
||||
let mut guard = state.lock().await;
|
||||
|
||||
if guard.running {
|
||||
return Err("Monitor already running".to_string());
|
||||
}
|
||||
|
||||
if guard.entries.is_empty() {
|
||||
return Err("No hosts to monitor".to_string());
|
||||
}
|
||||
|
||||
guard.running = true;
|
||||
let interval = Duration::from_millis(interval_ms.unwrap_or(2000));
|
||||
let state2 = get_monitor();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
{
|
||||
let guard = state2.lock().await;
|
||||
if !guard.running {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect IPs to ping
|
||||
let ips: Vec<IpAddr> = {
|
||||
let guard = state2.lock().await;
|
||||
guard.entries.keys().cloned().collect()
|
||||
};
|
||||
|
||||
// Ping all hosts concurrently
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
for ip in ips {
|
||||
join_set.spawn(async move {
|
||||
let timeout = Duration::from_millis(1500);
|
||||
let requestor = match IcmpEchoRequestor::new(ip, None, None, Some(timeout)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return (ip, None),
|
||||
};
|
||||
match requestor.send().await {
|
||||
Ok(reply) => match reply.status() {
|
||||
ping_async::IcmpEchoStatus::Success => {
|
||||
let rtt = reply.round_trip_time().as_secs_f64() * 1000.0;
|
||||
(ip, Some(rtt))
|
||||
}
|
||||
_ => (ip, None),
|
||||
},
|
||||
Err(_) => (ip, None),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process results
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
if let Ok((ip, rtt)) = result {
|
||||
let mut guard = state2.lock().await;
|
||||
if let Some(entry) = guard.entries.get_mut(&ip) {
|
||||
let old_status = entry.status.clone();
|
||||
match rtt {
|
||||
Some(ms) => entry.record_success(ms),
|
||||
None => entry.record_failure(),
|
||||
}
|
||||
// Emit state change event
|
||||
if entry.status != old_status {
|
||||
let _ = app.emit(
|
||||
"monitor:state-change",
|
||||
serde_json::json!({
|
||||
"ip": ip.to_string(),
|
||||
"old": format!("{:?}", old_status),
|
||||
"new": format!("{:?}", entry.status),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let _ = app.emit("monitor:update", &entry.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
});
|
||||
|
||||
guard.task = Some(task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn monitor_stop() -> Result<(), String> {
|
||||
let state = get_monitor();
|
||||
let mut guard = state.lock().await;
|
||||
guard.running = false;
|
||||
if let Some(task) = guard.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
static NMAP_CANCEL: std::sync::OnceLock<Arc<AtomicBool>> = std::sync::OnceLock::new();
|
||||
|
||||
fn get_nmap_cancel() -> Arc<AtomicBool> {
|
||||
NMAP_CANCEL
|
||||
.get_or_init(|| Arc::new(AtomicBool::new(false)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NmapConfig {
|
||||
pub target: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_nmap(app: AppHandle, config: NmapConfig) -> Result<(), String> {
|
||||
// Check nmap exists
|
||||
let check = Command::new("nmap")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|_| "nmap not found. Please install nmap.".to_string())?;
|
||||
|
||||
if !check.status.success() {
|
||||
return Err("nmap not found. Please install nmap.".to_string());
|
||||
}
|
||||
|
||||
let cancel = get_nmap_cancel();
|
||||
cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
let _ = app.emit("nmap:start", &config.target);
|
||||
|
||||
let mut cmd = Command::new("nmap");
|
||||
for arg in &config.args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
for target in config.target.split_whitespace() {
|
||||
cmd.arg(target);
|
||||
}
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start nmap: {}", e))?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to capture nmap stdout".to_string())?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to capture nmap stderr".to_string())?;
|
||||
|
||||
// Stream stdout
|
||||
let app2 = app.clone();
|
||||
let cancel2 = cancel.clone();
|
||||
let stdout_handle = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if cancel2.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let _ = app2.emit("nmap:line", &line);
|
||||
}
|
||||
});
|
||||
|
||||
// Stream stderr
|
||||
let app3 = app.clone();
|
||||
let stderr_handle = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
let _ = app3.emit("nmap:line", &line);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for completion or cancellation
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
let _ = app.emit("nmap:complete", "Scan complete");
|
||||
}
|
||||
Ok(s) => {
|
||||
let _ = app.emit("nmap:complete",
|
||||
format!("nmap exited with code {}", s.code().unwrap_or(-1)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app.emit("nmap:error", format!("nmap error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} => {
|
||||
let _ = child.kill().await;
|
||||
let _ = app.emit("nmap:complete", "Scan cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_handle.await;
|
||||
let _ = stderr_handle.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_nmap() -> Result<(), String> {
|
||||
let cancel = get_nmap_cancel();
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
tracing::info!("Nmap scan cancelled");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::net::range::parse_ip_range;
|
||||
use crate::scanner::ping::ping_sweep;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ScanConfig {
|
||||
pub range: String,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub port_scan: Option<bool>,
|
||||
pub ports: Option<Vec<u16>>,
|
||||
pub port_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
static CANCEL_FLAG: std::sync::OnceLock<Arc<AtomicBool>> = std::sync::OnceLock::new();
|
||||
|
||||
fn get_cancel_flag() -> Arc<AtomicBool> {
|
||||
CANCEL_FLAG
|
||||
.get_or_init(|| Arc::new(AtomicBool::new(false)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_scan(app: AppHandle, config: ScanConfig) -> Result<(), String> {
|
||||
let ips = parse_ip_range(&config.range).map_err(|e| format!("Invalid IP range: {}", e))?;
|
||||
|
||||
let timeout_ms = config.timeout_ms.unwrap_or(1500);
|
||||
let cancel = get_cancel_flag();
|
||||
|
||||
// Reset cancel flag from any previous scan
|
||||
cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
// Spawn the scan as a background task so this command returns immediately
|
||||
let port_scan = config.port_scan.unwrap_or(false);
|
||||
let ports = config.ports.clone();
|
||||
let port_timeout_ms = config.port_timeout_ms.unwrap_or(1000);
|
||||
|
||||
tokio::spawn(async move {
|
||||
ping_sweep(&app, &ips, timeout_ms, cancel, port_scan, ports, port_timeout_ms).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_scan() -> Result<(), String> {
|
||||
let cancel = get_cancel_flag();
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
tracing::info!("Scan cancelled");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::process::Stdio;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_traceroute(app: AppHandle, ip: String) -> Result<(), String> {
|
||||
let _ = app.emit("traceroute:start", &ip);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut child = Command::new("tracepath")
|
||||
.arg("-b")
|
||||
.arg(&ip)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start tracepath: {}. Is iputils-tracepath installed?", e))?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut child = Command::new("tracert")
|
||||
.arg("-d")
|
||||
.arg(&ip)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start tracert: {}", e))?;
|
||||
|
||||
let stdout = child.stdout.take()
|
||||
.ok_or_else(|| "Failed to capture stdout".to_string())?;
|
||||
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
let _ = app.emit("traceroute:hop", &line);
|
||||
}
|
||||
|
||||
let status = child.wait().await
|
||||
.map_err(|e| format!("tracepath failed: {}", e))?;
|
||||
|
||||
if status.success() {
|
||||
let _ = app.emit("traceroute:complete", &ip);
|
||||
} else {
|
||||
let _ = app.emit("traceroute:error", "tracepath exited with an error");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub update_url: String,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_url: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("Failed to get config dir: {}", e))?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create config dir: {}", e))?;
|
||||
Ok(dir.join("config.json"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(app: AppHandle) -> Result<AppConfig, String> {
|
||||
let path = config_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(AppConfig::default());
|
||||
}
|
||||
let data = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read config: {}", e))?;
|
||||
serde_json::from_str(&data)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
|
||||
let path = config_path(&app)?;
|
||||
let data = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?;
|
||||
std::fs::write(&path, data)
|
||||
.map_err(|e| format!("Failed to write config: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update(app: AppHandle) -> Result<Option<UpdateInfo>, String> {
|
||||
let config = get_config(app.clone()).await?;
|
||||
|
||||
if config.update_url.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response = reqwest::get(&config.update_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for updates: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Update server returned {}", response.status()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid update response: {}", e))?;
|
||||
|
||||
// Parse Gitea/GitHub release format
|
||||
let tag = body["tag_name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No tag_name in response".to_string())?;
|
||||
|
||||
let html_url = body["html_url"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let notes = body["body"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Strip leading 'v' from tag if present (v0.2.0 -> 0.2.0)
|
||||
let remote_version = tag.strip_prefix('v').unwrap_or(tag);
|
||||
let current = app.package_info().version.to_string();
|
||||
|
||||
if remote_version != current {
|
||||
Ok(Some(UpdateInfo {
|
||||
version: remote_version.to_string(),
|
||||
url: html_url,
|
||||
notes,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
mod commands;
|
||||
mod models;
|
||||
mod net;
|
||||
mod scanner;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "argus=debug".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::scan::start_scan,
|
||||
commands::scan::stop_scan,
|
||||
commands::export::export_csv,
|
||||
commands::actions::launch_url,
|
||||
commands::traceroute::run_traceroute,
|
||||
commands::nmap::run_nmap,
|
||||
commands::nmap::stop_nmap,
|
||||
commands::monitor::monitor_add_hosts,
|
||||
commands::monitor::monitor_remove_host,
|
||||
commands::monitor::monitor_clear,
|
||||
commands::monitor::monitor_get_entries,
|
||||
commands::monitor::monitor_start,
|
||||
commands::monitor::monitor_stop,
|
||||
commands::updater::get_config,
|
||||
commands::updater::save_config,
|
||||
commands::updater::check_for_update,
|
||||
commands::actions::check_privileges,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
argus_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum HostStatus {
|
||||
Up,
|
||||
Down,
|
||||
Filtered,
|
||||
Unknown
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PingResult {
|
||||
pub rtt_ms: f64,
|
||||
pub ttl: Option<u8>,
|
||||
pub loss_pct: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Protocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum PortState {
|
||||
Open,
|
||||
Closed,
|
||||
Filtered,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortResult {
|
||||
pub port: u16,
|
||||
pub protocol: Protocol,
|
||||
pub state: PortState,
|
||||
pub service: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetbiosInfo {
|
||||
pub computer_name: Option<String>,
|
||||
pub workgroup: Option<String>,
|
||||
pub mac_from_netbios: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HostResult {
|
||||
pub ip: IpAddr,
|
||||
pub hostname: Option<String>,
|
||||
pub mac: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub status: HostStatus,
|
||||
pub ping: Option<PingResult>,
|
||||
pub ports: Vec<PortResult>,
|
||||
pub netbios: Option<NetbiosInfo>,
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
impl HostResult {
|
||||
pub fn from_ping(ip: IpAddr, ping: PingResult) -> Self {
|
||||
Self {
|
||||
ip,
|
||||
hostname: None,
|
||||
mac: None,
|
||||
vendor: None,
|
||||
status: HostStatus::Up,
|
||||
ping: Some(ping),
|
||||
ports: Vec::new(),
|
||||
netbios: None,
|
||||
updated_at: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn down(ip: IpAddr) -> Self {
|
||||
Self {
|
||||
ip,
|
||||
hostname: None,
|
||||
mac: None,
|
||||
vendor: None,
|
||||
status: HostStatus::Down,
|
||||
ping: None,
|
||||
ports: Vec::new(),
|
||||
netbios: None,
|
||||
updated_at: now_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod host;
|
||||
pub mod monitor;
|
||||
@@ -0,0 +1,105 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MonitorStatus {
|
||||
Up,
|
||||
Down,
|
||||
Degraded,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MonitorEntry {
|
||||
pub ip: IpAddr,
|
||||
pub label: Option<String>,
|
||||
pub status: MonitorStatus,
|
||||
pub last_rtt_ms: Option<f64>,
|
||||
pub min_rtt_ms: Option<f64>,
|
||||
pub avg_rtt_ms: Option<f64>,
|
||||
pub max_rtt_ms: Option<f64>,
|
||||
pub packets_sent: u64,
|
||||
pub packets_received: u64,
|
||||
pub loss_pct: f64,
|
||||
pub consecutive_failures: u32,
|
||||
pub last_success: Option<u64>,
|
||||
pub last_failure: Option<u64>,
|
||||
}
|
||||
|
||||
impl MonitorEntry {
|
||||
pub fn new(ip: IpAddr, label: Option<String>) -> Self {
|
||||
Self {
|
||||
ip,
|
||||
label,
|
||||
status: MonitorStatus::Unknown,
|
||||
last_rtt_ms: None,
|
||||
min_rtt_ms: None,
|
||||
avg_rtt_ms: None,
|
||||
max_rtt_ms: None,
|
||||
packets_sent: 0,
|
||||
packets_received: 0,
|
||||
loss_pct: 0.0,
|
||||
consecutive_failures: 0,
|
||||
last_success: None,
|
||||
last_failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_success(&mut self, rtt_ms: f64) {
|
||||
self.packets_sent += 1;
|
||||
self.packets_received += 1;
|
||||
self.last_rtt_ms = Some(rtt_ms);
|
||||
self.consecutive_failures = 0;
|
||||
self.last_success = Some(now_ms());
|
||||
|
||||
// Update min/avg/max
|
||||
self.min_rtt_ms = Some(match self.min_rtt_ms {
|
||||
Some(min) => min.min(rtt_ms),
|
||||
None => rtt_ms,
|
||||
});
|
||||
self.max_rtt_ms = Some(match self.max_rtt_ms {
|
||||
Some(max) => max.max(rtt_ms),
|
||||
None => rtt_ms,
|
||||
});
|
||||
self.avg_rtt_ms = Some(
|
||||
(self.avg_rtt_ms.unwrap_or(0.0) * (self.packets_received - 1) as f64 + rtt_ms)
|
||||
/ self.packets_received as f64,
|
||||
);
|
||||
|
||||
self.update_loss();
|
||||
self.update_status();
|
||||
}
|
||||
|
||||
pub fn record_failure(&mut self) {
|
||||
self.packets_sent += 1;
|
||||
self.consecutive_failures += 1;
|
||||
self.last_rtt_ms = None;
|
||||
self.last_failure = Some(now_ms());
|
||||
|
||||
self.update_loss();
|
||||
self.update_status();
|
||||
}
|
||||
|
||||
fn update_loss(&mut self) {
|
||||
if self.packets_sent > 0 {
|
||||
self.loss_pct = (1.0 - self.packets_received as f64 / self.packets_sent as f64) * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_status(&mut self) {
|
||||
if self.consecutive_failures >= 3 {
|
||||
self.status = MonitorStatus::Down;
|
||||
} else if self.loss_pct > 20.0 || self.consecutive_failures > 0 {
|
||||
self.status = MonitorStatus::Degraded;
|
||||
} else if self.packets_received > 0 {
|
||||
self.status = MonitorStatus::Up;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::net::IpAddr;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
/// Perform reverse DNS lookup on a single IP.
|
||||
/// Returns the hostname if found, None otherwise.
|
||||
pub async fn reverse_lookup(ip: IpAddr) -> Option<String> {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
dns_lookup::lookup_addr(&ip)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(hostname)) => {
|
||||
if hostname == ip.to_string() {
|
||||
None
|
||||
} else {
|
||||
Some(hostname)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform reverse DNS lookups on a batch of IPs concurrently.
|
||||
/// Returns a Vec of (IpAddr, Option<hostname>) pairs.
|
||||
pub async fn reverse_lookup_batch(
|
||||
ips: &[IpAddr],
|
||||
max_concurrent: usize,
|
||||
) -> Vec<(IpAddr, Option<String>)> {
|
||||
let mut results = Vec::with_capacity(ips.len());
|
||||
|
||||
for chunk in ips.chunks(max_concurrent) {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
for &ip in chunk {
|
||||
join_set.spawn(async move {
|
||||
let hostname = reverse_lookup(ip).await;
|
||||
(ip, hostname)
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
match result {
|
||||
Ok(pair) => results.push(pair),
|
||||
Err(e) => tracing::warn!("DNS lookup task panicked: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod range;
|
||||
pub mod dns;
|
||||
@@ -0,0 +1,143 @@
|
||||
use ipnetwork::IpNetwork;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Supported formats:
|
||||
/// CIDR: "192.168.1.0/24"
|
||||
/// Range: "192.168.1.1-192.168.1.254"
|
||||
/// Single IP: "192.168.1.1"
|
||||
/// Comma-sep: "192.168.1.1,192.168.1.2,10.0.0.1"
|
||||
/// Mixed: "192.168.1.0/24,10.0.0.1"
|
||||
|
||||
pub fn parse_ip_range(input: &str) -> Result<Vec<IpAddr>, String> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err("Empty Input".to_string());
|
||||
}
|
||||
let mut all_ips = Vec::new();
|
||||
for segment in input.split(',') {
|
||||
let segment = segment.trim();
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match segment {
|
||||
s if s.contains('/') => parse_cidr(s, &mut all_ips)?,
|
||||
s if s.contains('-') => parse_range(s, &mut all_ips)?,
|
||||
s => parse_single(s, &mut all_ips)?,
|
||||
}
|
||||
}
|
||||
if all_ips.is_empty() {
|
||||
return Err("No Valid IPs parsed".to_string());
|
||||
}
|
||||
Ok(all_ips)
|
||||
}
|
||||
|
||||
fn parse_cidr(segment: &str, ips: &mut Vec<IpAddr>) -> Result<(), String> {
|
||||
let network: IpNetwork = segment
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid CIDR '{}': {}", segment, e))?;
|
||||
|
||||
match network {
|
||||
IpNetwork::V4(net) if net.prefix() < 31 => {
|
||||
let network_addr = net.network();
|
||||
let broadcast_addr = net.broadcast();
|
||||
for ip in net.iter() {
|
||||
if ip != network_addr && ip != broadcast_addr {
|
||||
ips.push(IpAddr::V4(ip));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for ip in network.iter() {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_range(segment: &str, ips: &mut Vec<IpAddr>) -> Result<(), String> {
|
||||
let parts: Vec<&str> = segment.splitn(2, '-').collect();
|
||||
let start: IpAddr = parts[0]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid start IP '{}': {}", parts[0], e))?;
|
||||
let end: IpAddr = parts[1]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid end IP '{}': {}", parts[1], e))?;
|
||||
match (start, end) {
|
||||
(IpAddr::V4(s), IpAddr::V4(e)) => {
|
||||
let s_u32 = u32::from(s);
|
||||
let e_u32 = u32::from(e);
|
||||
if s_u32 > e_u32 {
|
||||
return Err(format!("Start IP {} is greater than end IP {}", s, e,));
|
||||
}
|
||||
for addr in s_u32..=e_u32 {
|
||||
ips.push(IpAddr::V4(addr.into()));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err("Range notation only supports IPv4 currently".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_single(segment: &str, ips: &mut Vec<IpAddr>) -> Result<(), String> {
|
||||
let ip: IpAddr = segment
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid IP '{}': {}", segment, e))?;
|
||||
ips.push(ip);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_single_ip() {
|
||||
let result = parse_ip_range("192.168.1.1").unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr_24() {
|
||||
let result = parse_ip_range("192.168.1.0/24").unwrap();
|
||||
assert_eq!(result.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_range() {
|
||||
let result = parse_ip_range("192.168.1.1-192.168.1.10").unwrap();
|
||||
assert_eq!(result.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comma_separated() {
|
||||
let result = parse_ip_range("192.168.1.1,192.168.1.2,10.0.0.1").unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed() {
|
||||
let result = parse_ip_range("192.168.1.0/30,10.0.0.1").unwrap();
|
||||
assert_eq!(result.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
assert!(parse_ip_range("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_cidr() {
|
||||
assert!(parse_ip_range("not.an.ip/24").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reversed_range() {
|
||||
assert!(parse_ip_range("192.168.1.10-192.168.1.1").is_err());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "argus",
|
||||
"version": "0.2.0",
|
||||
"identifier": "io.argus.app",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "argus",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||