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 pty_commands;
|
||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
pub mod scanner_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::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::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::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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -50,6 +50,66 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Connect -->
|
<!-- Quick Connect -->
|
||||||
@ -230,11 +290,56 @@ const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null)
|
|||||||
interface EditorFile { path: string; content: string; sessionId: string; }
|
interface EditorFile { path: string; content: string; sessionId: string; }
|
||||||
const editorFile = ref<EditorFile | null>(null);
|
const editorFile = ref<EditorFile | null>(null);
|
||||||
const showFileMenu = ref(false);
|
const showFileMenu = ref(false);
|
||||||
|
const showToolsMenu = ref(false);
|
||||||
|
|
||||||
function closeFileMenuDeferred(): void {
|
function closeFileMenuDeferred(): void {
|
||||||
setTimeout(() => { showFileMenu.value = false; }, 150);
|
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> {
|
async function handleFileMenuAction(action: string): Promise<void> {
|
||||||
showFileMenu.value = false;
|
showFileMenu.value = false;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user