Initial Commit
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Tauri + Vanilla
|
||||||
|
|
||||||
|
This template should help get you started developing with Tauri in vanilla HTML, CSS and Javascript.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "argus",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Argus</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- Update Banner -->
|
||||||
|
<div id="update-banner" class="update-banner">
|
||||||
|
<span id="update-message"></span>
|
||||||
|
<a id="update-link" href="#" target="_blank" class="update-link">Download</a>
|
||||||
|
<button id="update-dismiss" class="update-dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div id="toolbar">
|
||||||
|
<svg class="toolbar-circuit" viewBox="0 0 400 50" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="400" y1="12" x2="280" y2="12" stroke="url(#cyanFade)" stroke-width="1" />
|
||||||
|
<line x1="400" y1="38" x2="310" y2="38" stroke="url(#cyanFade)" stroke-width="1" />
|
||||||
|
<line x1="400" y1="25" x2="240" y2="25" stroke="url(#cyanFade)" stroke-width="1" />
|
||||||
|
<polyline points="280,12 260,25 240,25" fill="none" stroke="url(#cyanFade)" stroke-width="1" />
|
||||||
|
<polyline points="310,38 290,25 260,25" fill="none" stroke="url(#cyanFade)" stroke-width="1" />
|
||||||
|
<polyline points="340,12 340,22 360,32 360,38" fill="none" stroke="url(#cyanFade)" stroke-width="1" />
|
||||||
|
<polyline points="250,12 230,12 220,20" fill="none" stroke="url(#cyanFade)" stroke-width="0.8" />
|
||||||
|
<polyline points="370,38 370,28 385,18 400,18" fill="none" stroke="url(#cyanFade)" stroke-width="0.8" />
|
||||||
|
<circle cx="280" cy="12" r="2.5" fill="#00d4ff" opacity="0.9" />
|
||||||
|
<circle cx="260" cy="25" r="2" fill="#00d4ff" opacity="0.7" />
|
||||||
|
<circle cx="240" cy="25" r="3" fill="#00d4ff" opacity="0.9">
|
||||||
|
<animate attributeName="opacity" values="0.9;0.4;0.9" dur="3s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="310" cy="38" r="2.5" fill="#00d4ff" opacity="0.8" />
|
||||||
|
<circle cx="290" cy="25" r="2" fill="#00d4ff" opacity="0.6" />
|
||||||
|
<circle cx="340" cy="12" r="2" fill="#00d4ff" opacity="0.7" />
|
||||||
|
<circle cx="360" cy="38" r="2" fill="#00d4ff" opacity="0.6" />
|
||||||
|
<circle cx="250" cy="12" r="1.5" fill="#00d4ff" opacity="0.5" />
|
||||||
|
<circle cx="220" cy="20" r="2" fill="#00d4ff" opacity="0.5">
|
||||||
|
<animate attributeName="opacity" values="0.5;0.2;0.5" dur="4s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="370" cy="28" r="1.5" fill="#00d4ff" opacity="0.5" />
|
||||||
|
<circle cx="240" cy="25" r="6" fill="none" stroke="#00d4ff" stroke-width="0.5" opacity="0.3">
|
||||||
|
<animate attributeName="r" values="6;10;6" dur="3s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="0.3;0;0.3" dur="3s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cyanFade" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0" />
|
||||||
|
<stop offset="40%" stop-color="#00d4ff" stop-opacity="0.5" />
|
||||||
|
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0.15" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<span class="app-title">ARGUS</span>
|
||||||
|
<input type="text" id="ip-range-input" placeholder="IP range (e.g. 192.168.1.0/24)" spellcheck="false" />
|
||||||
|
<button id="btn-scan" class="btn btn-primary">Scan</button>
|
||||||
|
<button id="btn-stop" class="btn btn-danger" disabled>Stop</button>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="chk-portscan" />
|
||||||
|
<span>Simple Port Scan</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group toolbar-right">
|
||||||
|
<span id="status-text" class="status-text">Ready</span>
|
||||||
|
<div id="progress-bar" class="progress-bar" hidden>
|
||||||
|
<div id="progress-fill" class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div id="tab-bar">
|
||||||
|
<button class="tab active" data-tab="scanner">Scanner</button>
|
||||||
|
<button class="tab" data-tab="monitor">Monitor</button>
|
||||||
|
<button class="tab" data-tab="nmap">Nmap</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Views -->
|
||||||
|
<div id="views">
|
||||||
|
<div id="view-scanner" class="view active">
|
||||||
|
<div id="results-toolbar">
|
||||||
|
<input type="text" id="filter-input" placeholder="Filter results..." spellcheck="false" />
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-export-all" class="btn-sm">Export All</button>
|
||||||
|
<button id="btn-export-up" class="btn-sm">Export Up</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-picker-wrap">
|
||||||
|
<button id="btn-columns" class="btn-sm">Columns</button>
|
||||||
|
<div id="col-picker" class="col-picker" hidden>
|
||||||
|
<label><input type="checkbox" data-col="ip" checked disabled /> IP Address</label>
|
||||||
|
<label><input type="checkbox" data-col="status" checked /> Status</label>
|
||||||
|
<label><input type="checkbox" data-col="rtt" checked /> Latency</label>
|
||||||
|
<label><input type="checkbox" data-col="hostname" checked /> Hostname</label>
|
||||||
|
<label><input type="checkbox" data-col="ports" checked /> Open Ports</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="send-group">
|
||||||
|
<div class="send-wrap">
|
||||||
|
<button id="btn-send-monitor" class="btn-sm">Send to Monitor ▾</button>
|
||||||
|
<div class="send-menu" id="send-monitor-menu" hidden>
|
||||||
|
<button class="ctx-item" data-send="monitor-all">All Hosts</button>
|
||||||
|
<button class="ctx-item" data-send="monitor-up">Up Hosts</button>
|
||||||
|
<button class="ctx-item" data-send="monitor-checked">Checked Hosts</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="send-wrap">
|
||||||
|
<button id="btn-send-nmap" class="btn-sm">Send to Nmap ▾</button>
|
||||||
|
<div class="send-menu" id="send-nmap-menu" hidden>
|
||||||
|
<button class="ctx-item" data-send="nmap-all">All Hosts</button>
|
||||||
|
<button class="ctx-item" data-send="nmap-up">Up Hosts</button>
|
||||||
|
<button class="ctx-item" data-send="nmap-checked">Checked Hosts</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="host-count" class="host-count"></span>
|
||||||
|
</div>
|
||||||
|
<div id="results-table-container">
|
||||||
|
<table id="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check"><input type="checkbox" id="check-all" /></th>
|
||||||
|
<th data-sort="ip" class="sortable sorted-asc">IP Address</th>
|
||||||
|
<th data-sort="status" class="sortable">Status</th>
|
||||||
|
<th data-sort="rtt" class="sortable">Latency (ms)</th>
|
||||||
|
<th data-sort="hostname" class="sortable">Hostname</th>
|
||||||
|
<th data-sort="ports" class="sortable">Open Ports</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="results-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-monitor" class="view">
|
||||||
|
<div id="monitor-toolbar">
|
||||||
|
<input type="text" id="monitor-input" placeholder="Add hosts (e.g. 192.168.1.1, 10.0.0.0/24)"
|
||||||
|
spellcheck="false" />
|
||||||
|
<button id="btn-monitor-add" class="btn-sm">Add</button>
|
||||||
|
<select id="monitor-interval">
|
||||||
|
<option value="1000">1s interval</option>
|
||||||
|
<option value="2000" selected>2s interval</option>
|
||||||
|
<option value="5000">5s interval</option>
|
||||||
|
<option value="10000">10s interval</option>
|
||||||
|
<option value="30000">30s interval</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-monitor-start" class="btn btn-primary">Start</button>
|
||||||
|
<button id="btn-monitor-stop" class="btn btn-danger" disabled>Stop</button>
|
||||||
|
<button id="btn-monitor-clear" class="btn-sm">Clear All</button>
|
||||||
|
</div>
|
||||||
|
<div id="monitor-table-container">
|
||||||
|
<table id="monitor-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last RTT</th>
|
||||||
|
<th>Min</th>
|
||||||
|
<th>Avg</th>
|
||||||
|
<th>Max</th>
|
||||||
|
<th>Sent</th>
|
||||||
|
<th>Recv</th>
|
||||||
|
<th>Loss %</th>
|
||||||
|
<th>Consec. Fail</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="monitor-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-nmap" class="view">
|
||||||
|
<div id="nmap-toolbar">
|
||||||
|
<input type="text" id="nmap-target" placeholder="Target (e.g. 192.168.1.1 or 192.168.1.0/24)"
|
||||||
|
spellcheck="false" />
|
||||||
|
<select id="nmap-preset">
|
||||||
|
<option value="quick">Quick Scan (-sT -T4)</option>
|
||||||
|
<option value="service">Service Detection (-sT -sV)</option>
|
||||||
|
<option value="os">OS Detection (-sT -O) *privileged</option>
|
||||||
|
<option value="full">Full Scan (-sT -sV -sC)</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="nmap-custom-args" placeholder="Custom nmap args..." spellcheck="false" hidden />
|
||||||
|
<button id="btn-nmap-scan" class="btn btn-primary">Scan</button>
|
||||||
|
<button id="btn-nmap-stop" class="btn btn-danger" disabled>Stop</button>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="chk-nmap-verbose" checked />
|
||||||
|
<span>Verbose</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="nmap-output" class="nmap-output"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div id="status-bar">
|
||||||
|
<span class="status-bar-item" id="status-bar-version">Argus</span>
|
||||||
|
<button id="btn-settings" class="status-bar-btn">Settings</button>
|
||||||
|
<span class="status-bar-item" id="privilege-status">unprivileged</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Traceroute Modal -->
|
||||||
|
<div id="traceroute-modal" class="modal-overlay">
|
||||||
|
<div class="modal-panel">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Traceroute to <span id="traceroute-target"></span></span>
|
||||||
|
<button id="traceroute-close" class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="traceroute-output" class="traceroute-output"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settings-modal" class="modal-overlay">
|
||||||
|
<div class="modal-panel" style="width: 480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Settings</span>
|
||||||
|
<button id="settings-close" class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="setting-update-url">Release API URL</label>
|
||||||
|
<input type="text" id="setting-update-url" placeholder="https://your-server.com/argus/update.json"
|
||||||
|
spellcheck="false" />
|
||||||
|
<p class="settings-hint">JSON file with version, url, and notes fields. Leave empty to disable.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-settings-save" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Context Menu -->
|
||||||
|
<div id="context-menu" class="context-menu" hidden>
|
||||||
|
<button class="ctx-item" data-action="copy-ip">Copy IP</button>
|
||||||
|
<button class="ctx-item" data-action="copy-hostname">Copy Hostname</button>
|
||||||
|
<div class="ctx-divider"></div>
|
||||||
|
<button class="ctx-item" data-action="open-http">Open HTTP</button>
|
||||||
|
<button class="ctx-item" data-action="open-https">Open HTTPS</button>
|
||||||
|
<div class="ctx-divider"></div>
|
||||||
|
<button class="ctx-item" data-action="traceroute">Traceroute</button>
|
||||||
|
<div class="ctx-divider"></div>
|
||||||
|
<button class="ctx-item" data-action="send-monitor">Send to Monitor</button>
|
||||||
|
<button class="ctx-item" data-action="send-nmap">Send to Nmap</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||