All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m44s
Was only setting the remote clipboard without pasting. Now sends clipboard content then simulates Ctrl+V (scancode 0x001D + 0x002F) with 50ms delay for clipboard propagation. Works for any text including special characters and multi-line content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
590 lines
26 KiB
Rust
590 lines
26 KiB
Rust
//! Tiny HTTP server for MCP bridge communication.
|
|
//!
|
|
//! Runs on localhost:0 (random port) at Tauri startup. The port is written
|
|
//! to ~/.wraith/mcp-port so the bridge binary can find it.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{extract::State as AxumState, routing::post, Json, Router};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::net::TcpListener;
|
|
|
|
use crate::mcp::ScrollbackRegistry;
|
|
use crate::rdp::RdpService;
|
|
use crate::sftp::SftpService;
|
|
use crate::ssh::session::SshService;
|
|
|
|
/// Shared state passed to axum handlers.
|
|
pub struct McpServerState {
|
|
pub ssh: SshService,
|
|
pub rdp: RdpService,
|
|
pub sftp: SftpService,
|
|
pub scrollback: ScrollbackRegistry,
|
|
pub app_handle: tauri::AppHandle,
|
|
pub error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TerminalReadRequest {
|
|
session_id: String,
|
|
lines: Option<usize>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ScreenshotRequest {
|
|
session_id: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SftpListRequest {
|
|
session_id: String,
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SftpReadRequest {
|
|
session_id: String,
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SftpWriteRequest {
|
|
session_id: String,
|
|
path: String,
|
|
content: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TerminalTypeRequest {
|
|
session_id: String,
|
|
text: String,
|
|
press_enter: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TerminalExecuteRequest {
|
|
session_id: String,
|
|
command: String,
|
|
timeout_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct McpResponse<T: Serialize> {
|
|
ok: bool,
|
|
data: Option<T>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
fn ok_response<T: Serialize>(data: T) -> Json<McpResponse<T>> {
|
|
Json(McpResponse { ok: true, data: Some(data), error: None })
|
|
}
|
|
|
|
fn err_response<T: Serialize>(msg: String) -> Json<McpResponse<T>> {
|
|
Json(McpResponse { ok: false, data: None, error: Some(msg) })
|
|
}
|
|
|
|
async fn handle_list_sessions(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
) -> Json<McpResponse<Vec<serde_json::Value>>> {
|
|
let mut sessions: Vec<serde_json::Value> = state.ssh.list_sessions()
|
|
.into_iter()
|
|
.map(|s| serde_json::json!({
|
|
"id": s.id,
|
|
"type": "ssh",
|
|
"name": format!("{}@{}:{}", s.username, s.hostname, s.port),
|
|
"host": s.hostname,
|
|
"username": s.username,
|
|
}))
|
|
.collect();
|
|
|
|
// Include RDP sessions
|
|
for s in state.rdp.list_sessions() {
|
|
sessions.push(serde_json::json!({
|
|
"id": s.id,
|
|
"type": "rdp",
|
|
"name": s.hostname.clone(),
|
|
"host": s.hostname,
|
|
"width": s.width,
|
|
"height": s.height,
|
|
}));
|
|
}
|
|
|
|
ok_response(sessions)
|
|
}
|
|
|
|
async fn handle_sftp_list(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<SftpListRequest>,
|
|
) -> Json<McpResponse<Vec<serde_json::Value>>> {
|
|
match state.sftp.list(&req.session_id, &req.path).await {
|
|
Ok(entries) => {
|
|
let items: Vec<serde_json::Value> = entries.into_iter().map(|e| {
|
|
serde_json::json!({
|
|
"name": e.name,
|
|
"path": e.path,
|
|
"size": e.size,
|
|
"is_dir": e.is_dir,
|
|
"modified": e.mod_time,
|
|
})
|
|
}).collect();
|
|
ok_response(items)
|
|
}
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_sftp_read(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<SftpReadRequest>,
|
|
) -> Json<McpResponse<String>> {
|
|
match state.sftp.read_file(&req.session_id, &req.path).await {
|
|
Ok(content) => ok_response(content),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_sftp_write(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<SftpWriteRequest>,
|
|
) -> Json<McpResponse<String>> {
|
|
match state.sftp.write_file(&req.session_id, &req.path, &req.content).await {
|
|
Ok(()) => ok_response("OK".to_string()),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_screenshot(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<ScreenshotRequest>,
|
|
) -> Json<McpResponse<String>> {
|
|
match state.rdp.screenshot_png_base64(&req.session_id).await {
|
|
Ok(b64) => ok_response(b64),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_terminal_type(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<TerminalTypeRequest>,
|
|
) -> Json<McpResponse<String>> {
|
|
let text = if req.press_enter.unwrap_or(true) {
|
|
format!("{}\r", req.text)
|
|
} else {
|
|
req.text.clone()
|
|
};
|
|
|
|
match state.ssh.write(&req.session_id, text.as_bytes()).await {
|
|
Ok(()) => ok_response("sent".to_string()),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_terminal_read(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<TerminalReadRequest>,
|
|
) -> Json<McpResponse<String>> {
|
|
let n = req.lines.unwrap_or(50);
|
|
match state.scrollback.get(&req.session_id) {
|
|
Some(buf) => ok_response(buf.read_lines(n)),
|
|
None => err_response(format!("No scrollback buffer for session {}", req.session_id)),
|
|
}
|
|
}
|
|
|
|
async fn handle_terminal_execute(
|
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
Json(req): Json<TerminalExecuteRequest>,
|
|
) -> Json<McpResponse<String>> {
|
|
let timeout = req.timeout_ms.unwrap_or(5000);
|
|
let marker = "__WRAITH_MCP_DONE__";
|
|
|
|
let buf = match state.scrollback.get(&req.session_id) {
|
|
Some(b) => b,
|
|
None => return err_response(format!("No scrollback buffer for session {}", req.session_id)),
|
|
};
|
|
|
|
let before = buf.total_written();
|
|
let full_cmd = format!("{}\recho {}\r", req.command, marker);
|
|
|
|
if let Err(e) = state.ssh.write(&req.session_id, full_cmd.as_bytes()).await {
|
|
return err_response(e);
|
|
}
|
|
|
|
let start = std::time::Instant::now();
|
|
let timeout_dur = std::time::Duration::from_millis(timeout);
|
|
|
|
loop {
|
|
if start.elapsed() > timeout_dur {
|
|
let raw = buf.read_raw();
|
|
let total = buf.total_written();
|
|
let new_bytes = total.saturating_sub(before);
|
|
let output = if new_bytes > 0 && raw.len() >= new_bytes {
|
|
&raw[raw.len() - new_bytes.min(raw.len())..]
|
|
} else {
|
|
""
|
|
};
|
|
return ok_response(format!("[timeout after {}ms]\n{}", timeout, output));
|
|
}
|
|
|
|
let raw = buf.read_raw();
|
|
if raw.contains(marker) {
|
|
let total = buf.total_written();
|
|
let new_bytes = total.saturating_sub(before);
|
|
let output = if new_bytes > 0 && raw.len() >= new_bytes {
|
|
raw[raw.len() - new_bytes.min(raw.len())..].to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let clean = output
|
|
.lines()
|
|
.filter(|line| !line.contains(marker))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
return ok_response(clean.trim().to_string());
|
|
}
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
|
|
// ── Tool handlers (all tools exposed to AI via MCP) ──────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolSessionTarget { session_id: String, target: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolSessionOnly { session_id: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolDnsRequest { session_id: String, domain: String, record_type: Option<String> }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolWolRequest { session_id: String, mac_address: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolScanNetworkRequest { session_id: String, subnet: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolScanPortsRequest { session_id: String, target: String, ports: Option<Vec<u16>> }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolSubnetRequest { cidr: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolKeygenRequest { key_type: String, comment: Option<String> }
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToolPassgenRequest { length: Option<usize>, uppercase: Option<bool>, lowercase: Option<bool>, digits: Option<bool>, symbols: Option<bool> }
|
|
|
|
async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("ping -c 4 {} 2>&1", req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", req.target, req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let rt = req.record_type.unwrap_or_else(|| "A".to_string());
|
|
match tool_exec(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", req.domain, rt, rt, req.domain, rt, req.domain)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("whois {} 2>&1 | head -80", req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let mac_clean = req.mac_address.replace([':', '-'], "");
|
|
let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex('{}');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 sent to {}')" 2>&1"#, mac_clean, req.mac_address);
|
|
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match crate::scanner::scan_network(&session.handle, &req.subnet).await {
|
|
Ok(hosts) => ok_response(serde_json::to_value(hosts).unwrap_or_default()),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_tool_scan_ports(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanPortsRequest>) -> Json<McpResponse<serde_json::Value>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let result = if let Some(ports) = req.ports {
|
|
crate::scanner::scan_ports(&session.handle, &req.target, &ports).await
|
|
} else {
|
|
crate::scanner::quick_port_scan(&session.handle, &req.target).await
|
|
};
|
|
match result { Ok(r) => ok_response(serde_json::to_value(r).unwrap_or_default()), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_subnet(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolSubnetRequest>) -> Json<McpResponse<serde_json::Value>> {
|
|
match crate::commands::tools_commands_r2::tool_subnet_calc_inner(&req.cidr) {
|
|
Ok(info) => ok_response(serde_json::to_value(info).unwrap_or_default()),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#;
|
|
match tool_exec(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> {
|
|
match crate::commands::tools_commands::tool_generate_ssh_key_inner(&req.key_type, req.comment) {
|
|
Ok(key) => ok_response(serde_json::to_value(key).unwrap_or_default()),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn handle_tool_passgen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolPassgenRequest>) -> Json<McpResponse<String>> {
|
|
match crate::commands::tools_commands::tool_generate_password_inner(req.length, req.uppercase, req.lowercase, req.digits, req.symbols) {
|
|
Ok(pw) => ok_response(pw),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
async fn tool_exec(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 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(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
|
|
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
// ── Docker handlers ──────────────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct DockerActionRequest { session_id: String, action: String, target: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct DockerListRequest { session_id: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct DockerExecRequest { session_id: String, container: String, command: String }
|
|
|
|
async fn handle_docker_ps(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerListRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_docker_action(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerActionRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let cmd = match req.action.as_str() {
|
|
"start" => format!("docker start {} 2>&1", req.target),
|
|
"stop" => format!("docker stop {} 2>&1", req.target),
|
|
"restart" => format!("docker restart {} 2>&1", req.target),
|
|
"remove" => format!("docker rm -f {} 2>&1", req.target),
|
|
"logs" => format!("docker logs --tail 100 {} 2>&1", req.target),
|
|
"builder-prune" => "docker builder prune -f 2>&1".to_string(),
|
|
"system-prune" => "docker system prune -f 2>&1".to_string(),
|
|
_ => return err_response(format!("Unknown action: {}", req.action)),
|
|
};
|
|
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerExecRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let cmd = format!("docker exec {} {} 2>&1", req.container, req.command);
|
|
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
// ── Service/process handlers ─────────────────────────────────────────────────
|
|
|
|
async fn handle_service_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", req.target, req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_process_list(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", req.target) };
|
|
match tool_exec(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
// ── Git handlers ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct GitRequest { session_id: String, path: String }
|
|
|
|
async fn handle_git_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("cd {} && git status --short --branch 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_git_pull(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("cd {} && git pull 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
match tool_exec(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
}
|
|
|
|
// ── Session creation handlers ────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct SshConnectRequest {
|
|
hostname: String,
|
|
port: Option<u16>,
|
|
username: String,
|
|
password: Option<String>,
|
|
private_key_path: Option<String>,
|
|
}
|
|
|
|
async fn handle_ssh_connect(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<SshConnectRequest>) -> Json<McpResponse<String>> {
|
|
use crate::ssh::session::AuthMethod;
|
|
|
|
let port = req.port.unwrap_or(22);
|
|
let auth = if let Some(key_path) = req.private_key_path {
|
|
// Read key file
|
|
let pem = match std::fs::read_to_string(&key_path) {
|
|
Ok(p) => p,
|
|
Err(e) => return err_response(format!("Failed to read key file {}: {}", key_path, e)),
|
|
};
|
|
AuthMethod::Key { private_key_pem: pem, passphrase: req.password }
|
|
} else {
|
|
AuthMethod::Password(req.password.unwrap_or_default())
|
|
};
|
|
|
|
match state.ssh.connect(
|
|
state.app_handle.clone(),
|
|
&req.hostname,
|
|
port,
|
|
&req.username,
|
|
auth,
|
|
120, 40,
|
|
&state.sftp,
|
|
&state.scrollback,
|
|
&state.error_watcher,
|
|
).await {
|
|
Ok(session_id) => ok_response(session_id),
|
|
Err(e) => err_response(e),
|
|
}
|
|
}
|
|
|
|
// ── RDP interaction handlers ─────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct RdpClickRequest { session_id: String, x: u16, y: u16, button: Option<String> }
|
|
|
|
#[derive(Deserialize)]
|
|
struct RdpTypeRequest { session_id: String, text: String }
|
|
|
|
#[derive(Deserialize)]
|
|
struct RdpClipboardRequest { session_id: String, text: String }
|
|
|
|
async fn handle_rdp_click(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpClickRequest>) -> Json<McpResponse<String>> {
|
|
use crate::rdp::input::mouse_flags;
|
|
let button_flag = match req.button.as_deref().unwrap_or("left") {
|
|
"right" => mouse_flags::BUTTON2,
|
|
"middle" => mouse_flags::BUTTON3,
|
|
_ => mouse_flags::BUTTON1,
|
|
};
|
|
// Move to position
|
|
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, mouse_flags::MOVE) { return err_response(e); }
|
|
// Click down
|
|
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, button_flag | mouse_flags::DOWN) { return err_response(e); }
|
|
// Click up
|
|
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, button_flag) { return err_response(e); }
|
|
ok_response(format!("clicked ({}, {})", req.x, req.y))
|
|
}
|
|
|
|
async fn handle_rdp_type(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpTypeRequest>) -> Json<McpResponse<String>> {
|
|
// Set clipboard then simulate Ctrl+V to paste (most reliable for arbitrary text)
|
|
if let Err(e) = state.rdp.send_clipboard(&req.session_id, &req.text) { return err_response(e); }
|
|
// Small delay for clipboard to propagate, then Ctrl+V
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
// Ctrl down
|
|
let _ = state.rdp.send_key(&req.session_id, 0x001D, true);
|
|
// V down
|
|
let _ = state.rdp.send_key(&req.session_id, 0x002F, true);
|
|
// V up
|
|
let _ = state.rdp.send_key(&req.session_id, 0x002F, false);
|
|
// Ctrl up
|
|
let _ = state.rdp.send_key(&req.session_id, 0x001D, false);
|
|
ok_response(format!("typed {} chars via clipboard paste", req.text.len()))
|
|
}
|
|
|
|
async fn handle_rdp_clipboard(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpClipboardRequest>) -> Json<McpResponse<String>> {
|
|
if let Err(e) = state.rdp.send_clipboard(&req.session_id, &req.text) { return err_response(e); }
|
|
ok_response("clipboard set".to_string())
|
|
}
|
|
|
|
/// Start the MCP HTTP server and write the port to disk.
|
|
pub async fn start_mcp_server(
|
|
ssh: SshService,
|
|
rdp: RdpService,
|
|
sftp: SftpService,
|
|
scrollback: ScrollbackRegistry,
|
|
app_handle: tauri::AppHandle,
|
|
error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
|
|
) -> Result<u16, String> {
|
|
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback, app_handle, error_watcher });
|
|
|
|
let app = Router::new()
|
|
.route("/mcp/sessions", post(handle_list_sessions))
|
|
.route("/mcp/terminal/type", post(handle_terminal_type))
|
|
.route("/mcp/terminal/read", post(handle_terminal_read))
|
|
.route("/mcp/terminal/execute", post(handle_terminal_execute))
|
|
.route("/mcp/screenshot", post(handle_screenshot))
|
|
.route("/mcp/sftp/list", post(handle_sftp_list))
|
|
.route("/mcp/sftp/read", post(handle_sftp_read))
|
|
.route("/mcp/sftp/write", post(handle_sftp_write))
|
|
.route("/mcp/tool/ping", post(handle_tool_ping))
|
|
.route("/mcp/tool/traceroute", post(handle_tool_traceroute))
|
|
.route("/mcp/tool/dns", post(handle_tool_dns))
|
|
.route("/mcp/tool/whois", post(handle_tool_whois))
|
|
.route("/mcp/tool/wol", post(handle_tool_wol))
|
|
.route("/mcp/tool/scan-network", post(handle_tool_scan_network))
|
|
.route("/mcp/tool/scan-ports", post(handle_tool_scan_ports))
|
|
.route("/mcp/tool/subnet", post(handle_tool_subnet))
|
|
.route("/mcp/tool/bandwidth", post(handle_tool_bandwidth))
|
|
.route("/mcp/tool/keygen", post(handle_tool_keygen))
|
|
.route("/mcp/tool/passgen", post(handle_tool_passgen))
|
|
.route("/mcp/docker/ps", post(handle_docker_ps))
|
|
.route("/mcp/docker/action", post(handle_docker_action))
|
|
.route("/mcp/docker/exec", post(handle_docker_exec))
|
|
.route("/mcp/service/status", post(handle_service_status))
|
|
.route("/mcp/process/list", post(handle_process_list))
|
|
.route("/mcp/git/status", post(handle_git_status))
|
|
.route("/mcp/git/pull", post(handle_git_pull))
|
|
.route("/mcp/git/log", post(handle_git_log))
|
|
.route("/mcp/rdp/click", post(handle_rdp_click))
|
|
.route("/mcp/rdp/type", post(handle_rdp_type))
|
|
.route("/mcp/rdp/clipboard", post(handle_rdp_clipboard))
|
|
.route("/mcp/ssh/connect", post(handle_ssh_connect))
|
|
.with_state(state);
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await
|
|
.map_err(|e| format!("Failed to bind MCP server: {}", e))?;
|
|
|
|
let port = listener.local_addr()
|
|
.map_err(|e| format!("Failed to get MCP server port: {}", e))?
|
|
.port();
|
|
|
|
// Write port to well-known location
|
|
let port_file = crate::data_directory().join("mcp-port");
|
|
std::fs::write(&port_file, port.to_string())
|
|
.map_err(|e| format!("Failed to write MCP port file: {}", e))?;
|
|
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.ok();
|
|
});
|
|
|
|
Ok(port)
|
|
}
|