feat: Tools R2 — DNS, Whois, Bandwidth, Subnet Calculator
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 7s

4 new tools with full backend + popup UIs:

DNS Lookup:
- dig/nslookup/host fallback chain on remote host
- Record type selector (A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR)

Whois:
- Remote whois query, first 80 lines
- Works for domains and IP addresses

Bandwidth Test (2 modes):
- iperf3: LAN speed test between remote host and iperf server
- Internet: speedtest-cli / curl-based Cloudflare test fallback

Subnet Calculator:
- Pure Rust, no SSH needed
- CIDR input with quick-select buttons (/8 through /32)
- Displays: network, broadcast, netmask, wildcard, host range,
  total/usable hosts, class, private/public

Tools menu now has 11 items across 3 sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 00:12:11 -04:00
parent 875dd1a28f
commit b3f56a2729
9 changed files with 390 additions and 1 deletions

View File

@ -10,3 +10,4 @@ pub mod pty_commands;
pub mod mcp_commands; pub mod mcp_commands;
pub mod scanner_commands; pub mod scanner_commands;
pub mod tools_commands; pub mod tools_commands;
pub mod tools_commands_r2;

View File

@ -0,0 +1,201 @@
//! Tauri commands for Tools Round 2: DNS, Whois, Bandwidth, Subnet Calculator.
use tauri::State;
use serde::Serialize;
use crate::AppState;
// ── DNS Lookup ───────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn tool_dns_lookup(
session_id: String,
domain: String,
record_type: Option<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 rtype = record_type.unwrap_or_else(|| "A".to_string());
let cmd = format!(
r#"dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null"#,
domain, rtype, rtype, domain, rtype, domain
);
exec_on_session(&session.handle, &cmd).await
}
// ── Whois ────────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn tool_whois(
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!("whois {} 2>&1 | head -80", target);
exec_on_session(&session.handle, &cmd).await
}
// ── Bandwidth Test ───────────────────────────────────────────────────────────
#[tauri::command]
pub async fn tool_bandwidth_iperf(
session_id: String,
server: String,
duration: Option<u32>,
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 dur = duration.unwrap_or(5);
let cmd = format!(
"iperf3 -c {} -t {} --json 2>/dev/null || iperf3 -c {} -t {} 2>&1 || echo 'iperf3 not installed — run: apt install iperf3 / brew install iperf3'",
server, dur, server, dur
);
exec_on_session(&session.handle, &cmd).await
}
#[tauri::command]
pub async fn tool_bandwidth_speedtest(
session_id: 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))?;
// Try multiple speedtest tools in order of preference
let cmd = r#"
if command -v speedtest-cli >/dev/null 2>&1; then
speedtest-cli --simple 2>&1
elif command -v speedtest >/dev/null 2>&1; then
speedtest --simple 2>&1
elif command -v curl >/dev/null 2>&1; then
echo "=== Download speed (curl) ==="
curl -o /dev/null -w "Download: %{speed_download} bytes/sec (%{size_download} bytes in %{time_total}s)\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null
echo "=== Upload speed (curl) ==="
dd if=/dev/zero bs=1M count=10 2>/dev/null | curl -X POST -o /dev/null -w "Upload: %{speed_upload} bytes/sec (%{size_upload} bytes in %{time_total}s)\n" -d @- https://speed.cloudflare.com/__up 2>/dev/null
else
echo "No speedtest tool found. Install: pip install speedtest-cli"
fi
"#;
exec_on_session(&session.handle, cmd).await
}
// ── Subnet Calculator ────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubnetInfo {
pub cidr: String,
pub network: String,
pub broadcast: String,
pub netmask: String,
pub wildcard: String,
pub first_host: String,
pub last_host: String,
pub total_hosts: u64,
pub usable_hosts: u64,
pub prefix_length: u8,
pub class: String,
pub is_private: bool,
}
/// Pure Rust subnet calculator — no SSH session needed.
#[tauri::command]
pub fn tool_subnet_calc(
cidr: String,
) -> Result<SubnetInfo, String> {
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return Err("Expected CIDR notation: e.g. 192.168.1.0/24".to_string());
}
let ip_str = parts[0];
let prefix: u8 = parts[1].parse()
.map_err(|_| format!("Invalid prefix length: {}", parts[1]))?;
if prefix > 32 {
return Err(format!("Prefix length must be 0-32, got {}", prefix));
}
let octets: Vec<u8> = ip_str.split('.')
.map(|o| o.parse::<u8>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| format!("Invalid IP address: {}", ip_str))?;
if octets.len() != 4 {
return Err(format!("Invalid IP address: {}", ip_str));
}
let ip: u32 = (octets[0] as u32) << 24
| (octets[1] as u32) << 16
| (octets[2] as u32) << 8
| (octets[3] as u32);
let mask: u32 = if prefix == 0 { 0 } else { !0u32 << (32 - prefix) };
let wildcard = !mask;
let network = ip & mask;
let broadcast = network | wildcard;
let first_host = if prefix >= 31 { network } else { network + 1 };
let last_host = if prefix >= 31 { broadcast } else { broadcast - 1 };
let total: u64 = 1u64 << (32 - prefix as u64);
let usable = if prefix >= 31 { total } else { total - 2 };
let class = match octets[0] {
0..=127 => "A",
128..=191 => "B",
192..=223 => "C",
224..=239 => "D (Multicast)",
_ => "E (Reserved)",
};
let is_private = matches!(
(octets[0], octets[1]),
(10, _) | (172, 16..=31) | (192, 168)
);
Ok(SubnetInfo {
cidr: format!("{}/{}", to_ip(network), prefix),
network: to_ip(network),
broadcast: to_ip(broadcast),
netmask: to_ip(mask),
wildcard: to_ip(wildcard),
first_host: to_ip(first_host),
last_host: to_ip(last_host),
total_hosts: total,
usable_hosts: usable,
prefix_length: prefix,
class: class.to_string(),
is_private,
})
}
fn to_ip(val: u32) -> String {
format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
}
// ── 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)
}

