feat: Tools menu + backend commands for all tools
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m2s

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) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 00:03:34 -04:00
parent 2d0964f6b2
commit 5cc412a251
4 changed files with 302 additions and 0 deletions

View File

@ -9,3 +9,4 @@ pub mod theme_commands;
pub mod pty_commands;
pub mod mcp_commands;
pub mod scanner_commands;
pub mod tools_commands;

View File

@ -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<u32>,
state: State<'_, AppState>,
) -> Result<PingResult, String> {
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<String, String> {
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<String, String> {
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<String>,
) -> Result<GeneratedKey, String> {
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<usize>,
uppercase: Option<bool>,
lowercase: Option<bool>,
digits: Option<bool>,
symbols: Option<bool>,
) -> Result<String, String> {
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<char> = 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<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
cmd: &str,
) -> Result<String, String> {
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)
}

View File

@ -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");

View File

@ -50,6 +50,66 @@
</button>
</div>
</div>
<!-- Tools menu -->
<div class="relative">
<button
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
@click="showToolsMenu = !showToolsMenu"
@blur="closeToolsMenuDeferred"
>
Tools
</button>
<div
v-if="showToolsMenu"
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('network-scanner')"
>
<span class="flex-1">Network Scanner</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('port-scanner')"
>
<span class="flex-1">Port Scanner</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('ping')"
>
<span class="flex-1">Ping</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('traceroute')"
>
<span class="flex-1">Traceroute</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('wake-on-lan')"
>
<span class="flex-1">Wake on LAN</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('ssh-keygen')"
>
<span class="flex-1">SSH Key Generator</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('password-gen')"
>
<span class="flex-1">Password Generator</span>
</button>
</div>
</div>
</div>
<!-- Quick Connect -->
@ -230,11 +290,56 @@ const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null)
interface EditorFile { path: string; content: string; sessionId: string; }
const editorFile = ref<EditorFile | null>(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<void> {
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<string, { title: string; width: number; height: number }> = {
"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<void> {
showFileMenu.value = false;
switch (action) {