diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f8746d..1563a57 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,6 +2,7 @@ name = "wraith" version = "0.1.0" edition = "2024" +default-run = "wraith" [lib] name = "wraith_lib" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3280833..2ead052 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,3 +8,4 @@ pub mod rdp_commands; pub mod theme_commands; pub mod pty_commands; pub mod mcp_commands; +pub mod scanner_commands; diff --git a/src-tauri/src/commands/scanner_commands.rs b/src-tauri/src/commands/scanner_commands.rs new file mode 100644 index 0000000..85cf304 --- /dev/null +++ b/src-tauri/src/commands/scanner_commands.rs @@ -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, 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, + state: State<'_, AppState>, +) -> Result, 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, 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 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6f9ebe1..e6acce0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/scanner/mod.rs b/src-tauri/src/scanner/mod.rs new file mode 100644 index 0000000..1698ec5 --- /dev/null +++ b/src-tauri/src/scanner/mod.rs @@ -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, + pub hostname: Option, + pub vendor: Option, + pub open_ports: Vec, + pub services: Vec, +} + +#[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>>, + subnet: &str, +) -> Result, 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 = 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::>() + .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>>, + target: &str, + ports: &[u16], +) -> Result, String> { + // Use bash /dev/tcp for port scanning — no nmap required + let port_checks: Vec = 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::() { + 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>>, + target: &str, +) -> Result, String> { + let common_ports: Vec = 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>>, cmd: &str) -> Option { + 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) +} diff --git a/src/components/sftp/FileTree.vue b/src/components/sftp/FileTree.vue index 1341146..0bb9523 100644 --- a/src/components/sftp/FileTree.vue +++ b/src/components/sftp/FileTree.vue @@ -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)" > + + +
+ + + + +
+ +
+ + + + +
+ +