From 5cc412a251b3447809b6b0689c31d54c63067613 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Wed, 25 Mar 2026 00:03:34 -0400 Subject: [PATCH] feat: Tools menu + backend commands for all tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools menu in toolbar (next to File) with 7 tools: - Network Scanner (existing scan_network command) - Port Scanner (existing scan_ports/quick_scan commands) - Ping — via SSH exec channel, cross-platform - Traceroute — via SSH exec channel - Wake on LAN — broadcasts WoL magic packet via python3 on remote host - SSH Key Generator — pure Rust ed25519/RSA keygen via ssh-key crate - Password Generator — cryptographic random with configurable charset Backend: all 5 new Tauri commands (tool_ping, tool_traceroute, tool_wake_on_lan, tool_generate_ssh_key, tool_generate_password) Frontend: Tools dropdown menu wired, popup window launcher ready. Tool window UIs (the actual panels inside each popup) to follow. SFTP context menu: right-click Edit/Download/Rename/Delete working. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/tools_commands.rs | 195 +++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/layouts/MainLayout.vue | 105 ++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src-tauri/src/commands/tools_commands.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ead052..634f985 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,3 +9,4 @@ pub mod theme_commands; pub mod pty_commands; pub mod mcp_commands; pub mod scanner_commands; +pub mod tools_commands; diff --git a/src-tauri/src/commands/tools_commands.rs b/src-tauri/src/commands/tools_commands.rs new file mode 100644 index 0000000..c1bebda --- /dev/null +++ b/src-tauri/src/commands/tools_commands.rs @@ -0,0 +1,195 @@ +//! Tauri commands for built-in tools: ping, traceroute, WoL, keygen, passgen. + +use tauri::State; +use serde::Serialize; + +use crate::AppState; + +// ── Ping ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PingResult { + pub target: String, + pub output: String, +} + +/// Ping a host through an SSH session's exec channel. +#[tauri::command] +pub async fn tool_ping( + session_id: String, + target: String, + count: Option, + state: State<'_, AppState>, +) -> Result { + let session = state.ssh.get_session(&session_id) + .ok_or_else(|| format!("SSH session {} not found", session_id))?; + let n = count.unwrap_or(4); + let cmd = format!("ping -c {} {} 2>&1", n, target); + let output = exec_on_session(&session.handle, &cmd).await?; + Ok(PingResult { target, output }) +} + +/// Traceroute through an SSH session's exec channel. +#[tauri::command] +pub async fn tool_traceroute( + session_id: String, + target: String, + state: State<'_, AppState>, +) -> Result { + let session = state.ssh.get_session(&session_id) + .ok_or_else(|| format!("SSH session {} not found", session_id))?; + let cmd = format!("traceroute {} 2>&1 || tracert {} 2>&1", target, target); + exec_on_session(&session.handle, &cmd).await +} + +// ── Wake on LAN ────────────────────────────────────────────────────────────── + +/// Send a Wake-on-LAN magic packet through an SSH session. +/// The remote host broadcasts the WoL packet on its local network. +#[tauri::command] +pub async fn tool_wake_on_lan( + session_id: String, + mac_address: String, + state: State<'_, AppState>, +) -> Result { + let session = state.ssh.get_session(&session_id) + .ok_or_else(|| format!("SSH session {} not found", session_id))?; + + // Build WoL magic packet as a shell one-liner using python or perl (widely available) + let mac_clean = mac_address.replace([':', '-'], ""); + if mac_clean.len() != 12 || !mac_clean.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("Invalid MAC address: {}", mac_address)); + } + + let cmd = format!( + r#"python3 -c " +import socket, struct +mac = bytes.fromhex('{mac_clean}') +pkt = b'\xff'*6 + mac*16 +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) +s.sendto(pkt, ('255.255.255.255', 9)) +s.close() +print('WoL packet sent to {mac_address}') +" 2>&1 || echo "python3 not available — install python3 on remote host for WoL""# + ); + + exec_on_session(&session.handle, &cmd).await +} + +// ── SSH Key Generator ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedKey { + pub private_key: String, + pub public_key: String, + pub fingerprint: String, + pub key_type: String, +} + +/// Generate an SSH key pair locally (no SSH session needed). +#[tauri::command] +pub fn tool_generate_ssh_key( + key_type: String, + comment: Option, +) -> Result { + use ssh_key::{Algorithm, HashAlg, LineEnding}; + + let comment_str = comment.unwrap_or_else(|| "wraith-generated".to_string()); + + let algorithm = match key_type.to_lowercase().as_str() { + "ed25519" => Algorithm::Ed25519, + "rsa" | "rsa-2048" => Algorithm::Rsa { hash: Some(ssh_key::HashAlg::Sha256) }, + "rsa-4096" => Algorithm::Rsa { hash: Some(ssh_key::HashAlg::Sha256) }, + _ => return Err(format!("Unsupported key type: {}. Use ed25519 or rsa", key_type)), + }; + + let private_key = ssh_key::PrivateKey::random(&mut ssh_key::rand_core::OsRng, algorithm) + .map_err(|e| format!("Key generation failed: {}", e))?; + + let private_pem = private_key.to_openssh(LineEnding::LF) + .map_err(|e| format!("Failed to encode private key: {}", e))?; + + let public_key = private_key.public_key(); + let public_openssh = public_key.to_openssh() + .map_err(|e| format!("Failed to encode public key: {}", e))?; + + let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string(); + + Ok(GeneratedKey { + private_key: private_pem.to_string(), + public_key: format!("{} {}", public_openssh, comment_str), + fingerprint, + key_type: key_type.to_lowercase(), + }) +} + +// ── Password Generator ─────────────────────────────────────────────────────── + +/// Generate a cryptographically secure random password. +#[tauri::command] +pub fn tool_generate_password( + length: Option, + uppercase: Option, + lowercase: Option, + digits: Option, + symbols: Option, +) -> Result { + use rand::Rng; + + let len = length.unwrap_or(20).max(4).min(128); + let use_upper = uppercase.unwrap_or(true); + let use_lower = lowercase.unwrap_or(true); + let use_digits = digits.unwrap_or(true); + let use_symbols = symbols.unwrap_or(true); + + let mut charset = String::new(); + if use_upper { charset.push_str("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); } + if use_lower { charset.push_str("abcdefghijklmnopqrstuvwxyz"); } + if use_digits { charset.push_str("0123456789"); } + if use_symbols { charset.push_str("!@#$%^&*()-_=+[]{}|;:,.<>?"); } + + if charset.is_empty() { + return Err("At least one character class must be enabled".to_string()); + } + + let chars: Vec = charset.chars().collect(); + let mut rng = rand::rng(); + let password: String = (0..len) + .map(|_| chars[rng.random_range(0..chars.len())]) + .collect(); + + Ok(password) +} + +// ── Helper ─────────────────────────────────────────────────────────────────── + +async fn exec_on_session( + handle: &std::sync::Arc>>, + cmd: &str, +) -> Result { + let mut channel = { + let h = handle.lock().await; + h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))? + }; + + channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?; + + let mut output = String::new(); + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Data { ref data }) => { + if let Ok(text) = std::str::from_utf8(data.as_ref()) { + output.push_str(text); + } + } + Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, + Some(russh::ChannelMsg::ExitStatus { .. }) => {} + _ => {} + } + } + + Ok(output) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6acce0..ffd1436 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,7 @@ pub fn run() { commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty, commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context, commands::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan, + commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 515187b..628e445 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -50,6 +50,66 @@ + + +
+ +
+ + + + +
+ +
+ + +
+
@@ -230,11 +290,56 @@ const sessionContainer = ref | null>(null) interface EditorFile { path: string; content: string; sessionId: string; } const editorFile = ref(null); const showFileMenu = ref(false); +const showToolsMenu = ref(false); function closeFileMenuDeferred(): void { setTimeout(() => { showFileMenu.value = false; }, 150); } +function closeToolsMenuDeferred(): void { + setTimeout(() => { showToolsMenu.value = false; }, 150); +} + +async function handleToolAction(tool: string): Promise { + showToolsMenu.value = false; + + // Tools that don't need a session + const localTools = ["ssh-keygen", "password-gen"]; + + if (!localTools.includes(tool) && !activeSessionId.value) { + alert("Connect to a server first — network tools run through SSH sessions."); + return; + } + + const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow"); + + const toolConfig: Record = { + "network-scanner": { title: "Network Scanner", width: 800, height: 600 }, + "port-scanner": { title: "Port Scanner", width: 700, height: 500 }, + "ping": { title: "Ping", width: 600, height: 400 }, + "traceroute": { title: "Traceroute", width: 600, height: 500 }, + "wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 }, + "ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 }, + "password-gen": { title: "Password Generator", width: 500, height: 400 }, + }; + + const config = toolConfig[tool]; + if (!config) return; + + const sessionId = activeSessionId.value || ""; + + // Open tool in a new Tauri window + const label = `tool-${tool}-${Date.now()}`; + new WebviewWindow(label, { + title: `Wraith — ${config.title}`, + width: config.width, + height: config.height, + resizable: true, + center: true, + url: `index.html#/tool/${tool}?sessionId=${sessionId}`, + }); +} + async function handleFileMenuAction(action: string): Promise { showFileMenu.value = false; switch (action) {