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
+7
View File
@@ -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
+5871
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+19
View File
@@ -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())
}
}
+46
View File
@@ -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(())
}
+7
View File
@@ -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;
+182
View File
@@ -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(())
}
+129
View File
@@ -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(())
}
+54
View File
@@ -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(())
}
+48
View File
@@ -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(())
}
+107
View File
@@ -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)
}
}
+39
View File
@@ -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");
}
+6
View File
@@ -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()
}
+96
View File
@@ -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(),
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod host;
pub mod monitor;
+105
View File
@@ -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
}
+50
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
pub mod range;
pub mod dns;
+143
View File
@@ -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());
}
}
+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
}
+33
View File
@@ -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"
]
}
}