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
+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)
}
}