DashMap::clone() deep-copies all entries into a new map. The MCP server's cloned SshService/SftpService/RdpService/ScrollbackRegistry were snapshots from startup that never saw new sessions. Fix: wrap all DashMap fields in Arc<DashMap<...>> so clones share the same underlying map. Sessions added after MCP startup are now visible to MCP tools. Affected: SshService, SftpService, RdpService, ScrollbackRegistry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
Rust
326 lines
11 KiB
Rust
//! SFTP service — high-level file operations over an existing SSH connection.
|
|
//!
|
|
//! SFTP sessions are created from SSH handles via a separate subsystem channel
|
|
//! (not the PTY channel). One `SftpSession` is stored per SSH session ID and
|
|
//! provides all file operations needed by the frontend.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, UNIX_EPOCH};
|
|
|
|
use dashmap::DashMap;
|
|
use log::{debug, info};
|
|
use russh_sftp::client::SftpSession;
|
|
use russh_sftp::protocol::OpenFlags;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
|
|
// ── file entry ───────────────────────────────────────────────────────────────
|
|
|
|
/// Metadata about a single remote file or directory, returned to the frontend.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FileEntry {
|
|
pub name: String,
|
|
pub path: String,
|
|
pub size: u64,
|
|
pub is_dir: bool,
|
|
/// Unix permission string, e.g. "0755"
|
|
pub permissions: String,
|
|
/// Human-readable modification time, e.g. "Mar 17 14:30"
|
|
pub mod_time: String,
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
|
|
fn format_mtime(unix_secs: u32) -> String {
|
|
// Build a SystemTime from the raw epoch value.
|
|
let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64);
|
|
|
|
// Convert to seconds-since-epoch for manual formatting. We avoid pulling
|
|
// in chrono just for this; a simple manual decomposition is sufficient for
|
|
// the "Mar 17 14:30" display format expected by the frontend.
|
|
let secs = unix_secs as i64;
|
|
|
|
// Days since epoch → calendar date (Gregorian, ignoring leap seconds).
|
|
let days = secs / 86_400;
|
|
let time_of_day = secs % 86_400;
|
|
let hours = time_of_day / 3_600;
|
|
let minutes = (time_of_day % 3_600) / 60;
|
|
|
|
// Gregorian calendar calculation (Zeller / Julian Day approach).
|
|
let z = days + 719_468;
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
let doe = z - era * 146_097;
|
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let y = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let _y = if m <= 2 { y + 1 } else { y };
|
|
|
|
let month = match m {
|
|
1 => "Jan",
|
|
2 => "Feb",
|
|
3 => "Mar",
|
|
4 => "Apr",
|
|
5 => "May",
|
|
6 => "Jun",
|
|
7 => "Jul",
|
|
8 => "Aug",
|
|
9 => "Sep",
|
|
10 => "Oct",
|
|
11 => "Nov",
|
|
12 => "Dec",
|
|
_ => "???",
|
|
};
|
|
|
|
// Suppress unused variable warning — st is only used as a sanity anchor.
|
|
let _ = st;
|
|
|
|
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
|
}
|
|
|
|
/// Convert raw permission bits to a "0755"-style octal string.
|
|
fn format_permissions(raw: Option<u32>) -> String {
|
|
match raw {
|
|
Some(p) => format!("{:04o}", p & 0o7777),
|
|
None => "----".to_string(),
|
|
}
|
|
}
|
|
|
|
// ── SFTP service ─────────────────────────────────────────────────────────────
|
|
|
|
/// Manages SFTP sessions keyed by SSH session ID.
|
|
#[derive(Clone)]
|
|
pub struct SftpService {
|
|
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
|
/// take a shared reference to the `SftpService` and still mutably borrow
|
|
/// individual sessions.
|
|
clients: Arc<DashMap<String, Arc<TokioMutex<SftpSession>>>>,
|
|
}
|
|
|
|
impl SftpService {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
clients: Arc::new(DashMap::new()),
|
|
}
|
|
}
|
|
|
|
/// Store an SFTP client for the given session.
|
|
pub fn register_client(&self, session_id: &str, client: SftpSession) {
|
|
info!("SFTP client registered for session {}", session_id);
|
|
self.clients
|
|
.insert(session_id.to_string(), Arc::new(TokioMutex::new(client)));
|
|
}
|
|
|
|
/// Remove and drop the SFTP client for a session (called on SSH disconnect).
|
|
pub fn remove_client(&self, session_id: &str) {
|
|
if self.clients.remove(session_id).is_some() {
|
|
info!("SFTP client removed for session {}", session_id);
|
|
}
|
|
}
|
|
|
|
/// List the contents of a remote directory.
|
|
///
|
|
/// Returns entries sorted: directories first (alphabetically), then files
|
|
/// (alphabetically).
|
|
pub async fn list(&self, session_id: &str, path: &str) -> Result<Vec<FileEntry>, String> {
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
let read_dir = client
|
|
.read_dir(path)
|
|
.await
|
|
.map_err(|e| format!("SFTP list '{}' failed: {}", path, e))?;
|
|
|
|
let mut entries: Vec<FileEntry> = read_dir
|
|
.map(|entry| {
|
|
let meta = entry.metadata();
|
|
let name = entry.file_name();
|
|
let full_path = if path.ends_with('/') {
|
|
format!("{}{}", path, name)
|
|
} else {
|
|
format!("{}/{}", path, name)
|
|
};
|
|
let is_dir = meta.is_dir();
|
|
FileEntry {
|
|
name,
|
|
path: full_path,
|
|
size: meta.size.unwrap_or(0),
|
|
is_dir,
|
|
permissions: format_permissions(meta.permissions),
|
|
mod_time: meta.mtime.map(format_mtime).unwrap_or_default(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Sort: directories first, then files; each group alphabetical.
|
|
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
|
(true, false) => std::cmp::Ordering::Less,
|
|
(false, true) => std::cmp::Ordering::Greater,
|
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
});
|
|
|
|
debug!(
|
|
"SFTP list '{}' for session {}: {} entries",
|
|
path,
|
|
session_id,
|
|
entries.len()
|
|
);
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Read a remote file as a UTF-8 string.
|
|
///
|
|
/// Enforces a 5 MB guard — returns an error for larger files.
|
|
pub async fn read_file(&self, session_id: &str, path: &str) -> Result<String, String> {
|
|
const MAX_BYTES: u64 = 5 * 1024 * 1024;
|
|
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
// Stat first to check size before pulling bytes.
|
|
let meta = client
|
|
.metadata(path)
|
|
.await
|
|
.map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?;
|
|
|
|
if let Some(size) = meta.size {
|
|
if size > MAX_BYTES {
|
|
return Err(format!(
|
|
"File '{}' is {} bytes — exceeds 5 MB read limit",
|
|
path, size
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut file = client
|
|
.open(path)
|
|
.await
|
|
.map_err(|e| format!("SFTP open '{}' failed: {}", path, e))?;
|
|
|
|
let mut buf = Vec::new();
|
|
file.read_to_end(&mut buf)
|
|
.await
|
|
.map_err(|e| format!("SFTP read '{}' failed: {}", path, e))?;
|
|
|
|
String::from_utf8(buf)
|
|
.map_err(|_| format!("File '{}' is not valid UTF-8", path))
|
|
}
|
|
|
|
/// Write a UTF-8 string to a remote file (create or truncate).
|
|
pub async fn write_file(
|
|
&self,
|
|
session_id: &str,
|
|
path: &str,
|
|
content: &str,
|
|
) -> Result<(), String> {
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
let mut file = client
|
|
.open_with_flags(
|
|
path,
|
|
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
|
|
)
|
|
.await
|
|
.map_err(|e| format!("SFTP open '{}' for write failed: {}", path, e))?;
|
|
|
|
file.write_all(content.as_bytes())
|
|
.await
|
|
.map_err(|e| format!("SFTP write '{}' failed: {}", path, e))?;
|
|
|
|
file.flush()
|
|
.await
|
|
.map_err(|e| format!("SFTP flush '{}' failed: {}", path, e))?;
|
|
|
|
debug!("SFTP wrote {} bytes to '{}'", content.len(), path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a remote directory.
|
|
pub async fn mkdir(&self, session_id: &str, path: &str) -> Result<(), String> {
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
client
|
|
.create_dir(path)
|
|
.await
|
|
.map_err(|e| format!("SFTP mkdir '{}' failed: {}", path, e))
|
|
}
|
|
|
|
/// Delete a remote file or directory.
|
|
///
|
|
/// Tries `remove_file` first; falls back to `remove_dir` if that fails.
|
|
pub async fn delete(&self, session_id: &str, path: &str) -> Result<(), String> {
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
match client.remove_file(path).await {
|
|
Ok(()) => Ok(()),
|
|
Err(_) => client
|
|
.remove_dir(path)
|
|
.await
|
|
.map_err(|e| format!("SFTP delete '{}' failed: {}", path, e)),
|
|
}
|
|
}
|
|
|
|
/// Rename (or move) a remote path.
|
|
pub async fn rename(
|
|
&self,
|
|
session_id: &str,
|
|
old_path: &str,
|
|
new_path: &str,
|
|
) -> Result<(), String> {
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
client
|
|
.rename(old_path, new_path)
|
|
.await
|
|
.map_err(|e| format!("SFTP rename '{}' → '{}' failed: {}", old_path, new_path, e))
|
|
}
|
|
|
|
/// Stat a single remote path and return its metadata as a `FileEntry`.
|
|
pub async fn stat(&self, session_id: &str, path: &str) -> Result<FileEntry, String> {
|
|
let arc = self.get_client(session_id)?;
|
|
let client = arc.lock().await;
|
|
|
|
let meta = client
|
|
.metadata(path)
|
|
.await
|
|
.map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?;
|
|
|
|
// Extract the file name from the path.
|
|
let name = path
|
|
.rsplit('/')
|
|
.next()
|
|
.unwrap_or(path)
|
|
.to_string();
|
|
|
|
Ok(FileEntry {
|
|
name,
|
|
path: path.to_string(),
|
|
size: meta.size.unwrap_or(0),
|
|
is_dir: meta.is_dir(),
|
|
permissions: format_permissions(meta.permissions),
|
|
mod_time: meta.mtime.map(format_mtime).unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
// ── private helpers ───────────────────────────────────────────────────────
|
|
|
|
fn get_client(
|
|
&self,
|
|
session_id: &str,
|
|
) -> Result<Arc<TokioMutex<SftpSession>>, String> {
|
|
self.clients
|
|
.get(session_id)
|
|
.map(|r| r.clone())
|
|
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
|
}
|
|
}
|