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,671 @@
|
||||
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, 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(&self, connection_id: &str, command: &str) -> Result<String, 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();
|
||||
if !stderr.is_empty() {
|
||||
output.push_str(&stderr);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
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,
|
||||
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(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(
|
||||
app: AppHandle,
|
||||
connection_id: String,
|
||||
shell_id: String,
|
||||
mut channel: ssh2::Channel,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let mut disconnect_reason = None;
|
||||
loop {
|
||||
match channel.read(&mut buffer) {
|
||||
Ok(0) if channel.eof() => break,
|
||||
Ok(0) => thread::sleep(Duration::from_millis(8)),
|
||||
Ok(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) => {
|
||||
disconnect_reason = Some(format!("远程 Shell 读取失败: {error}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A normal channel EOF (logout/Ctrl+D) only closes this shell. It must
|
||||
// not tear down the pooled SSH connection or unrelated split panes.
|
||||
if let Some(reason) = disconnect_reason {
|
||||
let _ = app.emit(
|
||||
"ssh:connection-closed",
|
||||
ConnectionClosedEvent {
|
||||
connection_id,
|
||||
reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ExecResponse {
|
||||
success: false,
|
||||
result: 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 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");
|
||||
assert_eq!(
|
||||
service
|
||||
.exec(&connection_id, "printf OPSX_TAURI_OK")
|
||||
.unwrap(),
|
||||
"OPSX_TAURI_OK"
|
||||
);
|
||||
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");
|
||||
assert_eq!(
|
||||
service
|
||||
.exec(&connection_id, "printf OPSX_PROXY_JUMP_OK")
|
||||
.unwrap(),
|
||||
"OPSX_PROXY_JUMP_OK"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user