View File

@ -142,6 +142,7 @@ pub fn run() {
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, 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,
commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -0,0 +1,44 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<div class="flex items-center gap-2">
<select v-model="mode" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
<option value="speedtest">Internet Speed Test</option>
<option value="iperf">iperf3 (LAN)</option>
</select>
<template v-if="mode === 'iperf'">
<input v-model="server" type="text" placeholder="iperf3 server IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-40" />
<input v-model.number="duration" type="number" min="1" max="60" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
<span class="text-xs text-[#484f58]">sec</span>
</template>
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run">
{{ running ? "Testing..." : "Run Test" }}
</button>
</div>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Select a mode and click Run Test" }}</pre>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
const mode = ref("speedtest");
const server = ref("");
const duration = ref(5);
const output = ref("");
const running = ref(false);
async function run(): Promise<void> {
running.value = true;
output.value = mode.value === "iperf" ? `Running iperf3 to ${server.value}...\n` : "Running speed test...\n";
try {
if (mode.value === "iperf") {
if (!server.value) { output.value = "Enter an iperf3 server IP"; running.value = false; return; }
output.value = await invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
} else {
output.value = await invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
}
} catch (err) { output.value = String(err); }
running.value = false;
}
</script>

View File

@ -0,0 +1,31 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<div class="flex items-center gap-2">
<input v-model="domain" type="text" placeholder="Domain name" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
<select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
<option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option>
</select>
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button>
</div>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain and click Lookup" }}</pre>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
const domain = ref("");
const recordType = ref("A");
const output = ref("");
const running = ref(false);
async function lookup(): Promise<void> {
if (!domain.value) return;
running.value = true;
try {
output.value = await invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value });
} catch (err) { output.value = String(err); }
running.value = false;
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<div class="flex flex-col h-full p-4 gap-4">
<div class="flex items-center gap-2">
<input v-model="cidr" type="text" placeholder="192.168.1.0/24" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-48 font-mono" @keydown.enter="calc" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer" @click="calc">Calculate</button>
<div class="flex items-center gap-1 ml-2">
<button v-for="quick in ['/8','/16','/24','/25','/26','/27','/28','/29','/30','/32']" :key="quick"
class="px-1.5 py-0.5 text-[10px] rounded bg-[#21262d] text-[#8b949e] hover:text-white hover:bg-[#30363d] cursor-pointer"
@click="cidr = cidr.replace(/\/\d+$/, '') + quick; calc()"
>{{ quick }}</button>
</div>
</div>
<div v-if="info" class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
<div><span class="text-[#8b949e]">CIDR:</span> <span class="font-mono">{{ info.cidr }}</span></div>
<div><span class="text-[#8b949e]">Class:</span> {{ info.class }} <span v-if="info.isPrivate" class="text-[#3fb950]">(Private)</span></div>
<div><span class="text-[#8b949e]">Network:</span> <span class="font-mono">{{ info.network }}</span></div>
<div><span class="text-[#8b949e]">Broadcast:</span> <span class="font-mono">{{ info.broadcast }}</span></div>
<div><span class="text-[#8b949e]">Netmask:</span> <span class="font-mono">{{ info.netmask }}</span></div>
<div><span class="text-[#8b949e]">Wildcard:</span> <span class="font-mono">{{ info.wildcard }}</span></div>
<div><span class="text-[#8b949e]">First Host:</span> <span class="font-mono">{{ info.firstHost }}</span></div>
<div><span class="text-[#8b949e]">Last Host:</span> <span class="font-mono">{{ info.lastHost }}</span></div>
<div><span class="text-[#8b949e]">Total Hosts:</span> {{ info.totalHosts.toLocaleString() }}</div>
<div><span class="text-[#8b949e]">Usable Hosts:</span> {{ info.usableHosts.toLocaleString() }}</div>
<div><span class="text-[#8b949e]">Prefix Length:</span> /{{ info.prefixLength }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const cidr = ref("192.168.1.0/24");
interface SubnetInfo {
cidr: string; network: string; broadcast: string; netmask: string; wildcard: string;
firstHost: string; lastHost: string; totalHosts: number; usableHosts: number;
prefixLength: number; class: string; isPrivate: boolean;
}
const info = ref<SubnetInfo | null>(null);
async function calc(): Promise<void> {
if (!cidr.value) return;
try { info.value = await invoke<SubnetInfo>("tool_subnet_calc", { cidr: cidr.value }); }
catch (err) { alert(err); }
}
</script>

View File

@ -5,6 +5,10 @@
<PingTool v-else-if="tool === 'ping'" :session-id="sessionId" /> <PingTool v-else-if="tool === 'ping'" :session-id="sessionId" />
<TracerouteTool v-else-if="tool === 'traceroute'" :session-id="sessionId" /> <TracerouteTool v-else-if="tool === 'traceroute'" :session-id="sessionId" />
<WakeOnLan v-else-if="tool === 'wake-on-lan'" :session-id="sessionId" /> <WakeOnLan v-else-if="tool === 'wake-on-lan'" :session-id="sessionId" />
<DnsLookup v-else-if="tool === 'dns-lookup'" :session-id="sessionId" />
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
<SshKeyGen v-else-if="tool === 'ssh-keygen'" /> <SshKeyGen v-else-if="tool === 'ssh-keygen'" />
<PasswordGen v-else-if="tool === 'password-gen'" /> <PasswordGen v-else-if="tool === 'password-gen'" />
<div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]"> <div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]">
@ -19,6 +23,10 @@ import PortScanner from "./PortScanner.vue";
import PingTool from "./PingTool.vue"; import PingTool from "./PingTool.vue";
import TracerouteTool from "./TracerouteTool.vue"; import TracerouteTool from "./TracerouteTool.vue";
import WakeOnLan from "./WakeOnLan.vue"; import WakeOnLan from "./WakeOnLan.vue";
import DnsLookup from "./DnsLookup.vue";
import WhoisTool from "./WhoisTool.vue";
import BandwidthTest from "./BandwidthTest.vue";
import SubnetCalc from "./SubnetCalc.vue";
import SshKeyGen from "./SshKeyGen.vue"; import SshKeyGen from "./SshKeyGen.vue";
import PasswordGen from "./PasswordGen.vue"; import PasswordGen from "./PasswordGen.vue";

