feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,83 @@
|
||||
use ssh2::Session;
|
||||
|
||||
use crate::domain::ssh::SshConnectRequest;
|
||||
|
||||
use super::path::expand_home;
|
||||
|
||||
pub(super) fn authenticate(session: &Session, config: &SshConnectRequest) -> Result<(), String> {
|
||||
if let Some(password) = config.password.as_deref().filter(|value| !value.is_empty()) {
|
||||
session
|
||||
.userauth_password(&config.username, password)
|
||||
.map_err(|error| format!("密码认证失败: {error}"))?;
|
||||
} else if let Some(private_key) = config
|
||||
.private_key
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
authenticate_private_key(session, config, private_key)?;
|
||||
} else {
|
||||
authenticate_automatic(session, &config.username)?;
|
||||
}
|
||||
if !session.authenticated() {
|
||||
return Err("SSH 认证被服务器拒绝".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authenticate_private_key(
|
||||
session: &Session,
|
||||
config: &SshConnectRequest,
|
||||
private_key: &str,
|
||||
) -> Result<(), String> {
|
||||
if private_key.contains("-----BEGIN") {
|
||||
return session
|
||||
.userauth_pubkey_memory(
|
||||
&config.username,
|
||||
None,
|
||||
private_key,
|
||||
config.passphrase.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("私钥认证失败: {error}"));
|
||||
}
|
||||
let key_path = expand_home(private_key);
|
||||
session
|
||||
.userauth_pubkey_file(
|
||||
&config.username,
|
||||
None,
|
||||
&key_path,
|
||||
config.passphrase.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("私钥认证失败 ({}): {error}", key_path.display()))
|
||||
}
|
||||
|
||||
fn authenticate_automatic(session: &Session, username: &str) -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
if let Err(error) = session.userauth_agent(username) {
|
||||
errors.push(format!("Agent: {error}"));
|
||||
}
|
||||
if !session.authenticated() {
|
||||
for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
|
||||
let Some(home) = dirs::home_dir() else { break };
|
||||
let path = home.join(".ssh").join(name);
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
match session.userauth_pubkey_file(username, None, &path, None) {
|
||||
Ok(()) if session.authenticated() => break,
|
||||
Ok(()) => {}
|
||||
Err(error) => errors.push(format!("{}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.authenticated() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"未找到可用凭据;请选择密码/私钥,或确认 SSH Agent 已加载密钥。{}",
|
||||
if errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", errors.join("; "))
|
||||
}
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::fs;
|
||||
|
||||
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHostKeyFormat, Session};
|
||||
|
||||
pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<(), String> {
|
||||
let (key, key_type) = session
|
||||
.host_key()
|
||||
.ok_or_else(|| "服务器未提供主机密钥".to_string())?;
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Err("无法确定用户主目录,不能校验 known_hosts".to_string());
|
||||
};
|
||||
let ssh_dir = home.join(".ssh");
|
||||
fs::create_dir_all(&ssh_dir).map_err(|error| format!("创建 ~/.ssh 失败: {error}"))?;
|
||||
let path = ssh_dir.join("known_hosts");
|
||||
let mut known = session
|
||||
.known_hosts()
|
||||
.map_err(|error| format!("初始化 known_hosts 失败: {error}"))?;
|
||||
if path.is_file() {
|
||||
known
|
||||
.read_file(&path, KnownHostFileKind::OpenSSH)
|
||||
.map_err(|error| format!("读取 known_hosts 失败: {error}"))?;
|
||||
}
|
||||
match known.check_port(host, port, key) {
|
||||
CheckResult::Match => Ok(()),
|
||||
CheckResult::Mismatch => Err(format!(
|
||||
"主机密钥已变化,已拒绝连接。请确认安全后从 known_hosts 删除 {}:{} 的旧记录",
|
||||
host, port
|
||||
)),
|
||||
CheckResult::NotFound | CheckResult::Failure => {
|
||||
let host_label = known_host_label(host, port);
|
||||
known
|
||||
.add(&host_label, key, &host_label, known_host_format(key_type))
|
||||
.map_err(|error| format!("记录主机密钥失败: {error}"))?;
|
||||
known
|
||||
.write_file(&path, KnownHostFileKind::OpenSSH)
|
||||
.map_err(|error| format!("写入 known_hosts 失败: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn known_host_label(host: &str, port: u16) -> String {
|
||||
if port == 22 {
|
||||
host.to_string()
|
||||
} else {
|
||||
format!("[{host}]:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn known_host_format(key_type: HostKeyType) -> KnownHostKeyFormat {
|
||||
match key_type {
|
||||
HostKeyType::Rsa => KnownHostKeyFormat::SshRsa,
|
||||
HostKeyType::Dss => KnownHostKeyFormat::SshDss,
|
||||
HostKeyType::Ecdsa256 => KnownHostKeyFormat::Ecdsa256,
|
||||
HostKeyType::Ecdsa384 => KnownHostKeyFormat::Ecdsa384,
|
||||
HostKeyType::Ecdsa521 => KnownHostKeyFormat::Ecdsa521,
|
||||
HostKeyType::Ed25519 => KnownHostKeyFormat::Ed25519,
|
||||
HostKeyType::Unknown => KnownHostKeyFormat::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_non_default_ports_like_openssh() {
|
||||
assert_eq!(known_host_label("example.com", 22), "example.com");
|
||||
assert_eq!(known_host_label("example.com", 2222), "[example.com]:2222");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn expand_home(value: &str) -> PathBuf {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
if let Some(relative) = value.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(relative);
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leaves_absolute_paths_unchanged() {
|
||||
assert_eq!(expand_home("/tmp/key"), PathBuf::from("/tmp/key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream, ToSocketAddrs},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::domain::ssh::SshConnectRequest;
|
||||
|
||||
use super::{establish_session, relay_channel};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProxyConfig {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JumpHostConfig {
|
||||
host: String,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
port: u16,
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
private_key: Option<String>,
|
||||
passphrase: Option<String>,
|
||||
proxy: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
pub(super) fn connect_transport(
|
||||
config: &SshConnectRequest,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<TcpStream, String> {
|
||||
let jump_values = config
|
||||
.jump_hosts
|
||||
.as_ref()
|
||||
.filter(|hosts| !hosts.is_empty())
|
||||
.cloned()
|
||||
.or_else(|| config.jump_host.clone().map(|host| vec![host]));
|
||||
if let Some(jump_values) = jump_values {
|
||||
if jump_values.len() > 8 {
|
||||
return Err("ProxyJump 最多支持 8 个跳板".to_string());
|
||||
}
|
||||
return connect_via_jump_chain(&jump_values, &config.host, config.port, timeout_seconds);
|
||||
}
|
||||
|
||||
let proxy = config
|
||||
.proxy
|
||||
.as_ref()
|
||||
.filter(|value| value.get("type").and_then(|value| value.as_str()) != Some("none"))
|
||||
.map(|value| {
|
||||
serde_json::from_value::<ProxyConfig>(value.clone())
|
||||
.map_err(|error| format!("代理配置无效: {error}"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let Some(proxy) = proxy else {
|
||||
return connect_tcp(&config.host, config.port, timeout_seconds, "SSH 主机");
|
||||
};
|
||||
if proxy.host.trim().is_empty() || proxy.port == 0 {
|
||||
return Err("代理地址和端口不能为空".to_string());
|
||||
}
|
||||
let mut stream = connect_tcp(&proxy.host, proxy.port, timeout_seconds, "代理服务器")?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(timeout_seconds)))
|
||||
.ok();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(timeout_seconds)))
|
||||
.ok();
|
||||
|
||||
match proxy.kind.as_str() {
|
||||
"http" => http_connect(&mut stream, &proxy, &config.host, config.port)?,
|
||||
"socks5" => socks5_connect(&mut stream, &proxy, &config.host, config.port)?,
|
||||
other => return Err(format!("不支持的代理类型: {other}")),
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn connect_via_jump_chain(
|
||||
jump_values: &[serde_json::Value],
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<TcpStream, String> {
|
||||
let (last, earlier) = jump_values
|
||||
.split_last()
|
||||
.ok_or_else(|| "ProxyJump 链不能为空".to_string())?;
|
||||
let jump: JumpHostConfig =
|
||||
serde_json::from_value(last.clone()).map_err(|error| format!("跳板配置无效: {error}"))?;
|
||||
if jump.host.trim().is_empty() || jump.username.trim().is_empty() || jump.port == 0 {
|
||||
return Err("跳板机主机、端口和用户名不能为空".to_string());
|
||||
}
|
||||
let jump_config = SshConnectRequest {
|
||||
host: jump.host.clone(),
|
||||
port: jump.port,
|
||||
username: jump.username.clone(),
|
||||
password: jump.password.clone(),
|
||||
private_key: jump.private_key.clone(),
|
||||
passphrase: jump.passphrase.clone(),
|
||||
proxy: jump.proxy.clone(),
|
||||
jump_host: None,
|
||||
jump_hosts: (!earlier.is_empty()).then(|| earlier.to_vec()),
|
||||
timeout_seconds: Some(timeout_seconds),
|
||||
keep_alive: Some(true),
|
||||
};
|
||||
let jump_session = establish_session(&jump_config)
|
||||
.map_err(|error| format!("连接跳板机 {} 失败: {error}", jump.host))?;
|
||||
let channel = jump_session
|
||||
.channel_direct_tcpip(target_host, target_port, None)
|
||||
.map_err(|error| format!("跳板机无法连接目标 {target_host}:{target_port}: {error}"))?;
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||
.map_err(|error| format!("创建跳板本地桥失败: {error}"))?;
|
||||
let bridge_address = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("读取跳板本地桥地址失败: {error}"))?;
|
||||
thread::spawn(move || {
|
||||
if let Ok((socket, _)) = listener.accept() {
|
||||
relay_channel(jump_session, channel, socket);
|
||||
}
|
||||
});
|
||||
TcpStream::connect_timeout(&bridge_address, Duration::from_secs(timeout_seconds))
|
||||
.map_err(|error| format!("连接跳板本地桥失败: {error}"))
|
||||
}
|
||||
|
||||
fn connect_tcp(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_seconds: u64,
|
||||
label: &str,
|
||||
) -> Result<TcpStream, String> {
|
||||
let address = format!("{}:{}", host.trim(), port);
|
||||
let socket_address = address
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| format!("无法解析{label} {host}: {error}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("无法解析{label} {host}"))?;
|
||||
TcpStream::connect_timeout(&socket_address, Duration::from_secs(timeout_seconds))
|
||||
.map_err(|error| format!("连接{label} {address} 失败: {error}"))
|
||||
}
|
||||
|
||||
fn http_connect(
|
||||
stream: &mut TcpStream,
|
||||
proxy: &ProxyConfig,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let authority = format!("{target_host}:{target_port}");
|
||||
let mut request = format!(
|
||||
"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\nProxy-Connection: Keep-Alive\r\n"
|
||||
);
|
||||
if let Some(username) = proxy.username.as_deref().filter(|value| !value.is_empty()) {
|
||||
let credentials = format!(
|
||||
"{username}:{}",
|
||||
proxy.password.as_deref().unwrap_or_default()
|
||||
);
|
||||
request.push_str(&format!(
|
||||
"Proxy-Authorization: Basic {}\r\n",
|
||||
STANDARD.encode(credentials)
|
||||
));
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|error| format!("发送 HTTP 代理请求失败: {error}"))?;
|
||||
|
||||
let mut response = Vec::with_capacity(512);
|
||||
let mut byte = [0_u8; 1];
|
||||
while response.len() < 16 * 1024 {
|
||||
stream
|
||||
.read_exact(&mut byte)
|
||||
.map_err(|error| format!("读取 HTTP 代理响应失败: {error}"))?;
|
||||
response.push(byte[0]);
|
||||
if response.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !response.ends_with(b"\r\n\r\n") {
|
||||
return Err("HTTP 代理响应头过大或不完整".to_string());
|
||||
}
|
||||
let status = String::from_utf8_lossy(&response);
|
||||
let status_line = status.lines().next().unwrap_or_default();
|
||||
let success = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|code| code.parse::<u16>().ok())
|
||||
.is_some_and(|code| (200..300).contains(&code));
|
||||
if !success {
|
||||
return Err(format!("HTTP 代理拒绝 CONNECT: {status_line}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn socks5_connect(
|
||||
stream: &mut TcpStream,
|
||||
proxy: &ProxyConfig,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let has_auth = proxy
|
||||
.username
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let methods: &[u8] = if has_auth { &[5, 2, 0, 2] } else { &[5, 1, 0] };
|
||||
stream
|
||||
.write_all(methods)
|
||||
.map_err(|error| format!("发送 SOCKS5 握手失败: {error}"))?;
|
||||
let mut choice = [0_u8; 2];
|
||||
stream
|
||||
.read_exact(&mut choice)
|
||||
.map_err(|error| format!("读取 SOCKS5 握手失败: {error}"))?;
|
||||
if choice[0] != 5 || choice[1] == 0xff {
|
||||
return Err("SOCKS5 代理未接受认证方式".to_string());
|
||||
}
|
||||
if choice[1] == 2 {
|
||||
let username = proxy.username.as_deref().unwrap_or_default().as_bytes();
|
||||
let password = proxy.password.as_deref().unwrap_or_default().as_bytes();
|
||||
if username.len() > 255 || password.len() > 255 {
|
||||
return Err("SOCKS5 用户名或密码过长".to_string());
|
||||
}
|
||||
let mut auth = Vec::with_capacity(username.len() + password.len() + 3);
|
||||
auth.extend_from_slice(&[1, username.len() as u8]);
|
||||
auth.extend_from_slice(username);
|
||||
auth.push(password.len() as u8);
|
||||
auth.extend_from_slice(password);
|
||||
stream
|
||||
.write_all(&auth)
|
||||
.map_err(|error| format!("发送 SOCKS5 认证失败: {error}"))?;
|
||||
let mut result = [0_u8; 2];
|
||||
stream
|
||||
.read_exact(&mut result)
|
||||
.map_err(|error| format!("读取 SOCKS5 认证结果失败: {error}"))?;
|
||||
if result != [1, 0] {
|
||||
return Err("SOCKS5 用户名或密码认证失败".to_string());
|
||||
}
|
||||
} else if choice[1] != 0 {
|
||||
return Err(format!("SOCKS5 代理选择了不支持的认证方式: {}", choice[1]));
|
||||
}
|
||||
|
||||
let host = target_host.as_bytes();
|
||||
if host.len() > 255 {
|
||||
return Err("SOCKS5 目标主机名过长".to_string());
|
||||
}
|
||||
let mut request = Vec::with_capacity(host.len() + 7);
|
||||
request.extend_from_slice(&[5, 1, 0, 3, host.len() as u8]);
|
||||
request.extend_from_slice(host);
|
||||
request.extend_from_slice(&target_port.to_be_bytes());
|
||||
stream
|
||||
.write_all(&request)
|
||||
.map_err(|error| format!("发送 SOCKS5 CONNECT 失败: {error}"))?;
|
||||
|
||||
let mut response = [0_u8; 4];
|
||||
stream
|
||||
.read_exact(&mut response)
|
||||
.map_err(|error| format!("读取 SOCKS5 CONNECT 结果失败: {error}"))?;
|
||||
if response[0] != 5 || response[1] != 0 {
|
||||
return Err(format!("SOCKS5 代理连接目标失败,状态码: {}", response[1]));
|
||||
}
|
||||
let address_length = match response[3] {
|
||||
1 => 4,
|
||||
3 => {
|
||||
let mut length = [0_u8; 1];
|
||||
stream
|
||||
.read_exact(&mut length)
|
||||
.map_err(|error| format!("读取 SOCKS5 地址失败: {error}"))?;
|
||||
length[0] as usize
|
||||
}
|
||||
4 => 16,
|
||||
kind => return Err(format!("SOCKS5 返回未知地址类型: {kind}")),
|
||||
};
|
||||
let mut ignored = vec![0_u8; address_length + 2];
|
||||
stream
|
||||
.read_exact(&mut ignored)
|
||||
.map_err(|error| format!("读取 SOCKS5 地址失败: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn establishes_http_connect_with_basic_auth() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut byte = [0_u8; 1];
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
socket.read_exact(&mut byte).unwrap();
|
||||
request.push(byte[0]);
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
assert!(request.starts_with("CONNECT target.example:22 HTTP/1.1\r\n"));
|
||||
assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n"));
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut stream = TcpStream::connect(address).unwrap();
|
||||
http_connect(
|
||||
&mut stream,
|
||||
&ProxyConfig {
|
||||
kind: "http".to_string(),
|
||||
host: address.ip().to_string(),
|
||||
port: address.port(),
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pass".to_string()),
|
||||
},
|
||||
"target.example",
|
||||
22,
|
||||
)
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establishes_socks5_tunnel_with_remote_dns() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut greeting = [0_u8; 3];
|
||||
socket.read_exact(&mut greeting).unwrap();
|
||||
assert_eq!(greeting, [5, 1, 0]);
|
||||
socket.write_all(&[5, 0]).unwrap();
|
||||
|
||||
let mut request = [0_u8; 5];
|
||||
socket.read_exact(&mut request).unwrap();
|
||||
assert_eq!(&request[..4], &[5, 1, 0, 3]);
|
||||
let mut target = vec![0_u8; request[4] as usize];
|
||||
socket.read_exact(&mut target).unwrap();
|
||||
assert_eq!(target, b"target.example");
|
||||
let mut port = [0_u8; 2];
|
||||
socket.read_exact(&mut port).unwrap();
|
||||
assert_eq!(u16::from_be_bytes(port), 2222);
|
||||
socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]).unwrap();
|
||||
});
|
||||
|
||||
let mut stream = TcpStream::connect(address).unwrap();
|
||||
socks5_connect(
|
||||
&mut stream,
|
||||
&ProxyConfig {
|
||||
kind: "socks5".to_string(),
|
||||
host: address.ip().to_string(),
|
||||
port: address.port(),
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
"target.example",
|
||||
2222,
|
||||
)
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ssh2::{FileStat, OpenFlags, OpenType, RenameFlags};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::ssh::{SftpDirEntry, SftpFileAttrs, SftpProgressEvent};
|
||||
|
||||
use super::{lock_error, path::expand_home, SshService};
|
||||
|
||||
impl SshService {
|
||||
pub fn create_sftp(&self, connection_id: &str) -> Result<String, String> {
|
||||
let connection = self.connection(connection_id)?;
|
||||
connection
|
||||
.session
|
||||
.sftp()
|
||||
.map_err(|error| format!("初始化 SFTP 失败: {error}"))?;
|
||||
let id = format!("sftp-{}", Uuid::new_v4());
|
||||
self.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.insert(id.clone(), connection_id.to_string());
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn sftp_list(&self, sftp_id: &str, remote_path: &str) -> Result<Vec<SftpDirEntry>, String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let mut entries = sftp
|
||||
.readdir(Path::new(remote_path))
|
||||
.map_err(|error| format!("读取远程目录失败: {error}"))?
|
||||
.into_iter()
|
||||
.filter_map(|(path, stat)| {
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
if filename == "." || filename == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(dir_entry(filename, stat))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.filename.to_lowercase());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn sftp_upload(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
sftp_id: &str,
|
||||
local_path: &str,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
upload_file(
|
||||
&sftp,
|
||||
&expand_home(local_path),
|
||||
remote_path,
|
||||
|transferred, total| emit_progress(app, sftp_id, transferred, total),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sftp_download(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
sftp_id: &str,
|
||||
remote_path: &str,
|
||||
local_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let local_path = expand_home(local_path);
|
||||
download_file(&sftp, remote_path, &local_path, |transferred, total| {
|
||||
emit_progress(app, sftp_id, transferred, total)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_rename(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
old_remote_path: &str,
|
||||
new_remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
self.sftp(sftp_id)?
|
||||
.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
|
||||
.map_err(|error| format!("远程重命名失败: {error}"))
|
||||
}
|
||||
|
||||
pub fn sftp_delete(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
remote_path: &str,
|
||||
directory: bool,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
if directory {
|
||||
sftp.rmdir(Path::new(remote_path))
|
||||
.map_err(|error| format!("删除远程目录失败: {error}"))
|
||||
} else {
|
||||
sftp.unlink(Path::new(remote_path))
|
||||
.map_err(|error| format!("删除远程文件失败: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sftp_mkdir(&self, sftp_id: &str, remote_path: &str) -> Result<(), String> {
|
||||
self.sftp(sftp_id)?
|
||||
.mkdir(Path::new(remote_path), 0o755)
|
||||
.map_err(|error| format!("创建远程目录失败: {error}"))
|
||||
}
|
||||
|
||||
pub fn sftp_stat(&self, sftp_id: &str, remote_path: &str) -> Result<FileStat, String> {
|
||||
self.sftp(sftp_id)?
|
||||
.lstat(Path::new(remote_path))
|
||||
.map_err(|error| format!("读取远程属性失败: {error}"))
|
||||
}
|
||||
|
||||
pub fn sftp_chmod(&self, sftp_id: &str, remote_path: &str, mode: u32) -> Result<(), String> {
|
||||
self.sftp(sftp_id)?
|
||||
.setstat(
|
||||
Path::new(remote_path),
|
||||
FileStat {
|
||||
size: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
perm: Some(mode),
|
||||
atime: None,
|
||||
mtime: None,
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("修改远程权限失败: {error}"))
|
||||
}
|
||||
|
||||
pub fn sftp_read_text(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
remote_path: &str,
|
||||
) -> Result<(String, u64), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let mut file = sftp
|
||||
.open(Path::new(remote_path))
|
||||
.map_err(|error| format!("打开远程文件失败: {error}"))?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|error| format!("远程文件不是有效 UTF-8 文本: {error}"))?;
|
||||
let size = content.len() as u64;
|
||||
Ok((content, size))
|
||||
}
|
||||
|
||||
pub fn sftp_write_text(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
remote_path: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let mut file = sftp
|
||||
.create(Path::new(remote_path))
|
||||
.map_err(|error| format!("创建远程文件失败: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("写入远程文件失败: {error}"))
|
||||
}
|
||||
|
||||
pub(super) fn sftp(&self, sftp_id: &str) -> Result<ssh2::Sftp, String> {
|
||||
let connection_id = self
|
||||
.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.get(sftp_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
|
||||
self.connection(&connection_id)?
|
||||
.session
|
||||
.sftp()
|
||||
.map_err(|error| format!("创建 SFTP 通道失败: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_entry(filename: String, stat: FileStat) -> SftpDirEntry {
|
||||
let mode = stat.perm.unwrap_or(0);
|
||||
let kind = match mode & 0o170000 {
|
||||
0o040000 => 'd',
|
||||
0o120000 => 'l',
|
||||
_ => '-',
|
||||
};
|
||||
SftpDirEntry {
|
||||
longname: format!("{}{}", kind, permission_string(mode)),
|
||||
filename,
|
||||
attrs: SftpFileAttrs {
|
||||
mode,
|
||||
size: stat.size.unwrap_or(0),
|
||||
mtime: stat.mtime.unwrap_or(0),
|
||||
atime: stat.atime.unwrap_or(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_string(mode: u32) -> String {
|
||||
let mut output = String::with_capacity(9);
|
||||
for shift in [6, 3, 0] {
|
||||
let bits = (mode >> shift) & 7;
|
||||
output.push(if bits & 4 != 0 { 'r' } else { '-' });
|
||||
output.push(if bits & 2 != 0 { 'w' } else { '-' });
|
||||
output.push(if bits & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn upload_file(
|
||||
sftp: &ssh2::Sftp,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
let mut source = fs::File::open(local_path)
|
||||
.map_err(|error| format!("打开本地文件失败 ({}): {error}", local_path.display()))?;
|
||||
let total = source
|
||||
.metadata()
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
let partial_path = format!("{remote_path}.opsx-part");
|
||||
let mut offset = sftp
|
||||
.stat(Path::new(&partial_path))
|
||||
.ok()
|
||||
.and_then(|stat| stat.size)
|
||||
.unwrap_or(0);
|
||||
if offset > total {
|
||||
let _ = sftp.unlink(Path::new(&partial_path));
|
||||
offset = 0;
|
||||
}
|
||||
source
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("定位本地断点失败: {error}"))?;
|
||||
let mut target = sftp
|
||||
.open_mode(
|
||||
Path::new(&partial_path),
|
||||
OpenFlags::WRITE | OpenFlags::CREATE,
|
||||
0o644,
|
||||
OpenType::File,
|
||||
)
|
||||
.map_err(|error| format!("打开远程临时文件失败 ({partial_path}): {error}"))?;
|
||||
target
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("定位远程断点失败: {error}"))?;
|
||||
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
drop(target);
|
||||
let uploaded = sftp
|
||||
.stat(Path::new(&partial_path))
|
||||
.ok()
|
||||
.and_then(|stat| stat.size)
|
||||
.unwrap_or(0);
|
||||
if uploaded != total {
|
||||
return Err(format!(
|
||||
"上传大小校验失败: 本地 {total} 字节,远程 {uploaded} 字节"
|
||||
));
|
||||
}
|
||||
finalize_remote_transfer(sftp, &partial_path, remote_path)
|
||||
}
|
||||
|
||||
pub(super) fn download_file(
|
||||
sftp: &ssh2::Sftp,
|
||||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
let mut source = sftp
|
||||
.open(Path::new(remote_path))
|
||||
.map_err(|error| format!("打开远程文件失败 ({remote_path}): {error}"))?;
|
||||
let total = source.stat().ok().and_then(|stat| stat.size).unwrap_or(0);
|
||||
let partial_path = local_partial_path(local_path);
|
||||
let mut offset = partial_path
|
||||
.metadata()
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
if offset > total {
|
||||
fs::remove_file(&partial_path).map_err(|error| format!("清理无效下载断点失败: {error}"))?;
|
||||
offset = 0;
|
||||
}
|
||||
source
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("定位远程断点失败: {error}"))?;
|
||||
let mut target = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&partial_path)
|
||||
.map_err(|error| format!("打开本地临时文件失败 ({}): {error}", partial_path.display()))?;
|
||||
target
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| format!("定位本地断点失败: {error}"))?;
|
||||
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
target
|
||||
.sync_all()
|
||||
.map_err(|error| format!("同步本地临时文件失败: {error}"))?;
|
||||
drop(target);
|
||||
let downloaded = partial_path
|
||||
.metadata()
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
if downloaded != total {
|
||||
return Err(format!(
|
||||
"下载大小校验失败: 远程 {total} 字节,本地 {downloaded} 字节"
|
||||
));
|
||||
}
|
||||
if local_path.is_file() {
|
||||
fs::remove_file(local_path).map_err(|error| format!("替换本地文件失败: {error}"))?;
|
||||
}
|
||||
fs::rename(&partial_path, local_path).map_err(|error| format!("提交下载文件失败: {error}"))
|
||||
}
|
||||
|
||||
pub(super) fn copy_with_progress(
|
||||
reader: &mut impl Read,
|
||||
writer: &mut impl Write,
|
||||
initial: u64,
|
||||
total: u64,
|
||||
mut progress: impl FnMut(u64),
|
||||
) -> Result<(), String> {
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
let mut transferred = initial;
|
||||
progress(transferred);
|
||||
loop {
|
||||
let length = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("读取传输数据失败: {error}"))?;
|
||||
if length == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..length])
|
||||
.map_err(|error| format!("写入传输数据失败: {error}"))?;
|
||||
transferred += length as u64;
|
||||
progress(transferred);
|
||||
}
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|error| format!("刷新传输数据失败: {error}"))?;
|
||||
if transferred < total {
|
||||
progress(transferred);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finalize_remote_transfer(
|
||||
sftp: &ssh2::Sftp,
|
||||
partial_path: &str,
|
||||
remote_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let partial = Path::new(partial_path);
|
||||
let destination = Path::new(remote_path);
|
||||
if sftp
|
||||
.rename(
|
||||
partial,
|
||||
destination,
|
||||
Some(RenameFlags::OVERWRITE | RenameFlags::ATOMIC | RenameFlags::NATIVE),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let _ = sftp.unlink(destination);
|
||||
sftp.rename(partial, destination, Some(RenameFlags::OVERWRITE))
|
||||
.map_err(|error| format!("提交远程上传文件失败: {error}"))
|
||||
}
|
||||
|
||||
pub(super) fn local_partial_path(destination: &Path) -> PathBuf {
|
||||
let mut value = destination.as_os_str().to_os_string();
|
||||
value.push(".opsx-part");
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, sftp_id: &str, transferred: u64, total: u64) {
|
||||
let percentage = transferred
|
||||
.saturating_mul(100)
|
||||
.checked_div(total)
|
||||
.unwrap_or(100)
|
||||
.min(100) as u8;
|
||||
let _ = app.emit(
|
||||
"sftp:progress",
|
||||
SftpProgressEvent {
|
||||
sftp_id: sftp_id.to_string(),
|
||||
transferred,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user