Files
opsxshell/src-tauri/src/services/ssh.rs
T

763 lines
26 KiB
Rust

use std::{
collections::HashMap,
io::{self, Read, Write},
net::{Shutdown, TcpStream},
sync::Mutex,
thread,
time::Duration,
};
use ssh2::Session;
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
use crate::domain::ssh::{
ConnectionClosedEvent, ExecResponse, ForwardStatus, ShellClosedEvent, ShellDataEvent,
SshConnectRequest,
};
use super::tunnel::TunnelManager;
mod auth;
mod known_hosts;
mod path;
mod proxy;
mod sftp;
use auth::authenticate;
use known_hosts::verify_host_key;
use proxy::connect_transport;
#[derive(Clone)]
struct Connection {
session: Session,
config: SshConnectRequest,
}
struct Shell {
connection_id: String,
session: Session,
channel: ssh2::Channel,
}
#[derive(Default)]
pub struct SshService {
connections: Mutex<HashMap<String, Connection>>,
shells: Mutex<HashMap<String, Shell>>,
sftps: Mutex<HashMap<String, String>>,
tunnels: TunnelManager,
}
impl SshService {
pub fn connect(&self, config: SshConnectRequest) -> Result<String, String> {
validate_config(&config)?;
let session = establish_session(&config)?;
let id = Uuid::new_v4().to_string();
self.connections
.lock()
.map_err(lock_error)?
.insert(id.clone(), Connection { session, config });
Ok(id)
}
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
self.tunnels.stop_connection(connection_id)?;
let shell_ids = {
let shells = self.shells.lock().map_err(lock_error)?;
shells
.iter()
.filter_map(|(id, shell)| {
(shell.connection_id == connection_id).then_some(id.clone())
})
.collect::<Vec<_>>()
};
for shell_id in shell_ids {
if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) {
let _ = shell.channel.close();
let _ = shell.session.disconnect(None, "closed by user", None);
}
}
self.sftps
.lock()
.map_err(lock_error)?
.retain(|_, id| id != connection_id);
if let Some(connection) = self
.connections
.lock()
.map_err(lock_error)?
.remove(connection_id)
{
let _ = connection.session.disconnect(None, "closed by user", None);
}
let _ = app.emit(
"ssh:connection-closed",
ConnectionClosedEvent {
connection_id: connection_id.to_string(),
reason: "closed by user".to_string(),
},
);
Ok(())
}
pub fn exec_with_status(
&self,
connection_id: &str,
command: &str,
) -> Result<(String, i32), String> {
let connection = self.connection(connection_id)?;
let mut channel = connection
.session
.channel_session()
.map_err(|error| format!("创建命令通道失败: {error}"))?;
channel
.exec(command)
.map_err(|error| format!("执行命令失败: {error}"))?;
let mut output = String::new();
channel
.read_to_string(&mut output)
.map_err(|error| format!("读取命令输出失败: {error}"))?;
let mut stderr = String::new();
let _ = channel.stderr().read_to_string(&mut stderr);
let _ = channel.wait_close();
let exit_code = channel.exit_status().unwrap_or(-1);
if !stderr.is_empty() {
output.push_str(&stderr);
}
Ok((output, exit_code))
}
pub fn keepalive(&self, connection_id: &str) -> Result<(), String> {
self.connection(connection_id)?
.session
.keepalive_send()
.map(|_| ())
.map_err(|error| format!("SSH 保活失败: {error}"))?;
// Interactive panes use dedicated SSH sessions so a busy SFTP/exec
// channel cannot stall terminal IO. Keep those transports alive too.
let shell_sessions = self
.shells
.lock()
.map_err(lock_error)?
.values()
.filter(|shell| shell.connection_id == connection_id)
.map(|shell| shell.session.clone())
.collect::<Vec<_>>();
for session in shell_sessions {
session
.keepalive_send()
.map_err(|error| format!("Shell SSH 保活失败: {error}"))?;
}
Ok(())
}
pub fn create_shell(
self: &std::sync::Arc<Self>,
app: AppHandle,
connection_id: &str,
cols: u32,
rows: u32,
preferred_id: Option<String>,
) -> Result<String, String> {
let connection = self.connection(connection_id)?;
// A dedicated libssh2 session keeps the interactive non-blocking reader
// from serializing SFTP and exec operations on the pooled connection.
let session = establish_session(&connection.config)?;
let mut channel = session
.channel_session()
.map_err(|error| format!("创建 Shell 通道失败: {error}"))?;
channel
.request_pty("xterm-256color", None, Some((cols, rows, 0, 0)))
.map_err(|error| format!("申请远程 PTY 失败: {error}"))?;
channel
.shell()
.map_err(|error| format!("启动远程 Shell 失败: {error}"))?;
session.set_blocking(false);
let shell_id = preferred_id.unwrap_or_else(|| format!("shell-{}", Uuid::new_v4()));
let reader = channel.clone();
self.shells.lock().map_err(lock_error)?.insert(
shell_id.clone(),
Shell {
connection_id: connection_id.to_string(),
session,
channel,
},
);
spawn_shell_reader(
std::sync::Arc::clone(self),
app,
connection_id.to_string(),
shell_id.clone(),
reader,
);
Ok(shell_id)
}
pub fn shell_write(&self, shell_id: &str, data: &[u8]) -> Result<(), String> {
let mut shells = self.shells.lock().map_err(lock_error)?;
let shell = shells
.get_mut(shell_id)
.ok_or_else(|| format!("远程 Shell 不存在: {shell_id}"))?;
write_nonblocking(&mut shell.channel, data)
.map_err(|error| format!("写入远程 Shell 失败: {error}"))
}
pub fn shell_resize(&self, shell_id: &str, cols: u32, rows: u32) -> Result<(), String> {
let mut shells = self.shells.lock().map_err(lock_error)?;
let shell = shells
.get_mut(shell_id)
.ok_or_else(|| format!("远程 Shell 不存在: {shell_id}"))?;
retry_would_block(|| shell.channel.request_pty_size(cols, rows, None, None))
.map_err(|error| format!("调整远程终端尺寸失败: {error}"))
}
pub fn forward_local(
&self,
connection_id: &str,
forward_id: Option<String>,
local_port: u16,
remote_host: String,
remote_port: u16,
) -> Result<ForwardStatus, String> {
let config = self.connection(connection_id)?.config;
self.tunnels.start_local(
forward_id,
connection_id,
config,
local_port,
remote_host,
remote_port,
)
}
pub fn forward_dynamic(
&self,
connection_id: &str,
forward_id: Option<String>,
local_port: u16,
) -> Result<ForwardStatus, String> {
let config = self.connection(connection_id)?.config;
self.tunnels
.start_dynamic(forward_id, connection_id, config, local_port)
}
pub fn forward_remote(
&self,
connection_id: &str,
forward_id: Option<String>,
remote_port: u16,
local_host: String,
local_port: u16,
) -> Result<ForwardStatus, String> {
let config = self.connection(connection_id)?.config;
self.tunnels.start_remote(
forward_id,
connection_id,
config,
remote_port,
local_host,
local_port,
)
}
pub fn forward_stop(&self, forward_id: &str) -> Result<(), String> {
self.tunnels.stop(forward_id)
}
pub fn forward_list(&self, connection_id: Option<&str>) -> Result<Vec<ForwardStatus>, String> {
self.tunnels.list(connection_id)
}
fn connection(&self, connection_id: &str) -> Result<Connection, String> {
self.connections
.lock()
.map_err(lock_error)?
.get(connection_id)
.cloned()
.ok_or_else(|| format!("SSH 连接不存在: {connection_id}"))
}
}
fn validate_config(config: &SshConnectRequest) -> Result<(), String> {
if config.host.trim().is_empty() || config.username.trim().is_empty() {
return Err("主机和用户名不能为空".to_string());
}
if config.port == 0 {
return Err("SSH 端口必须在 1-65535 范围内".to_string());
}
Ok(())
}
pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, String> {
let timeout_seconds = config.timeout_seconds.unwrap_or(15).clamp(3, 120);
let address = format!("{}:{}", config.host.trim(), config.port);
let tcp = connect_transport(config, timeout_seconds)?;
tcp.set_read_timeout(Some(Duration::from_secs(timeout_seconds)))
.ok();
tcp.set_write_timeout(Some(Duration::from_secs(timeout_seconds)))
.ok();
let mut session = Session::new().map_err(|error| format!("初始化 SSH 失败: {error}"))?;
session.set_tcp_stream(tcp);
session.set_timeout(timeout_seconds.saturating_mul(1_000) as u32);
session
.handshake()
.map_err(|error| format!("SSH 握手失败 ({address}): {error}"))?;
session.set_keepalive(config.keep_alive.unwrap_or(true), 30);
verify_host_key(&session, &config.host, config.port)?;
authenticate(&session, config)?;
Ok(session)
}
fn spawn_shell_reader(
service: std::sync::Arc<SshService>,
app: AppHandle,
connection_id: String,
shell_id: String,
mut channel: ssh2::Channel,
) {
thread::spawn(move || {
let mut buffer = [0_u8; 8192];
let mut output_tail = Vec::with_capacity(SHELL_OUTPUT_TAIL_LIMIT);
let mut read_error = None;
loop {
match channel.read(&mut buffer) {
Ok(0) if channel.eof() => break,
Ok(0) => thread::sleep(Duration::from_millis(8)),
Ok(length) => {
append_output_tail(&mut output_tail, &buffer[..length]);
let _ = app.emit(
"ssh:shell-data",
ShellDataEvent {
connection_id: connection_id.clone(),
shell_id: shell_id.clone(),
data: buffer[..length].to_vec(),
},
);
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(8));
}
Err(error) => {
read_error = Some(error.to_string());
break;
}
}
}
// disconnect() removes the shell before closing its channel. In that
// case the connection-closed event is authoritative and a second
// shell-closed event would briefly show a misleading recovery prompt.
let was_active = service
.shells
.lock()
.map(|mut shells| shells.remove(&shell_id).is_some())
.unwrap_or(false);
if !was_active {
return;
}
// Interactive shells use a dedicated SSH session. EOF, Ctrl+D, logout
// and server-side TMOUT close only this shell; the pooled SSH
// connection remains available to SFTP, tunnels and sibling panes.
let (reason, idle_timeout) = classify_shell_close(&output_tail, read_error.as_deref());
let _ = app.emit(
"ssh:shell-closed",
ShellClosedEvent {
connection_id,
shell_id,
reason,
idle_timeout,
},
);
});
}
const SHELL_OUTPUT_TAIL_LIMIT: usize = 4096;
fn append_output_tail(tail: &mut Vec<u8>, chunk: &[u8]) {
if chunk.len() >= SHELL_OUTPUT_TAIL_LIMIT {
tail.clear();
tail.extend_from_slice(&chunk[chunk.len() - SHELL_OUTPUT_TAIL_LIMIT..]);
return;
}
let overflow = tail
.len()
.saturating_add(chunk.len())
.saturating_sub(SHELL_OUTPUT_TAIL_LIMIT);
if overflow > 0 {
tail.drain(..overflow);
}
tail.extend_from_slice(chunk);
}
fn classify_shell_close(output_tail: &[u8], read_error: Option<&str>) -> (String, bool) {
let output = String::from_utf8_lossy(output_tail).to_ascii_lowercase();
let idle_timeout = output.contains("timed out waiting for input")
|| output.contains("auto-logout")
|| output.contains("auto logout");
if idle_timeout {
return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true);
}
if let Some(error) = read_error {
return (format!("远端 Shell 读取失败: {error}"), false);
}
(
"远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(),
false,
)
}
fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> {
let mut written = 0;
while written < data.len() {
match channel.write(&data[written..]) {
Ok(0) => thread::sleep(Duration::from_millis(5)),
Ok(length) => written += length,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(5));
}
Err(error) => return Err(error),
}
}
Ok(())
}
fn retry_would_block<T>(
mut operation: impl FnMut() -> Result<T, ssh2::Error>,
) -> Result<T, ssh2::Error> {
loop {
match operation() {
Err(error) if error.code() == ssh2::ErrorCode::Session(-37) => {
thread::sleep(Duration::from_millis(5));
}
result => return result,
}
}
}
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
"SSH 服务状态锁异常".to_string()
}
fn relay_channel(session: Session, mut channel: ssh2::Channel, mut socket: TcpStream) {
session.set_blocking(false);
let _ = socket.set_nonblocking(true);
let mut network_buffer = [0_u8; 32 * 1024];
let mut channel_buffer = [0_u8; 32 * 1024];
let mut network_eof = false;
loop {
let mut progressed = false;
if !network_eof {
match socket.read(&mut network_buffer) {
Ok(0) => {
network_eof = true;
let _ = channel.send_eof();
}
Ok(length) => {
if write_nonblocking(&mut channel, &network_buffer[..length]).is_err() {
break;
}
progressed = true;
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(_) => break,
}
}
match channel.read(&mut channel_buffer) {
Ok(0) if channel.eof() => break,
Ok(0) => {}
Ok(length) => {
if write_socket_nonblocking(&mut socket, &channel_buffer[..length]).is_err() {
break;
}
progressed = true;
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(_) => break,
}
if !progressed {
thread::sleep(Duration::from_millis(5));
}
}
let _ = socket.shutdown(Shutdown::Both);
let _ = channel.close();
}
fn write_socket_nonblocking(socket: &mut TcpStream, data: &[u8]) -> io::Result<()> {
let mut written = 0;
while written < data.len() {
match socket.write(&data[written..]) {
Ok(0) => thread::sleep(Duration::from_millis(5)),
Ok(length) => written += length,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(5))
}
Err(error) => return Err(error),
}
}
Ok(())
}
#[allow(dead_code)]
fn _exec_response(result: Result<String, String>) -> ExecResponse {
match result {
Ok(output) => ExecResponse {
success: true,
result: Some(output),
exit_code: None,
error: None,
},
Err(error) => ExecResponse {
success: false,
result: None,
exit_code: None,
error: Some(error),
},
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
io::{Seek, SeekFrom},
path::Path,
};
use super::sftp::{copy_with_progress, download_file, local_partial_path, upload_file};
use super::*;
#[test]
fn detects_remote_shell_idle_timeout_from_recent_output() {
let (reason, idle_timeout) =
classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None);
assert!(idle_timeout);
assert!(reason.contains("TMOUT"));
}
#[test]
fn shell_output_tail_is_bounded_and_keeps_latest_marker() {
let mut tail = vec![b'x'; SHELL_OUTPUT_TAIL_LIMIT];
append_output_tail(&mut tail, b"auto-logout");
assert_eq!(tail.len(), SHELL_OUTPUT_TAIL_LIMIT);
assert!(tail.ends_with(b"auto-logout"));
}
#[test]
fn reports_normal_shell_eof_without_marking_connection_dead() {
let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None);
assert!(!idle_timeout);
assert!(reason.contains("Shell 已退出"));
}
#[test]
fn resumes_copy_from_existing_offset() {
let mut source = io::Cursor::new(b"abcdef".to_vec());
source.seek(SeekFrom::Start(3)).unwrap();
let mut target = io::Cursor::new(b"abc".to_vec());
target.seek(SeekFrom::Start(3)).unwrap();
let mut progress = Vec::new();
copy_with_progress(&mut source, &mut target, 3, 6, |value| progress.push(value)).unwrap();
assert_eq!(target.into_inner(), b"abcdef");
assert_eq!(progress.first(), Some(&3));
assert_eq!(progress.last(), Some(&6));
}
#[test]
#[ignore = "requires an explicitly configured SSH endpoint"]
fn real_ssh_exec_and_sftp_probe() {
let host = std::env::var("OPSX_TEST_SSH_HOST").expect("OPSX_TEST_SSH_HOST");
let username = std::env::var("OPSX_TEST_SSH_USER").unwrap_or_else(|_| "root".to_string());
let port = std::env::var("OPSX_TEST_SSH_PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(22);
let private_key = std::env::var("OPSX_TEST_SSH_KEY").ok();
let service = SshService::default();
let connection_id = service
.connect(SshConnectRequest {
host,
port,
username,
password: None,
private_key,
passphrase: None,
proxy: None,
jump_host: None,
jump_hosts: None,
timeout_seconds: Some(15),
keep_alive: Some(true),
})
.expect("SSH connect");
let (output, exit_code) = service
.exec_with_status(&connection_id, "printf OPSX_TAURI_OK")
.unwrap();
assert_eq!(output, "OPSX_TAURI_OK");
assert_eq!(exit_code, 0);
service
.keepalive(&connection_id)
.expect("SSH protocol keepalive");
let sftp_id = service.create_sftp(&connection_id).expect("SFTP connect");
assert!(!service
.sftp_list(&sftp_id, "/")
.expect("SFTP list")
.is_empty());
let probe_id = Uuid::new_v4().to_string();
let local_source = std::env::temp_dir().join(format!("opsxshell-upload-{probe_id}.txt"));
let local_download =
std::env::temp_dir().join(format!("opsxshell-download-{probe_id}.txt"));
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
fs::write(&local_source, &payload).expect("write local SFTP fixture");
let sftp = service.sftp(&sftp_id).expect("SFTP channel");
let transfer_result = (|| -> Result<(), String> {
upload_file(&sftp, &local_source, &remote_path, |_, _| {})?;
download_file(&sftp, &remote_path, &local_download, |_, _| {})?;
let downloaded = fs::read(&local_download)
.map_err(|error| format!("read downloaded probe: {error}"))?;
if downloaded != payload {
return Err("SFTP round-trip content mismatch".to_string());
}
Ok(())
})();
let _ = sftp.unlink(Path::new(&remote_path));
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
let _ = fs::remove_file(&local_source);
let _ = fs::remove_file(&local_download);
let _ = fs::remove_file(local_partial_path(&local_download));
transfer_result.expect("SFTP upload/download round trip");
let connection = service.connection(&connection_id).unwrap();
let shell_session = establish_session(&connection.config).expect("shell SSH session");
let mut shell = shell_session.channel_session().expect("shell channel");
shell
.request_pty("xterm-256color", None, Some((80, 24, 0, 0)))
.expect("request PTY");
shell.shell().expect("start shell");
shell
.write_all(b"printf OPSX_SHELL_OK; exit\n")
.expect("write shell");
let mut output = String::new();
shell.read_to_string(&mut output).expect("read shell");
assert!(
output.contains("OPSX_SHELL_OK"),
"unexpected shell output: {output}"
);
}
#[test]
#[ignore = "requires an explicitly configured SSH endpoint"]
fn real_dynamic_forward_probe() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let host = std::env::var("OPSX_TEST_SSH_HOST").expect("OPSX_TEST_SSH_HOST");
let username = std::env::var("OPSX_TEST_SSH_USER").unwrap_or_else(|_| "root".to_string());
let port = std::env::var("OPSX_TEST_SSH_PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(22);
let private_key = std::env::var("OPSX_TEST_SSH_KEY").ok();
let service = SshService::default();
let connection_id = service
.connect(SshConnectRequest {
host: host.clone(),
port,
username,
password: None,
private_key,
passphrase: None,
proxy: None,
jump_host: None,
jump_hosts: None,
timeout_seconds: Some(20),
keep_alive: Some(true),
})
.expect("SSH connect");
let forward_id = format!("real-socks-{}", Uuid::new_v4());
let status = service
.forward_dynamic(&connection_id, Some(forward_id.clone()), 0)
.expect("start dynamic forward");
let mut client = TcpStream::connect(("127.0.0.1", status.bound_port))
.expect("connect local SOCKS5 listener");
client
.set_read_timeout(Some(Duration::from_secs(25)))
.unwrap();
client.write_all(&[5, 1, 0]).unwrap();
let mut greeting = [0_u8; 2];
client.read_exact(&mut greeting).unwrap();
assert_eq!(greeting, [5, 0]);
let target = host.as_bytes();
assert!(target.len() <= 255);
let mut request = vec![5, 1, 0, 3, target.len() as u8];
request.extend_from_slice(target);
request.extend_from_slice(&port.to_be_bytes());
client.write_all(&request).unwrap();
let mut reply = [0_u8; 10];
client.read_exact(&mut reply).unwrap();
assert_eq!(&reply[..2], &[5, 0]);
let mut banner = [0_u8; 4];
client.read_exact(&mut banner).unwrap();
assert_eq!(&banner, b"SSH-");
service.forward_stop(&forward_id).expect("stop forward");
assert!(service
.forward_list(Some(&connection_id))
.unwrap()
.is_empty());
}
#[test]
#[ignore = "requires an explicitly configured SSH endpoint reachable through a jump host"]
fn real_proxy_jump_probe() {
let host = std::env::var("OPSX_TEST_SSH_HOST").expect("OPSX_TEST_SSH_HOST");
let jump_host = std::env::var("OPSX_TEST_SSH_JUMP_HOST").expect("OPSX_TEST_SSH_JUMP_HOST");
let username = std::env::var("OPSX_TEST_SSH_USER").unwrap_or_else(|_| "root".to_string());
let port = std::env::var("OPSX_TEST_SSH_PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(22);
let jump_port = std::env::var("OPSX_TEST_SSH_JUMP_PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(22);
let jump = serde_json::json!({
"host": jump_host,
"port": jump_port,
"username": username,
});
let jump_count = std::env::var("OPSX_TEST_SSH_JUMP_COUNT")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(1)
.clamp(1, 8);
let service = SshService::default();
let connection_id = service
.connect(SshConnectRequest {
host,
port,
username,
password: None,
private_key: None,
passphrase: None,
proxy: None,
jump_host: (jump_count == 1).then(|| jump.clone()),
jump_hosts: (jump_count > 1).then(|| vec![jump; jump_count]),
timeout_seconds: Some(20),
keep_alive: Some(true),
})
.expect("SSH connect through jump host");
let (output, exit_code) = service
.exec_with_status(&connection_id, "printf OPSX_PROXY_JUMP_OK")
.unwrap();
assert_eq!(output, "OPSX_PROXY_JUMP_OK");
assert_eq!(exit_code, 0);
}
}