feat: network scanner + SFTP context menu + CI fix
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m7s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m7s
Network scanner (through SSH exec channels): - scan_network: ping sweep + ARP table + reverse DNS on remote network - scan_ports: TCP connect scan via bash /dev/tcp (parallel batches of 20) - quick_scan: 24 common ports (SSH, HTTP, RDP, SMB, DB, etc.) - Cross-platform: Linux + macOS - No agent/nmap required — uses standard POSIX commands - All scans run on the remote host through existing SSH tunnel SFTP context menu: - Right-click on files/folders shows Edit, Download, Rename, Delete - Right-click on folders shows Open Folder - Teleport menu to body for proper z-index layering - Click-away handler to close menu - Rename uses sftp_rename invoke CI fix: - Added default-run = "wraith" to Cargo.toml - The [[bin]] entry for wraith-mcp-bridge confused Cargo about which binary is the Tauri app main binary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4532f3beb6
commit
2d0964f6b2
@ -2,6 +2,7 @@
|
||||
name = "wraith"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
default-run = "wraith"
|
||||
|
||||
[lib]
|
||||
name = "wraith_lib"
|
||||
|
||||
@ -8,3 +8,4 @@ pub mod rdp_commands;
|
||||
pub mod theme_commands;
|
||||
pub mod pty_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod scanner_commands;
|
||||
|
||||
44
src-tauri/src/commands/scanner_commands.rs
Normal file
44
src-tauri/src/commands/scanner_commands.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Tauri commands for network scanning through SSH sessions.
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::scanner::{self, DiscoveredHost, PortResult};
|
||||
use crate::AppState;
|
||||
|
||||
/// Discover hosts on the remote network via ARP + ping sweep.
|
||||
/// `subnet` should be the first 3 octets, e.g. "192.168.1"
|
||||
#[tauri::command]
|
||||
pub async fn scan_network(
|
||||
session_id: String,
|
||||
subnet: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<DiscoveredHost>, String> {
|
||||
let session = state.ssh.get_session(&session_id)
|
||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||
scanner::scan_network(&session.handle, &subnet).await
|
||||
}
|
||||
|
||||
/// Scan specific ports on a target host through an SSH session.
|
||||
#[tauri::command]
|
||||
pub async fn scan_ports(
|
||||
session_id: String,
|
||||
target: String,
|
||||
ports: Vec<u16>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<PortResult>, String> {
|
||||
let session = state.ssh.get_session(&session_id)
|
||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||
scanner::scan_ports(&session.handle, &target, &ports).await
|
||||
}
|
||||
|
||||
/// Quick scan of common ports (22, 80, 443, 3389, etc.) on a target.
|
||||
#[tauri::command]
|
||||
pub async fn quick_scan(
|
||||
session_id: String,
|
||||
target: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<PortResult>, String> {
|
||||
let session = state.ssh.get_session(&session_id)
|
||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||
scanner::quick_port_scan(&session.handle, &target).await
|
||||
}
|
||||
@ -10,6 +10,7 @@ pub mod theme;
|
||||
pub mod workspace;
|
||||
pub mod pty;
|
||||
pub mod mcp;
|
||||
pub mod scanner;
|
||||
pub mod commands;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@ -139,6 +140,7 @@ pub fn run() {
|
||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||
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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
222
src-tauri/src/scanner/mod.rs
Normal file
222
src-tauri/src/scanner/mod.rs
Normal file
@ -0,0 +1,222 @@
|
||||
//! Network scanner tools — IP discovery, port scanning, and network mapping
|
||||
//! through SSH exec channels. No agent installation required.
|
||||
//!
|
||||
//! All scans run on the REMOTE host through the existing SSH connection,
|
||||
//! giving visibility into the remote network without direct access.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use russh::client::Handle;
|
||||
use russh::ChannelMsg;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::ssh::session::SshClient;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiscoveredHost {
|
||||
pub ip: String,
|
||||
pub mac: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub open_ports: Vec<u16>,
|
||||
pub services: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PortResult {
|
||||
pub port: u16,
|
||||
pub open: bool,
|
||||
pub service: String,
|
||||
}
|
||||
|
||||
/// Well-known port → service name mapping for common ports.
|
||||
fn service_name(port: u16) -> &'static str {
|
||||
match port {
|
||||
21 => "FTP",
|
||||
22 => "SSH",
|
||||
23 => "Telnet",
|
||||
25 => "SMTP",
|
||||
53 => "DNS",
|
||||
80 => "HTTP",
|
||||
110 => "POP3",
|
||||
135 => "RPC",
|
||||
139 => "NetBIOS",
|
||||
143 => "IMAP",
|
||||
443 => "HTTPS",
|
||||
445 => "SMB",
|
||||
993 => "IMAPS",
|
||||
995 => "POP3S",
|
||||
1433 => "MSSQL",
|
||||
1521 => "Oracle",
|
||||
3306 => "MySQL",
|
||||
3389 => "RDP",
|
||||
5432 => "PostgreSQL",
|
||||
5900 => "VNC",
|
||||
6379 => "Redis",
|
||||
8080 => "HTTP-Alt",
|
||||
8443 => "HTTPS-Alt",
|
||||
27017 => "MongoDB",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover hosts on the remote network using ARP table and ping sweep.
|
||||
pub async fn scan_network(
|
||||
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||
subnet: &str,
|
||||
) -> Result<Vec<DiscoveredHost>, String> {
|
||||
// Script that works on Linux and macOS:
|
||||
// 1. Ping sweep the subnet to populate ARP cache
|
||||
// 2. Read ARP table for IP/MAC pairs
|
||||
// 3. Try reverse DNS for hostnames
|
||||
let script = format!(r#"
|
||||
OS=$(uname -s 2>/dev/null)
|
||||
SUBNET="{subnet}"
|
||||
|
||||
# Ping sweep (background, fast)
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
for i in $(seq 1 254); do
|
||||
ping -c 1 -W 1 "$SUBNET.$i" > /dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
elif [ "$OS" = "Darwin" ]; then
|
||||
for i in $(seq 1 254); do
|
||||
ping -c 1 -t 1 "$SUBNET.$i" > /dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
fi
|
||||
|
||||
# Read ARP table
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
arp -n 2>/dev/null | grep -v incomplete | awk 'NR>1 {{printf "%s|%s\n", $1, $3}}'
|
||||
elif [ "$OS" = "Darwin" ]; then
|
||||
arp -a 2>/dev/null | grep -v incomplete | awk '{{gsub(/[()]/, ""); printf "%s|%s\n", $2, $4}}'
|
||||
fi
|
||||
"#);
|
||||
|
||||
let output = exec_command(handle, &script).await
|
||||
.ok_or_else(|| "Failed to execute network scan".to_string())?;
|
||||
|
||||
let mut hosts = Vec::new();
|
||||
for line in output.lines() {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
if parts.len() >= 2 && !parts[0].is_empty() {
|
||||
let ip = parts[0].trim().to_string();
|
||||
let mac = if parts[1].trim().is_empty() || parts[1].trim() == "(incomplete)" {
|
||||
None
|
||||
} else {
|
||||
Some(parts[1].trim().to_string())
|
||||
};
|
||||
|
||||
hosts.push(DiscoveredHost {
|
||||
ip,
|
||||
mac,
|
||||
hostname: None,
|
||||
vendor: None,
|
||||
open_ports: Vec::new(),
|
||||
services: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Try reverse DNS for each host
|
||||
if !hosts.is_empty() {
|
||||
let ips: Vec<String> = hosts.iter().map(|h| h.ip.clone()).collect();
|
||||
let dns_script = ips.iter()
|
||||
.map(|ip| format!("echo \"{}|$(host {} 2>/dev/null | awk '/domain name pointer/ {{print $NF}}' | sed 's/\\.$//')\"", ip, ip))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
if let Some(dns_output) = exec_command(handle, &dns_script).await {
|
||||
for line in dns_output.lines() {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
if parts.len() >= 2 && !parts[1].is_empty() {
|
||||
if let Some(host) = hosts.iter_mut().find(|h| h.ip == parts[0]) {
|
||||
host.hostname = Some(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hosts)
|
||||
}
|
||||
|
||||
/// Scan specific ports on a target host through the SSH session.
|
||||
pub async fn scan_ports(
|
||||
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||
target: &str,
|
||||
ports: &[u16],
|
||||
) -> Result<Vec<PortResult>, String> {
|
||||
// Use bash /dev/tcp for port scanning — no nmap required
|
||||
let port_checks: Vec<String> = ports.iter()
|
||||
.map(|p| format!(
|
||||
"(echo >/dev/tcp/{target}/{p}) 2>/dev/null && echo \"{p}|open\" || echo \"{p}|closed\""
|
||||
))
|
||||
.collect();
|
||||
|
||||
// Run in parallel batches of 20 for speed
|
||||
let mut results = Vec::new();
|
||||
for chunk in port_checks.chunks(20) {
|
||||
let script = chunk.join(" &\n") + " &\nwait";
|
||||
let output = exec_command(handle, &script).await
|
||||
.ok_or_else(|| "Port scan exec failed".to_string())?;
|
||||
|
||||
for line in output.lines() {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
if parts.len() >= 2 {
|
||||
if let Ok(port) = parts[0].parse::<u16>() {
|
||||
results.push(PortResult {
|
||||
port,
|
||||
open: parts[1] == "open",
|
||||
service: service_name(port).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by_key(|r| r.port);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Quick scan of common ports on a target.
|
||||
pub async fn quick_port_scan(
|
||||
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||
target: &str,
|
||||
) -> Result<Vec<PortResult>, String> {
|
||||
let common_ports: Vec<u16> = vec![
|
||||
21, 22, 23, 25, 53, 80, 110, 135, 139, 143,
|
||||
443, 445, 993, 995, 1433, 1521, 3306, 3389,
|
||||
5432, 5900, 6379, 8080, 8443, 27017,
|
||||
];
|
||||
scan_ports(handle, target, &common_ports).await
|
||||
}
|
||||
|
||||
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
|
||||
let mut channel = {
|
||||
let h = handle.lock().await;
|
||||
h.channel_open_session().await.ok()?
|
||||
};
|
||||
|
||||
channel.exec(true, cmd).await.ok()?;
|
||||
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(ChannelMsg::Data { ref data }) => {
|
||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
||||
output.push_str(text);
|
||||
}
|
||||
}
|
||||
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
|
||||
Some(ChannelMsg::ExitStatus { .. }) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
@ -98,6 +98,7 @@
|
||||
:class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }"
|
||||
@click="selectedEntry = entry"
|
||||
@dblclick="handleEntryDblClick(entry)"
|
||||
@contextmenu.prevent="openContextMenu($event, entry)"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<svg
|
||||
@ -136,6 +137,62 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
|
||||
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||
@click="contextMenu.visible = false"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button
|
||||
v-if="!contextMenu.entry?.isDir"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||
@click="handleEdit(contextMenu.entry!)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
v-if="!contextMenu.entry?.isDir"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||
@click="selectedEntry = contextMenu.entry!; handleDownload()"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.entry?.isDir"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||
@click="navigateTo(contextMenu.entry!.path)"
|
||||
>
|
||||
Open Folder
|
||||
</button>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||
@click="handleRename(contextMenu.entry!)"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
|
||||
@click="selectedEntry = contextMenu.entry!; handleDelete()"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Click-away handler to close context menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="fixed inset-0 z-[99]"
|
||||
@click="contextMenu.visible = false"
|
||||
@contextmenu.prevent="contextMenu.visible = false"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Follow terminal toggle -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
|
||||
@ -170,6 +227,35 @@ const { addTransfer, completeTransfer, failTransfer } = useTransfers();
|
||||
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
||||
const selectedEntry = ref<FileEntry | null>(null);
|
||||
|
||||
/** Right-click context menu state. */
|
||||
const contextMenu = ref<{ visible: boolean; x: number; y: number; entry: FileEntry | null }>({
|
||||
visible: false, x: 0, y: 0, entry: null,
|
||||
});
|
||||
|
||||
function openContextMenu(event: MouseEvent, entry: FileEntry): void {
|
||||
selectedEntry.value = entry;
|
||||
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, entry };
|
||||
}
|
||||
|
||||
function handleEdit(entry: FileEntry): void {
|
||||
emit("openFile", entry);
|
||||
}
|
||||
|
||||
async function handleRename(entry: FileEntry): Promise<void> {
|
||||
const newName = prompt("Rename to:", entry.name);
|
||||
if (!newName || !newName.trim() || newName.trim() === entry.name) return;
|
||||
|
||||
const parentPath = entry.path.substring(0, entry.path.lastIndexOf("/"));
|
||||
const newPath = parentPath + "/" + newName.trim();
|
||||
|
||||
try {
|
||||
await invoke("sftp_rename", { sessionId: props.sessionId, oldPath: entry.path, newPath });
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
console.error("SFTP rename error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hidden file input element used for the upload flow. */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user