feat: Tools menu + backend commands for all tools
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m2s
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:
parent
2d0964f6b2
commit
5cc412a251
@ -9,3 +9,4 @@ pub mod theme_commands;
|
||||
pub mod pty_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod scanner_commands;
|
||||
pub mod tools_commands;
|
||||
|
||||
195
src-tauri/src/commands/tools_commands.rs
Normal file
195
src-tauri/src/commands/tools_commands.rs
Normal 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)
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user