feat: network scanner + SFTP context menu + CI fix
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:
Vantz Stockwell 2026-03-24 23:56:42 -04:00
parent 4532f3beb6
commit 2d0964f6b2
6 changed files with 356 additions and 0 deletions

View File

@ -2,6 +2,7 @@
name = "wraith"
version = "0.1.0"
edition = "2024"
default-run = "wraith"
[lib]
name = "wraith_lib"

View File

@ -8,3 +8,4 @@ pub mod rdp_commands;
pub mod theme_commands;
pub mod pty_commands;
pub mod mcp_commands;
pub mod scanner_commands;

View 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
}

View File

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

View 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)
}

View File

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