View File

@ -0,0 +1,26 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<div class="flex items-center gap-2">
<input v-model="target" type="text" placeholder="Domain or IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button>
</div>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain or IP and click Whois" }}</pre>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
const target = ref("");
const output = ref("");
const running = ref(false);
async function lookup(): Promise<void> {
if (!target.value) return;
running.value = true;
try { output.value = await invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value }); }
catch (err) { output.value = String(err); }
running.value = false;
}
</script>

View File

@ -88,6 +88,30 @@
> >
<span class="flex-1">Traceroute</span> <span class="flex-1">Traceroute</span>
</button> </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('dns-lookup')"
>
<span class="flex-1">DNS Lookup</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('whois')"
>
<span class="flex-1">Whois</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('bandwidth')"
>
<span class="flex-1">Bandwidth Test</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('subnet-calc')"
>
<span class="flex-1">Subnet Calculator</span>
</button>
<div class="border-t border-[#30363d] my-1" /> <div class="border-t border-[#30363d] my-1" />
<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" 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"
@ -304,7 +328,7 @@ async function handleToolAction(tool: string): Promise<void> {
showToolsMenu.value = false; showToolsMenu.value = false;
// Tools that don't need a session // Tools that don't need a session
const localTools = ["ssh-keygen", "password-gen"]; const localTools = ["ssh-keygen", "password-gen", "subnet-calc"];
if (!localTools.includes(tool) && !activeSessionId.value) { if (!localTools.includes(tool) && !activeSessionId.value) {
alert("Connect to a server first — network tools run through SSH sessions."); alert("Connect to a server first — network tools run through SSH sessions.");
@ -318,6 +342,10 @@ async function handleToolAction(tool: string): Promise<void> {
"port-scanner": { title: "Port Scanner", width: 700, height: 500 }, "port-scanner": { title: "Port Scanner", width: 700, height: 500 },
"ping": { title: "Ping", width: 600, height: 400 }, "ping": { title: "Ping", width: 600, height: 400 },
"traceroute": { title: "Traceroute", width: 600, height: 500 }, "traceroute": { title: "Traceroute", width: 600, height: 500 },
"dns-lookup": { title: "DNS Lookup", width: 600, height: 400 },
"whois": { title: "Whois", width: 700, height: 500 },
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 }, "wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 }, "ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
"password-gen": { title: "Password Generator", width: 500, height: 400 }, "password-gen": { title: "Password Generator", width: 500, height: 400 },