diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b6c17fd7..9ae02f2a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,9 +1,9 @@ use crate::connection_manager::ConnectionManager; use crate::ftp_client::FtpConfig; use crate::os_detect::{self, OsInfo}; +use crate::proxy::{ProxyConfig, ProxyType}; use crate::sftp_client::{FileEntry, FileEntryType, SftpAuthMethod, SftpConfig}; use crate::ssh::{AuthMethod, SshConfig}; -use base64::Engine as _; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; @@ -18,6 +18,18 @@ pub struct ConnectRequest { pub password: Option, pub key_path: Option, pub passphrase: Option, + /// Advanced SSH options — `Option` so legacy callers that omit them keep + /// the previous defaults (compression on, keepalive 60 s / 3). + pub compression: Option, + pub keepalive_enabled: Option, + pub keepalive_interval: Option, + pub keepalive_max: Option, + /// Proxy options — ignored when `proxy_type` is "none" or missing. + pub proxy_type: Option, + pub proxy_host: Option, + pub proxy_port: Option, + pub proxy_username: Option, + pub proxy_password: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -58,6 +70,21 @@ pub async fn ssh_connect( request: ConnectRequest, state: State<'_, Arc>, ) -> Result { + let proxy = build_proxy(&request)?; + + // Keepalive defaults match the connection dialog UI: enabled at 60 s / 3. + let keepalive_enabled = request.keepalive_enabled.unwrap_or(true); + let keepalive_interval = if keepalive_enabled { + Some(request.keepalive_interval.unwrap_or(60)) + } else { + None + }; + let keepalive_max = if keepalive_enabled { + Some(request.keepalive_max.unwrap_or(3)) + } else { + None + }; + let auth_method = match request.auth_method.as_str() { "password" => AuthMethod::Password { password: request.password.ok_or("Password required")?, @@ -74,6 +101,10 @@ pub async fn ssh_connect( port: request.port, username: request.username, auth_method, + compression: request.compression.unwrap_or(true), + keepalive_interval, + keepalive_max, + proxy, }; match state @@ -93,6 +124,34 @@ pub async fn ssh_connect( } } +/// Map the proxy request fields into a `ProxyConfig`, or `None` when the +/// connection should go direct. +fn build_proxy(request: &ConnectRequest) -> Result, String> { + match request.proxy_type.as_deref() { + None | Some("none") | Some("") => Ok(None), + Some(kind) => { + let host = request + .proxy_host + .clone() + .filter(|h| !h.trim().is_empty()) + .ok_or("Proxy host is required")?; + let proxy_type = match kind { + "http" => ProxyType::Http, + "socks4" => ProxyType::Socks4, + "socks5" => ProxyType::Socks5, + other => return Err(format!("Invalid proxy type: {other}")), + }; + Ok(Some(ProxyConfig { + proxy_type, + host, + port: request.proxy_port.unwrap_or(8080), + username: request.proxy_username.clone(), + password: request.proxy_password.clone(), + })) + } + } +} + #[tauri::command] pub async fn ssh_cancel_connect( connection_id: String, @@ -3327,3 +3386,88 @@ mod local_fs_tests { assert_eq!(s, "2024-01-01 00:00:00"); } } + +#[cfg(test)] +mod proxy_config_tests { + use super::*; + + fn request(proxy_type: Option<&str>) -> ConnectRequest { + ConnectRequest { + connection_id: "c1".to_string(), + host: "example.com".to_string(), + port: 22, + username: "root".to_string(), + auth_method: "password".to_string(), + password: Some("pw".to_string()), + key_path: None, + passphrase: None, + compression: None, + keepalive_enabled: None, + keepalive_interval: None, + keepalive_max: None, + proxy_type: proxy_type.map(|s| s.to_string()), + proxy_host: None, + proxy_port: None, + proxy_username: None, + proxy_password: None, + } + } + + #[test] + fn no_proxy_when_type_none_or_none_type() { + assert!(build_proxy(&request(None)).unwrap().is_none()); + assert!(build_proxy(&request(Some("none"))).unwrap().is_none()); + assert!(build_proxy(&request(Some(""))).unwrap().is_none()); + } + + /// Request with a proxy host set, so type/host validation reaches the type. + fn request_with_host(proxy_type: &str) -> ConnectRequest { + let mut req = request(Some(proxy_type)); + req.proxy_host = Some("proxy.local".to_string()); + req + } + + #[test] + fn maps_supported_proxy_types() { + let http = build_proxy(&request_with_host("http")).unwrap().unwrap(); + assert_eq!(http.proxy_type, ProxyType::Http); + let socks5 = build_proxy(&request_with_host("socks5")).unwrap().unwrap(); + assert_eq!(socks5.proxy_type, ProxyType::Socks5); + let socks4 = build_proxy(&request_with_host("socks4")).unwrap().unwrap(); + assert_eq!(socks4.proxy_type, ProxyType::Socks4); + } + + #[test] + fn requires_proxy_host() { + let err = build_proxy(&request(Some("http"))).unwrap_err(); + assert!(err.contains("Proxy host is required")); + } + + #[test] + fn rejects_unknown_proxy_type() { + let err = build_proxy(&request_with_host("ftp")).unwrap_err(); + assert!(err.contains("Invalid proxy type")); + } + + #[test] + fn carries_proxy_credentials_and_port() { + let mut req = request(Some("socks5")); + req.proxy_host = Some("proxy.local".to_string()); + req.proxy_port = Some(1080); + req.proxy_username = Some("user".to_string()); + req.proxy_password = Some("pass".to_string()); + let cfg = build_proxy(&req).unwrap().unwrap(); + assert_eq!(cfg.host, "proxy.local"); + assert_eq!(cfg.port, 1080); + assert_eq!(cfg.username.as_deref(), Some("user")); + assert_eq!(cfg.password.as_deref(), Some("pass")); + } + + #[test] + fn defaults_proxy_port_to_8080() { + let mut req = request(Some("http")); + req.proxy_host = Some("proxy.local".to_string()); + let cfg = build_proxy(&req).unwrap().unwrap(); + assert_eq!(cfg.port, 8080); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6f1bad4..dcb3d1b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod desktop_protocol; mod ftp_client; mod ls_parser; mod os_detect; +mod proxy; mod rdp_client; mod sftp_client; mod ssh; diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs new file mode 100644 index 00000000..c063bdaa --- /dev/null +++ b/src-tauri/src/proxy.rs @@ -0,0 +1,641 @@ +use anyhow::{bail, Result}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::TcpStream; + +/// Proxy protocol used to tunnel the SSH connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProxyType { + Http, + Socks4, + Socks5, +} + +/// Proxy server configuration applied when establishing the SSH connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyConfig { + pub proxy_type: ProxyType, + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, +} + +/// A TCP stream that may already hold bytes read past the proxy handshake. +/// +/// The HTTP CONNECT read loop can consume more bytes than the header (e.g. the +/// server's SSH banner arriving early). Those bytes are parked in `pending` and +/// replayed on the first read so russh sees the full stream. +pub struct Tunnel { + stream: TcpStream, + pending: Vec, +} + +impl AsyncRead for Tunnel { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if !self.pending.is_empty() { + let n = std::cmp::min(buf.remaining(), self.pending.len()); + buf.put_slice(&self.pending[..n]); + self.pending.drain(..n); + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.stream).poll_read(cx, buf) + } +} + +impl AsyncWrite for Tunnel { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} + +/// Establish a TCP tunnel to (host, port) through the configured proxy. +/// +/// The returned stream is connected to the target via the proxy's CONNECT +/// handshake and can be handed to russh's `connect_stream` so the SSH handshake +/// runs over the tunnel. +pub async fn connect_via_proxy( + proxy: &ProxyConfig, + host: &str, + port: u16, + timeout: Duration, +) -> Result { + let mut stream = tokio::time::timeout( + timeout, + TcpStream::connect((proxy.host.as_str(), proxy.port)), + ) + .await + .map_err(|_| { + anyhow::anyhow!( + "Proxy connection timed out. Please check the proxy address and network connectivity." + ) + })? + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to proxy {}:{}: {}", + proxy.host, + proxy.port, + e + ) + })?; + + let pending = match proxy.proxy_type { + ProxyType::Http => { + http_connect( + &mut stream, + host, + port, + proxy.username.as_deref(), + proxy.password.as_deref(), + ) + .await? + } + ProxyType::Socks4 => { + socks4_connect(&mut stream, host, port, proxy.username.as_deref()).await?; + Vec::new() + } + ProxyType::Socks5 => { + socks5_connect( + &mut stream, + host, + port, + proxy.username.as_deref(), + proxy.password.as_deref(), + ) + .await?; + Vec::new() + } + }; + + Ok(Tunnel { stream, pending }) +} + +/// Perform an HTTP CONNECT handshake so the stream is tunneled to host:port. +/// +/// Returns any bytes read past the header block, which belong to the SSH +/// connection itself. +async fn http_connect( + stream: &mut TcpStream, + host: &str, + port: u16, + username: Option<&str>, + password: Option<&str>, +) -> Result> { + let mut request = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"); + if let (Some(u), Some(p)) = (username, password) { + let credentials = base64::engine::general_purpose::STANDARD.encode(format!("{u}:{p}")); + request.push_str(&format!("Proxy-Authorization: Basic {credentials}\r\n")); + } + request.push_str("\r\n"); + + stream + .write_all(request.as_bytes()) + .await + .map_err(|e| anyhow::anyhow!("Failed to send HTTP CONNECT request: {e}"))?; + + // Read until the terminating empty line, keeping any bytes that arrived + // past the header (e.g. the SSH banner) for the caller. + let mut response = Vec::new(); + let mut buf = [0u8; 1024]; + let header_end = loop { + let n = stream + .read(&mut buf) + .await + .map_err(|e| anyhow::anyhow!("Failed to read proxy response: {e}"))?; + if n == 0 { + bail!("Proxy closed the connection during the CONNECT handshake"); + } + response.extend_from_slice(&buf[..n]); + if let Some(pos) = find_header_end(&response) { + break pos; + } + }; + + let extra = response.split_off(header_end); + let header_text = String::from_utf8_lossy(&response); + let status_line = header_text.lines().next().unwrap_or(""); + if !status_line.contains(" 200 ") { + bail!("HTTP proxy CONNECT failed: {status_line}"); + } + Ok(extra) +} + +/// Returns the index just past the CRLFCRLF that terminates an HTTP header. +fn find_header_end(bytes: &[u8]) -> Option { + bytes + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|i| i + 4) +} + +/// Perform a SOCKS5 handshake (method negotiation + CONNECT with a domain name). +async fn socks5_connect( + stream: &mut TcpStream, + host: &str, + port: u16, + username: Option<&str>, + password: Option<&str>, +) -> Result<()> { + // Method negotiation: advertise no-auth plus username/password (RFC 1929) + // when credentials are provided, no-auth only otherwise. + let methods: &[u8] = if username.is_some() { + &[0x05, 0x02, 0x00, 0x02] + } else { + &[0x05, 0x01, 0x00] + }; + stream + .write_all(methods) + .await + .map_err(|e| anyhow::anyhow!("Failed to send SOCKS5 negotiation: {e}"))?; + + let mut resp = [0u8; 2]; + stream + .read_exact(&mut resp) + .await + .map_err(|e| anyhow::anyhow!("Failed to read SOCKS5 negotiation response: {e}"))?; + if resp[0] != 0x05 { + bail!("SOCKS5: invalid version {}", resp[0]); + } + match resp[1] { + 0x00 => {} + 0x02 => { + let user = username.unwrap_or(""); + let pass = password.unwrap_or(""); + let user_len = user.len().min(255) as u8; + let pass_len = pass.len().min(255) as u8; + let mut msg = Vec::with_capacity(1 + 1 + user_len as usize + 1 + pass_len as usize); + msg.push(0x01); // auth version + msg.push(user_len); + msg.extend_from_slice(&user.as_bytes()[..user_len as usize]); + msg.push(pass_len); + msg.extend_from_slice(&pass.as_bytes()[..pass_len as usize]); + stream + .write_all(&msg) + .await + .map_err(|e| anyhow::anyhow!("Failed to send SOCKS5 credentials: {e}"))?; + + let mut auth_resp = [0u8; 2]; + stream + .read_exact(&mut auth_resp) + .await + .map_err(|e| anyhow::anyhow!("Failed to read SOCKS5 auth response: {e}"))?; + if auth_resp[1] != 0x00 { + bail!("SOCKS5: username/password authentication failed"); + } + } + method => bail!("SOCKS5: no acceptable authentication method (server chose {method})"), + } + + // CONNECT request with a domain-type address (ATYP = 0x03). + let host_bytes = host.as_bytes(); + if host_bytes.len() > 255 { + bail!("SOCKS5: hostname too long"); + } + let mut msg = Vec::with_capacity(4 + host_bytes.len() + 2); + msg.push(0x05); // version + msg.push(0x01); // CONNECT + msg.push(0x00); // reserved + msg.push(0x03); // ATYP: domain name + msg.push(host_bytes.len() as u8); + msg.extend_from_slice(host_bytes); + msg.extend_from_slice(&port.to_be_bytes()); + stream + .write_all(&msg) + .await + .map_err(|e| anyhow::anyhow!("Failed to send SOCKS5 CONNECT request: {e}"))?; + + let mut resp = [0u8; 4]; + stream + .read_exact(&mut resp) + .await + .map_err(|e| anyhow::anyhow!("Failed to read SOCKS5 CONNECT response: {e}"))?; + if resp[0] != 0x05 || resp[1] != 0x00 { + bail!("SOCKS5: connection failed (reply code {:#x})", resp[1]); + } + + // Skip the bind address (ATYP + address + port). + let skip = match resp[3] { + 0x01 => 4 + 2, // IPv4 + 0x03 => { + let mut len = [0u8; 1]; + stream + .read_exact(&mut len) + .await + .map_err(|e| anyhow::anyhow!("Failed to read SOCKS5 address length: {e}"))?; + len[0] as usize + 2 + } + 0x04 => 16 + 2, // IPv6 + atyp => bail!("SOCKS5: unknown address type {atyp}"), + }; + let mut skip_buf = vec![0u8; skip]; + stream + .read_exact(&mut skip_buf) + .await + .map_err(|e| anyhow::anyhow!("Failed to read SOCKS5 bind address: {e}"))?; + + Ok(()) +} + +/// Perform a SOCKS4 CONNECT handshake. SOCKS4 only carries IPv4 addresses, so +/// the hostname is resolved locally before the request is sent. +async fn socks4_connect( + stream: &mut TcpStream, + host: &str, + port: u16, + username: Option<&str>, +) -> Result<()> { + let ip = tokio::net::lookup_host((host, port)) + .await + .map_err(|e| anyhow::anyhow!("SOCKS4: failed to resolve {host}: {e}"))? + .find_map(|addr| match addr { + std::net::SocketAddr::V4(v4) => Some(*v4.ip()), + _ => None, + }) + .ok_or_else(|| anyhow::anyhow!("SOCKS4: no IPv4 address found for {host}"))?; + + let mut msg = Vec::with_capacity(9); + msg.push(0x04); // version + msg.push(0x01); // CONNECT + msg.extend_from_slice(&port.to_be_bytes()); + msg.extend_from_slice(&ip.octets()); + msg.extend_from_slice(username.unwrap_or("").as_bytes()); + msg.push(0x00); + stream + .write_all(&msg) + .await + .map_err(|e| anyhow::anyhow!("Failed to send SOCKS4 request: {e}"))?; + + let mut resp = [0u8; 8]; + stream + .read_exact(&mut resp) + .await + .map_err(|e| anyhow::anyhow!("Failed to read SOCKS4 response: {e}"))?; + if resp[0] != 0x00 || resp[1] != 0x5a { + bail!("SOCKS4: connection failed (reply code {:#x})", resp[1]); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + fn test_proxy(proxy_type: ProxyType) -> ProxyConfig { + ProxyConfig { + proxy_type, + host: "127.0.0.1".to_string(), + port: 0, // filled in by each test + username: None, + password: None, + } + } + + /// Read until the blank line that terminates an HTTP header block. + async fn read_http_headers(sock: &mut TcpStream) -> Vec { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let n = sock.read(&mut chunk).await.unwrap(); + assert!(n > 0, "connection closed mid-header"); + buf.extend_from_slice(&chunk[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + return buf; + } + } + } + + const TIMEOUT: Duration = Duration::from_secs(5); + + #[tokio::test] + async fn http_connect_success_with_basic_auth() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let request = String::from_utf8_lossy(&read_http_headers(&mut sock).await).to_string(); + assert!( + request.starts_with("CONNECT example.com:443 HTTP/1.1\r\n"), + "unexpected request: {request}" + ); + assert!( + request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n"), + "missing basic auth: {request}" + ); + sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Http); + proxy.port = addr.port(); + proxy.username = Some("user".to_string()); + proxy.password = Some("pass".to_string()); + + let result = connect_via_proxy(&proxy, "example.com", 443, TIMEOUT).await; + assert!( + result.is_ok(), + "HTTP CONNECT should succeed: {:?}", + result.err() + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn http_connect_keeps_bytes_past_the_header() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + read_http_headers(&mut sock).await; + // Header and SSH banner arrive in a single write to simulate the + // bytes being coalesced on the wire. + sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\nSSH-2.0-test-banner\r\n") + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Http); + proxy.port = addr.port(); + + let mut tunnel = connect_via_proxy(&proxy, "example.com", 443, TIMEOUT) + .await + .expect("HTTP CONNECT should succeed"); + let mut banner = String::new(); + // Read from the tunnel: the pending bytes must be replayed first. + tokio::time::timeout(TIMEOUT, tunnel.read_to_string(&mut banner)) + .await + .unwrap() + .unwrap(); + assert!( + banner.starts_with("SSH-2.0-test-banner"), + "pending bytes should be replayed, got: {banner}" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn http_connect_rejects_non_200() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + read_http_headers(&mut sock).await; + sock.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Http); + proxy.port = addr.port(); + + let err = match connect_via_proxy(&proxy, "example.com", 443, TIMEOUT).await { + Ok(_) => panic!("expected a proxy failure"), + Err(e) => e, + }; + assert!( + err.to_string().contains("403"), + "error should mention the status line: {err}" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn socks5_no_auth_success() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + + // Method negotiation: client advertises no-auth only. + let mut greeting = [0u8; 3]; + sock.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x01, 0x00]); + sock.write_all(&[0x05, 0x00]).await.unwrap(); + + // CONNECT request with a domain address. + let mut req = Vec::new(); + let mut buf = [0u8; 128]; + let n = sock.read(&mut buf).await.unwrap(); + req.extend_from_slice(&buf[..n]); + assert_eq!(req[0], 0x05); // version + assert_eq!(req[1], 0x01); // CONNECT + assert_eq!(req[3], 0x03); // ATYP domain + assert_eq!(req[4], 11, "example.com is 11 bytes"); + assert_eq!(&req[5..16], b"example.com"); + assert_eq!(&req[16..18], &443u16.to_be_bytes()); + + // Success reply with an IPv4 bind address. + sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01, 0x1f, 0x90]) + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Socks5); + proxy.port = addr.port(); + + let result = connect_via_proxy(&proxy, "example.com", 443, TIMEOUT).await; + assert!(result.is_ok(), "SOCKS5 should succeed: {:?}", result.err()); + server.await.unwrap(); + } + + #[tokio::test] + async fn socks5_username_password_success() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + + // Greeting advertises no-auth + username/password. + let mut greeting = [0u8; 4]; + sock.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x02, 0x00, 0x02]); + sock.write_all(&[0x05, 0x02]).await.unwrap(); + + // RFC 1929 auth: version, ulen, user, plen, pass. + let mut auth = Vec::new(); + let mut buf = [0u8; 128]; + let n = sock.read(&mut buf).await.unwrap(); + auth.extend_from_slice(&buf[..n]); + assert_eq!(auth[0], 0x01); + assert_eq!(auth[1] as usize, 4); + assert_eq!(&auth[2..6], b"user"); + assert_eq!(auth[6] as usize, 4); + assert_eq!(&auth[7..11], b"pass"); + sock.write_all(&[0x01, 0x00]).await.unwrap(); + + // CONNECT then success. Domain-type request is 18 bytes: + // ver(1) cmd(1) rsv(1) atyp(1) len(1) host(11) port(2). + let mut req = [0u8; 18]; + sock.read_exact(&mut req).await.unwrap(); + assert_eq!(req[0], 0x05); + assert_eq!(req[1], 0x01); + assert_eq!(req[3], 0x03); + sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01, 0x1f, 0x90]) + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Socks5); + proxy.port = addr.port(); + proxy.username = Some("user".to_string()); + proxy.password = Some("pass".to_string()); + + let result = connect_via_proxy(&proxy, "example.com", 443, TIMEOUT).await; + assert!( + result.is_ok(), + "SOCKS5 auth should succeed: {:?}", + result.err() + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn socks5_rejects_connect_failure() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut greeting = [0u8; 3]; + sock.read_exact(&mut greeting).await.unwrap(); + sock.write_all(&[0x05, 0x00]).await.unwrap(); + let mut req = [0u8; 18]; + sock.read_exact(&mut req).await.unwrap(); + // Connection refused (0x05). + sock.write_all(&[0x05, 0x05, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01, 0x1f, 0x90]) + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Socks5); + proxy.port = addr.port(); + + let err = match connect_via_proxy(&proxy, "example.com", 443, TIMEOUT).await { + Ok(_) => panic!("expected a proxy failure"), + Err(e) => e, + }; + assert!( + err.to_string().contains("0x5"), + "error should mention code: {err}" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn socks4_success() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 128]; + let n = sock.read(&mut buf).await.unwrap(); + req.extend_from_slice(&buf[..n]); + assert_eq!(req[0], 0x04); + assert_eq!(req[1], 0x01); + // Host is resolved to 127.0.0.1 in the test, so the address bytes + // carry the loopback IP. + assert_eq!(&req[4..8], &[127, 0, 0, 1]); + sock.write_all(&[0x00, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Socks4); + proxy.port = addr.port(); + + // Use a literal IPv4 host so lookup_host resolves without DNS. + let result = connect_via_proxy(&proxy, "127.0.0.1", 22, TIMEOUT).await; + assert!(result.is_ok(), "SOCKS4 should succeed: {:?}", result.err()); + server.await.unwrap(); + } + + #[tokio::test] + async fn socks4_rejects_failure() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 128]; + sock.read(&mut buf).await.unwrap(); + sock.write_all(&[0x00, 0x5b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + .await + .unwrap(); + }); + + let mut proxy = test_proxy(ProxyType::Socks4); + proxy.port = addr.port(); + + let err = match connect_via_proxy(&proxy, "127.0.0.1", 22, TIMEOUT).await { + Ok(_) => panic!("expected a proxy failure"), + Err(e) => e, + }; + assert!( + err.to_string().contains("0x5b"), + "error should mention code: {err}" + ); + server.await.unwrap(); + } +} diff --git a/src-tauri/src/ssh/mod.rs b/src-tauri/src/ssh/mod.rs index d9388aa0..d9539300 100644 --- a/src-tauri/src/ssh/mod.rs +++ b/src-tauri/src/ssh/mod.rs @@ -1,3 +1,4 @@ +use crate::proxy::ProxyConfig; use anyhow::Result; use russh::*; use russh_keys::*; @@ -23,12 +24,38 @@ pub static PREFERRED_HOST_KEY_ALGOS: &[russh_keys::key::Name] = &[ russh_keys::key::SSH_RSA, ]; +/// Compression algorithms to advertise, ordered so zlib is preferred over none. +/// +/// Order matters: russh negotiates the first algorithm that the server also +/// lists, so zlib must come before none for compression to actually take +/// effect. `zlib@openssh.com` covers servers using OpenSSH's "delayed" +/// compression. Requires russh's `flate2` feature, which is enabled by default. +pub fn compression_preferences(enabled: bool) -> &'static [russh::compression::Name] { + if enabled { + &[ + russh::compression::ZLIB, + russh::compression::ZLIB_LEGACY, + russh::compression::NONE, + ] + } else { + &[russh::compression::NONE] + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshConfig { pub host: String, pub port: u16, pub username: String, pub auth_method: AuthMethod, + /// Enable zlib compression negotiation (default: true, matching the UI). + pub compression: bool, + /// Keepalive interval in seconds. `None` disables keepalive. + pub keepalive_interval: Option, + /// Max missed keepalive replies before the connection is closed. + pub keepalive_max: Option, + /// Optional HTTP/SOCKS proxy tunnel. `None` connects directly. + pub proxy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -86,28 +113,54 @@ impl SshClient { } pub async fn connect(&mut self, config: &SshConfig) -> Result<()> { + let keepalive_interval = config.keepalive_interval.map(Duration::from_secs); + let ssh_config = client::Config { preferred: russh::Preferred { key: std::borrow::Cow::Borrowed(PREFERRED_HOST_KEY_ALGOS), + compression: std::borrow::Cow::Borrowed(compression_preferences( + config.compression, + )), ..russh::Preferred::DEFAULT }, - // Send a keepalive every 60 s. After 3 missed replies russh closes - // the connection, preventing the server from silently dropping idle - // sessions after hours of inactivity. - keepalive_interval: Some(Duration::from_secs(60)), - keepalive_max: 3, + // Send a keepalive on the user-configured interval. After the + // configured number of missed replies russh closes the connection, + // preventing the server from silently dropping idle sessions. + keepalive_interval, + keepalive_max: config.keepalive_max.unwrap_or(3) as usize, ..client::Config::default() }; // Connection timeout: 3 seconds let connection_timeout = Duration::from_secs(3); - let mut ssh_session = tokio::time::timeout( - connection_timeout, - client::connect(Arc::new(ssh_config), (&config.host[..], config.port), Client) - ).await + let mut ssh_session = if let Some(proxy) = &config.proxy { + // Tunnel through the proxy first, then hand the established stream + // to russh so the SSH handshake runs over the tunnel. + let stream = crate::proxy::connect_via_proxy( + proxy, + &config.host, + config.port, + connection_timeout, + ) + .await + .map_err(|e| anyhow::anyhow!("Proxy connection failed: {e}"))?; + tokio::time::timeout( + connection_timeout, + client::connect_stream(Arc::new(ssh_config), stream, Client), + ) + .await .map_err(|_| anyhow::anyhow!("Connection timed out after 3 seconds. Please check the host address and network connectivity."))? - .map_err(|e| anyhow::anyhow!("Failed to connect to {}:{}: {}", config.host, config.port, e))?; + .map_err(|e| anyhow::anyhow!("Failed to connect to {}:{}: {}", config.host, config.port, e))? + } else { + tokio::time::timeout( + connection_timeout, + client::connect(Arc::new(ssh_config), (&config.host[..], config.port), Client), + ) + .await + .map_err(|_| anyhow::anyhow!("Connection timed out after 3 seconds. Please check the host address and network connectivity."))? + .map_err(|e| anyhow::anyhow!("Failed to connect to {}:{}: {}", config.host, config.port, e))? + }; let authenticated = match &config.auth_method { AuthMethod::Password { password } => ssh_session diff --git a/src-tauri/src/ssh/tests.rs b/src-tauri/src/ssh/tests.rs index 9b824939..7ef5d611 100644 --- a/src-tauri/src/ssh/tests.rs +++ b/src-tauri/src/ssh/tests.rs @@ -18,6 +18,10 @@ mod tests { auth_method: AuthMethod::Password { password: TEST_PASSWORD.to_string(), }, + compression: true, + keepalive_interval: None, + keepalive_max: None, + proxy: None, } } @@ -95,6 +99,10 @@ mod tests { auth_method: AuthMethod::Password { password: "wrongpassword".to_string(), }, + compression: true, + keepalive_interval: None, + keepalive_max: None, + proxy: None, }; let result = client_write.connect(&config).await; @@ -250,6 +258,10 @@ mod key_loading_tests { key_path: "/nonexistent/path/id_rsa".to_string(), passphrase: None, }, + compression: true, + keepalive_interval: None, + keepalive_max: None, + proxy: None, }; let mut client = SshClient::new(); @@ -347,3 +359,49 @@ mod key_loading_tests { key_path.to_string() } } + +#[cfg(test)] +mod compression_pref_tests { + use crate::ssh::compression_preferences; + use russh::compression::{NONE, ZLIB, ZLIB_LEGACY}; + + /// Mirror russh's client-side negotiation: pick the first algorithm in our + /// preferred list that the server also advertises (see negotiation.rs). + fn negotiate<'a>( + our_list: &'a [russh::compression::Name], + server_list: &str, + ) -> Option<&'a str> { + for ours in our_list { + if server_list.split(',').any(|s| s == ours.as_ref()) { + return Some(ours.as_ref()); + } + } + None + } + + #[test] + fn enabled_prefers_zlib_over_none() { + let prefs = compression_preferences(true); + assert_eq!( + prefs[0], ZLIB, + "zlib must come before none or russh picks none" + ); + assert!(prefs.contains(&ZLIB_LEGACY)); + assert!(prefs.contains(&NONE)); + + // OpenSSH with `Compression delayed` advertises none,zlib@openssh.com. + assert_eq!( + negotiate(prefs, "none,zlib@openssh.com"), + Some("zlib@openssh.com") + ); + // OpenSSH with `Compression yes` advertises none,zlib. + assert_eq!(negotiate(prefs, "none,zlib"), Some("zlib")); + } + + #[test] + fn disabled_only_offers_none() { + let prefs = compression_preferences(false); + assert_eq!(prefs, &[NONE]); + assert_eq!(negotiate(prefs, "none,zlib@openssh.com"), Some("none")); + } +} diff --git a/src/App.tsx b/src/App.tsx index caf54c71..b161f3c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,8 +13,9 @@ import { SettingsModal } from './components/settings-modal'; import { IntegratedFileBrowser } from './components/integrated-file-browser'; import { WelcomeScreen } from './components/welcome-screen'; import { UpdateChecker } from './components/update-checker'; -import { ActiveConnectionsManager, ConnectionStorageManager } from './lib/connection-storage'; +import { ActiveConnectionsManager, ConnectionStorageManager, type ConnectionData } from './lib/connection-storage'; import { isDesktopProtocol } from './lib/protocol-config'; +import { buildSshConnectRequest } from './lib/ssh-connect-request'; import { registerRestoration, clearAllRestorations } from './lib/restoration-manager'; import { useLayout, LayoutProvider } from './lib/layout-context'; import { @@ -37,6 +38,39 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './componen import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs'; import { History, ShieldCheck, PlugZap, Activity, Loader2 } from 'lucide-react'; +/** + * Build a ConnectionConfig from stored ConnectionData, carrying over all + * optional fields (proxy, advanced SSH options, desktop protocol settings). + * Keeps editing dialog state in sync with what was persisted. + */ +function toConnectionConfig(connectionData: ConnectionData): ConnectionConfig { + return { + id: connectionData.id, + name: connectionData.name, + protocol: connectionData.protocol as ConnectionConfig['protocol'], + host: connectionData.host, + port: connectionData.port, + username: connectionData.username, + authMethod: connectionData.authMethod || 'password', + password: connectionData.password, + privateKeyPath: connectionData.privateKeyPath, + passphrase: connectionData.passphrase, + ftpsEnabled: connectionData.ftpsEnabled, + proxyType: connectionData.proxyType, + proxyHost: connectionData.proxyHost, + proxyPort: connectionData.proxyPort, + proxyUsername: connectionData.proxyUsername, + proxyPassword: connectionData.proxyPassword, + compression: connectionData.compression, + keepAlive: connectionData.keepAlive, + keepAliveInterval: connectionData.keepAliveInterval, + serverAliveCountMax: connectionData.serverAliveCountMax, + domain: connectionData.domain, + rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], + vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], + }; +} + interface ConnectionNode { id: string; name: string; @@ -387,16 +421,7 @@ function AppContent() { invoke<{ success: boolean; error?: string }>( 'ssh_connect', { - request: { - connection_id: activeConn.connectionId, - host: connectionData.host, - port: connectionData.port || 22, - username: connectionData.username, - auth_method: connectionData.authMethod || 'password', - password: connectionData.password || '', - key_path: connectionData.privateKeyPath || null, - passphrase: connectionData.passphrase || null, - } + request: buildSshConnectRequest(activeConn.connectionId, connectionData), } ), CONNECT_TIMEOUT_MS, @@ -506,22 +531,7 @@ function AppContent() { : !!connectionData.privateKeyPath); if (!hasCredentials) { - setEditingConnection({ - id: connection.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connection.id); setConnectionDialogOpen(true); return; @@ -607,16 +617,7 @@ function AppContent() { const result = await invoke<{ success: boolean; error?: string }>( 'ssh_connect', { - request: { - connection_id: sessionId, - host: connectionData.host, - port: connectionData.port || 22, - username: connectionData.username, - auth_method: connectionData.authMethod || 'password', - password: connectionData.password || '', - key_path: connectionData.privateKeyPath || null, - passphrase: connectionData.passphrase || null, - } + request: buildSshConnectRequest(sessionId, connectionData), } ); @@ -631,18 +632,7 @@ function AppContent() { toast.error(t('app.connectionFailed'), { description: result.error || 'Unable to connect to the server. Please check your credentials and try again.', }); - setEditingConnection({ - id: connection.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connection.id); setConnectionDialogOpen(true); } @@ -652,18 +642,7 @@ function AppContent() { toast.error(t('app.connectionError'), { description: error instanceof Error ? error.message : t('app.connectionErrorDesc'), }); - setEditingConnection({ - id: connection.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connection.id); setConnectionDialogOpen(true); } @@ -802,16 +781,7 @@ function AppContent() { const result = await invoke<{ success: boolean; error?: string }>( 'ssh_connect', { - request: { - connection_id: duplicateId, - host: connectionData.host, - port: connectionData.port || 22, - username: connectionData.username, - auth_method: connectionData.authMethod || 'password', - password: connectionData.password || '', - key_path: connectionData.privateKeyPath || null, - passphrase: connectionData.passphrase || null, - } + request: buildSshConnectRequest(duplicateId, connectionData), } ); @@ -873,22 +843,7 @@ function AppContent() { toast.error(t('app.cannotReconnect'), { description: t('app.noCredentialsDesc'), }); - setEditingConnection({ - id: originalConnectionId, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(originalConnectionId); setConnectionDialogOpen(true); return; @@ -955,16 +910,7 @@ function AppContent() { const result = await invoke<{ success: boolean; error?: string }>( 'ssh_connect', { - request: { - connection_id: tabId, - host: connectionData.host, - port: connectionData.port || 22, - username: connectionData.username, - auth_method: connectionData.authMethod || 'password', - password: connectionData.password || '', - key_path: connectionData.privateKeyPath || null, - passphrase: connectionData.passphrase || null, - } + request: buildSshConnectRequest(tabId, connectionData), } ); @@ -1296,21 +1242,7 @@ function AppContent() { if (connection.type === 'connection') { const connectionData = ConnectionStorageManager.getConnection(connection.id); if (connectionData) { - setEditingConnection({ - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setConnectionDialogOpen(true); setPendingConnectionId(null); } else { @@ -1450,16 +1382,7 @@ function AppContent() { // SSH / Telnet / Raw — connect then create/reuse tab try { const result = await invoke<{ success: boolean; error?: string }>('ssh_connect', { - request: { - connection_id: sessionId, - host: config.host, - port: config.port || 22, - username: config.username, - auth_method: config.authMethod || 'password', - password: config.password || '', - key_path: config.privateKeyPath || null, - passphrase: config.passphrase || null, - } + request: buildSshConnectRequest(sessionId, config), }); if (result.success) { @@ -1541,22 +1464,7 @@ function AppContent() { : !!connectionData.privateKeyPath); if (!hasCredentials) { - setEditingConnection({ - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - domain: connectionData.domain, - rdpResolution: connectionData.rdpResolution as ConnectionConfig['rdpResolution'], - vncColorDepth: connectionData.vncColorDepth as ConnectionConfig['vncColorDepth'], - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connectionData.id); setConnectionDialogOpen(true); return; @@ -1564,19 +1472,7 @@ function AppContent() { if (isFileBrowser) { // Route through handleConnectionDialogConnect which handles SFTP/FTP - const config: ConnectionConfig = { - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - ftpsEnabled: connectionData.ftpsEnabled, - }; + const config: ConnectionConfig = toConnectionConfig(connectionData); await handleConnectionDialogConnect(config); toast.success(t('app.quickConnected'), { description: t('app.quickConnectedDesc', { name: connectionData.name }), @@ -1587,34 +1483,14 @@ function AppContent() { const result = await invoke<{ success: boolean; error?: string }>( 'ssh_connect', { - request: { - connection_id: connectionData.id, - host: connectionData.host, - port: connectionData.port || 22, - username: connectionData.username, - auth_method: connectionData.authMethod || 'password', - password: connectionData.password || '', - key_path: connectionData.privateKeyPath || null, - passphrase: connectionData.passphrase || null, - } + request: buildSshConnectRequest(connectionData.id, connectionData), } ); if (result.success) { ConnectionStorageManager.updateLastConnected(connectionData.id); - const config: ConnectionConfig = { - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }; + const config: ConnectionConfig = toConnectionConfig(connectionData); handleConnectionDialogConnect(config); @@ -1626,18 +1502,7 @@ function AppContent() { toast.error(t('app.connectionFailed'), { description: result.error || 'Unable to connect. Please try again.', }); - setEditingConnection({ - id: connectionData.id, - name: connectionData.name, - protocol: connectionData.protocol as ConnectionConfig['protocol'], - host: connectionData.host, - port: connectionData.port, - username: connectionData.username, - authMethod: connectionData.authMethod || 'password', - password: connectionData.password, - privateKeyPath: connectionData.privateKeyPath, - passphrase: connectionData.passphrase, - }); + setEditingConnection(toConnectionConfig(connectionData)); setPendingConnectionId(connectionData.id); setConnectionDialogOpen(true); } diff --git a/src/__tests__/connection-dialog-advanced-save.test.tsx b/src/__tests__/connection-dialog-advanced-save.test.tsx new file mode 100644 index 00000000..2a85a972 --- /dev/null +++ b/src/__tests__/connection-dialog-advanced-save.test.tsx @@ -0,0 +1,141 @@ +/** + * Regression tests: advanced (SSH) and proxy tab edits must be persisted when + * the user clicks Save in the edit-connection dialog. Previously these fields + * were updated in local component state but never written to storage. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConnectionDialog, type ConnectionConfig } from '../components/connection-dialog'; +import { ConnectionStorageManager } from '../lib/connection-storage'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() }, +})); + +vi.mock('../lib/connection-storage', () => ({ + ConnectionStorageManager: { + getValidFolders: vi.fn(() => [{ path: 'All Connections' }]), + updateConnection: vi.fn(() => null), + }, +})); + +vi.mock('../lib/connection-profiles', () => ({ + ConnectionProfileManager: { + getProfiles: vi.fn(() => []), + }, +})); + +const baseConnection: ConnectionConfig = { + id: 'conn-1', + name: 'My Server', + host: 'example.com', + port: 22, + username: 'root', + protocol: 'SSH', + authMethod: 'password', +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ConnectionDialog advanced tab save', () => { + it('persists compression and keepAliveInterval edits on save', async () => { + render( + , + ); + + // Switch to the Advanced tab (Radix Tabs activates on mouseDown) + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Advanced' }), { button: 0 }); + + // compression switch renders first, defaults to ON → click to turn OFF + const switches = await screen.findAllByRole('switch'); + expect(switches.length).toBeGreaterThanOrEqual(2); + fireEvent.click(switches[0]); // compression → false + + // keepAlive interval input (visible because keepAlive defaults to ON) + const intervalInput = screen.getByLabelText('Interval (seconds)'); + fireEvent.change(intervalInput, { target: { value: '30' } }); + + // Save + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(ConnectionStorageManager.updateConnection).toHaveBeenCalledWith( + 'conn-1', + expect.objectContaining({ + compression: false, + keepAlive: true, + keepAliveInterval: 30, + serverAliveCountMax: 3, + }), + ); + }); + + it('shows default advanced values for legacy connections missing them', async () => { + // A connection saved before advanced/proxy fields were persisted has no + // such values — the edit dialog should fall back to the new-connection + // defaults instead of showing blank controls. + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Advanced' }), { button: 0 }); + + const switches = await screen.findAllByRole('switch'); + // compression (index 0) and keepAlive (index 1) both default to ON + expect(switches[0].getAttribute('data-state')).toBe('checked'); + expect(switches[1].getAttribute('data-state')).toBe('checked'); + + // numeric inputs default to 60 / 3 + expect((screen.getByLabelText('Interval (seconds)') as HTMLInputElement).value).toBe('60'); + expect((screen.getByLabelText('Max Count') as HTMLInputElement).value).toBe('3'); + }); + + it('persists existing proxy config unchanged when saving', async () => { + const editingWithProxy: ConnectionConfig = { + ...baseConnection, + id: 'conn-2', + proxyType: 'socks5', + proxyHost: 'proxy.example.com', + proxyPort: 1080, + proxyUsername: 'user', + proxyPassword: 'pass', + }; + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(ConnectionStorageManager.updateConnection).toHaveBeenCalledWith( + 'conn-2', + expect.objectContaining({ + proxyType: 'socks5', + proxyHost: 'proxy.example.com', + proxyPort: 1080, + proxyUsername: 'user', + proxyPassword: 'pass', + }), + ); + }); +}); diff --git a/src/__tests__/connection-dialog-edit-roundtrip.test.tsx b/src/__tests__/connection-dialog-edit-roundtrip.test.tsx new file mode 100644 index 00000000..ee6a8d8c --- /dev/null +++ b/src/__tests__/connection-dialog-edit-roundtrip.test.tsx @@ -0,0 +1,143 @@ +/** + * Regression tests: editing a saved SSH connection and toggling advanced + * options (compression, keepalive) must survive a save → re-open round-trip. + * + * Uses the real ConnectionStorageManager + localStorage, so it exercises the + * same storage path as the desktop app. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConnectionDialog, type ConnectionConfig } from '../components/connection-dialog'; +import { ConnectionStorageManager, type ConnectionData } from '../lib/connection-storage'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() }, +})); + +vi.mock('../lib/connection-profiles', () => ({ + ConnectionProfileManager: { + getProfiles: vi.fn(() => []), + }, +})); + +/** Mirror of App.tsx's toConnectionConfig: stored ConnectionData → dialog config. */ +function toConfig(data: ConnectionData): ConnectionConfig { + return { + id: data.id, + name: data.name, + protocol: data.protocol as ConnectionConfig['protocol'], + host: data.host, + port: data.port, + username: data.username, + authMethod: data.authMethod || 'password', + password: data.password, + privateKeyPath: data.privateKeyPath, + passphrase: data.passphrase, + proxyType: data.proxyType, + proxyHost: data.proxyHost, + proxyPort: data.proxyPort, + proxyUsername: data.proxyUsername, + proxyPassword: data.proxyPassword, + compression: data.compression, + keepAlive: data.keepAlive, + keepAliveInterval: data.keepAliveInterval, + serverAliveCountMax: data.serverAliveCountMax, + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +async function switchToAdvancedTab() { + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Advanced' }), { button: 0 }); +} + +describe('edit advanced options round-trip (real storage)', () => { + it('compression OFF survives save → re-open as OFF', async () => { + ConnectionStorageManager.saveConnectionWithId('c1', { + name: 'My Server', + host: 'example.com', + port: 22, + username: 'root', + protocol: 'SSH', + authMethod: 'password', + compression: true, + keepAlive: true, + keepAliveInterval: 60, + serverAliveCountMax: 3, + }); + + const editing = toConfig(ConnectionStorageManager.getConnection('c1')!); + + // First edit session: turn compression OFF and save. + const { unmount } = render( + , + ); + await switchToAdvancedTab(); + const switches = await screen.findAllByRole('switch'); + expect(switches[0].getAttribute('data-state')).toBe('checked'); // compression starts ON + fireEvent.click(switches[0]); // compression → OFF + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + unmount(); + + // The stored value must now be false. + const stored = ConnectionStorageManager.getConnection('c1')!; + expect(stored.compression).toBe(false); + + // Second edit session: re-open from storage and assert the switch is OFF. + render( + , + ); + await switchToAdvancedTab(); + const switches2 = await screen.findAllByRole('switch'); + expect(switches2[0].getAttribute('data-state')).toBe('unchecked'); + }); + + it('keepalive interval edit survives save → re-open', async () => { + ConnectionStorageManager.saveConnectionWithId('c1', { + name: 'My Server', + host: 'example.com', + port: 22, + username: 'root', + protocol: 'SSH', + authMethod: 'password', + compression: true, + keepAlive: true, + keepAliveInterval: 60, + serverAliveCountMax: 3, + }); + + const editing = toConfig(ConnectionStorageManager.getConnection('c1')!); + + const { unmount } = render( + , + ); + await switchToAdvancedTab(); + const intervalInput = screen.getByLabelText('Interval (seconds)'); + fireEvent.change(intervalInput, { target: { value: '30' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + unmount(); + + expect(ConnectionStorageManager.getConnection('c1')?.keepAliveInterval).toBe(30); + }); +}); diff --git a/src/__tests__/connection-storage-advanced-fields.test.ts b/src/__tests__/connection-storage-advanced-fields.test.ts new file mode 100644 index 00000000..6ced8648 --- /dev/null +++ b/src/__tests__/connection-storage-advanced-fields.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for advanced (SSH) and proxy field persistence in connection storage. + * Verifies that fields edited in the dialog's Advanced / Proxy tabs survive a + * save → reload round trip (regression: they were previously dropped). + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ConnectionStorageManager } from '../lib/connection-storage'; + +beforeEach(() => { + localStorage.clear(); + ConnectionStorageManager.initialize(); +}); + +describe('connection-storage advanced & proxy field persistence', () => { + it('round-trips proxy fields through saveConnectionWithId', () => { + ConnectionStorageManager.saveConnectionWithId('conn-1', { + name: 'My Server', + host: 'example.com', + port: 22, + username: 'root', + protocol: 'SSH', + authMethod: 'password', + proxyType: 'socks5', + proxyHost: 'proxy.example.com', + proxyPort: 1080, + proxyUsername: 'user', + proxyPassword: 'pass', + }); + + const loaded = ConnectionStorageManager.getConnection('conn-1'); + expect(loaded?.proxyType).toBe('socks5'); + expect(loaded?.proxyHost).toBe('proxy.example.com'); + expect(loaded?.proxyPort).toBe(1080); + expect(loaded?.proxyUsername).toBe('user'); + expect(loaded?.proxyPassword).toBe('pass'); + }); + + it('round-trips advanced SSH fields through saveConnectionWithId', () => { + ConnectionStorageManager.saveConnectionWithId('conn-2', { + name: 'My Server', + host: 'example.com', + port: 22, + username: 'root', + protocol: 'SSH', + authMethod: 'password', + compression: false, + keepAlive: false, + keepAliveInterval: 45, + serverAliveCountMax: 5, + }); + + const loaded = ConnectionStorageManager.getConnection('conn-2'); + expect(loaded?.compression).toBe(false); + expect(loaded?.keepAlive).toBe(false); + expect(loaded?.keepAliveInterval).toBe(45); + expect(loaded?.serverAliveCountMax).toBe(5); + }); + + it('updateConnection preserves advanced & proxy fields', () => { + ConnectionStorageManager.saveConnectionWithId('conn-3', { + name: 'My Server', + host: 'example.com', + port: 22, + username: 'root', + protocol: 'SSH', + authMethod: 'password', + }); + + ConnectionStorageManager.updateConnection('conn-3', { + compression: true, + keepAlive: false, + keepAliveInterval: 30, + serverAliveCountMax: 4, + proxyType: 'http', + proxyHost: 'proxy.example.com', + proxyPort: 3128, + proxyUsername: 'user', + proxyPassword: 'pass', + }); + + const loaded = ConnectionStorageManager.getConnection('conn-3'); + expect(loaded?.compression).toBe(true); + expect(loaded?.keepAlive).toBe(false); + expect(loaded?.keepAliveInterval).toBe(30); + expect(loaded?.serverAliveCountMax).toBe(4); + expect(loaded?.proxyType).toBe('http'); + expect(loaded?.proxyHost).toBe('proxy.example.com'); + expect(loaded?.proxyPort).toBe(3128); + expect(loaded?.proxyUsername).toBe('user'); + expect(loaded?.proxyPassword).toBe('pass'); + }); +}); diff --git a/src/__tests__/ssh-connect-request.test.ts b/src/__tests__/ssh-connect-request.test.ts new file mode 100644 index 00000000..f805de8d --- /dev/null +++ b/src/__tests__/ssh-connect-request.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { buildSshConnectRequest, type SshConnectRequestSource } from '../lib/ssh-connect-request'; + +const baseSource: SshConnectRequestSource = { + host: 'example.com', + port: 22, + username: 'alice', + authMethod: 'password', + password: 'secret', +}; + +describe('buildSshConnectRequest', () => { + it('sends basic credentials with connection defaults', () => { + const req = buildSshConnectRequest('conn-1', baseSource); + + expect(req.connection_id).toBe('conn-1'); + expect(req.host).toBe('example.com'); + expect(req.port).toBe(22); + expect(req.username).toBe('alice'); + expect(req.auth_method).toBe('password'); + expect(req.password).toBe('secret'); + expect(req.key_path).toBeNull(); + expect(req.passphrase).toBeNull(); + }); + + it('applies default advanced settings matching the UI (compression + keepalive 60/3, no proxy)', () => { + const req = buildSshConnectRequest('conn-1', baseSource); + + expect(req.compression).toBe(true); + expect(req.keepalive_enabled).toBe(true); + expect(req.keepalive_interval).toBe(60); + expect(req.keepalive_max).toBe(3); + expect(req.proxy_type).toBe('none'); + expect(req.proxy_host).toBeNull(); + expect(req.proxy_port).toBeNull(); + expect(req.proxy_username).toBeNull(); + expect(req.proxy_password).toBeNull(); + }); + + it('forwards custom advanced settings', () => { + const req = buildSshConnectRequest('conn-1', { + ...baseSource, + compression: false, + keepAlive: true, + keepAliveInterval: 30, + serverAliveCountMax: 5, + }); + + expect(req.compression).toBe(false); + expect(req.keepalive_enabled).toBe(true); + expect(req.keepalive_interval).toBe(30); + expect(req.keepalive_max).toBe(5); + }); + + it('disables keepalive when the keepAlive toggle is off', () => { + const req = buildSshConnectRequest('conn-1', { + ...baseSource, + keepAlive: false, + }); + + expect(req.keepalive_enabled).toBe(false); + expect(req.keepalive_interval).toBeNull(); + expect(req.keepalive_max).toBeNull(); + }); + + it('sends the proxy fields when a proxy type is selected', () => { + const req = buildSshConnectRequest('conn-1', { + ...baseSource, + proxyType: 'socks5', + proxyHost: 'proxy.local', + proxyPort: 1080, + proxyUsername: 'proxy-user', + proxyPassword: 'proxy-pass', + }); + + expect(req.proxy_type).toBe('socks5'); + expect(req.proxy_host).toBe('proxy.local'); + expect(req.proxy_port).toBe(1080); + expect(req.proxy_username).toBe('proxy-user'); + expect(req.proxy_password).toBe('proxy-pass'); + }); + + it('sends null proxy fields when no proxy type is selected', () => { + const req = buildSshConnectRequest('conn-1', { + ...baseSource, + proxyType: 'none', + proxyHost: 'proxy.local', + proxyPort: 1080, + }); + + expect(req.proxy_type).toBe('none'); + expect(req.proxy_host).toBeNull(); + expect(req.proxy_port).toBeNull(); + }); + + it('defaults keepalive numbers when only the toggle is set', () => { + const req = buildSshConnectRequest('conn-1', { + ...baseSource, + keepAlive: true, + }); + + expect(req.keepalive_enabled).toBe(true); + expect(req.keepalive_interval).toBe(60); + expect(req.keepalive_max).toBe(3); + }); + + it('falls back to port 22 and password auth for partial sources', () => { + const req = buildSshConnectRequest('conn-1', { + host: 'example.com', + port: 0, + username: '', + }); + + expect(req.port).toBe(22); + expect(req.auth_method).toBe('password'); + expect(req.username).toBe(''); + }); +}); diff --git a/src/components/connection-dialog.tsx b/src/components/connection-dialog.tsx index 8feaa556..212c1048 100644 --- a/src/components/connection-dialog.tsx +++ b/src/components/connection-dialog.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/ import { Separator } from './ui/separator'; import { ConnectionProfileManager, type ConnectionProfile } from '../lib/connection-profiles'; import { ConnectionStorageManager } from '../lib/connection-storage'; +import { buildSshConnectRequest } from '../lib/ssh-connect-request'; import { toast } from 'sonner'; import { Server, @@ -29,7 +30,7 @@ interface ConnectionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onConnect: (config: ConnectionConfig) => void; - onSave?: (config: ConnectionConfig) => void; + onSave?: (config: ConnectionConfig) => void | Promise; editingConnection?: ConnectionConfig | null; initialFolder?: string; } @@ -70,6 +71,23 @@ export interface ConnectionConfig { vncColorDepth?: '24' | '16' | '8'; } +/** + * Merge form overrides on top of defaults, falling back to the default when a + * field is `undefined`. Historical connections saved before advanced/proxy + * fields were persisted have no such values — without this fallback their edit + * dialog would show blank controls instead of the defaults used for new ones. + */ +function mergeWithDefaults(defaults: ConnectionConfig, overrides: ConnectionConfig): ConnectionConfig { + const merged: ConnectionConfig = { ...defaults, ...overrides }; + const mergedRecord = merged as unknown as Record; + for (const key of Object.keys(defaults) as Array) { + if (mergedRecord[key] === undefined) { + mergedRecord[key] = defaults[key]; + } + } + return merged; +} + export function ConnectionDialog({ open, onOpenChange, @@ -163,12 +181,12 @@ export function ConnectionDialog({ setConnectionFolder(initialFolder); } - // Load editing connection data into config when dialog opens + // Load editing connection data into config when dialog opens. + // mergeWithDefaults falls back to defaultConfig for fields a historical + // connection never stored (advanced/proxy options), matching the + // pre-filled values a new connection gets. if (editingConnection) { - setConfig({ - ...defaultConfig, - ...editingConnection - }); + setConfig(mergeWithDefaults(defaultConfig, editingConnection)); syncDisplayValues({ port: editingConnection.port ?? 22, proxyPort: editingConnection.proxyPort ?? 8080, @@ -305,6 +323,15 @@ export function ConnectionDialog({ privateKeyPath: config.privateKeyPath, passphrase: config.passphrase, ftpsEnabled: config.ftpsEnabled, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + compression: config.compression, + keepAlive: config.keepAlive, + keepAliveInterval: config.keepAliveInterval, + serverAliveCountMax: config.serverAliveCountMax, domain: config.domain, rdpResolution: config.rdpResolution, vncColorDepth: config.vncColorDepth, @@ -323,6 +350,15 @@ export function ConnectionDialog({ privateKeyPath: config.privateKeyPath, passphrase: config.passphrase, ftpsEnabled: config.ftpsEnabled, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + compression: config.compression, + keepAlive: config.keepAlive, + keepAliveInterval: config.keepAliveInterval, + serverAliveCountMax: config.serverAliveCountMax, domain: config.domain, rdpResolution: config.rdpResolution, vncColorDepth: config.vncColorDepth, @@ -345,7 +381,6 @@ export function ConnectionDialog({ // SSH / Telnet / Raw / Serial — connect via ssh_connect // Save connection config FIRST (consistent with SFTP/FTP/Desktop), // so the config is preserved even if the remote server is temporarily unreachable. - let connectionSaved = false; if (editingConnection?.id) { ConnectionStorageManager.updateConnection(editingConnection.id, { name: config.name, @@ -357,9 +392,17 @@ export function ConnectionDialog({ password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + compression: config.compression, + keepAlive: config.keepAlive, + keepAliveInterval: config.keepAliveInterval, + serverAliveCountMax: config.serverAliveCountMax, lastConnected: new Date().toISOString(), }); - connectionSaved = true; } else if (saveAsConnection) { ConnectionStorageManager.saveConnectionWithId(connectionId, { name: config.name, @@ -372,24 +415,23 @@ export function ConnectionDialog({ password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + compression: config.compression, + keepAlive: config.keepAlive, + keepAliveInterval: config.keepAliveInterval, + serverAliveCountMax: config.serverAliveCountMax, }); - connectionSaved = true; } try { const result = await invoke<{ success: boolean; error?: string }>( 'ssh_connect', { - request: { - connection_id: connectionId, - host: config.host, - port: config.port || 22, - username: config.username, - auth_method: config.authMethod || 'password', - password: config.password || '', - key_path: config.privateKeyPath || null, - passphrase: config.passphrase || null, - } + request: buildSshConnectRequest(connectionId, config), } ); @@ -424,7 +466,7 @@ export function ConnectionDialog({ }); } } finally { - // Close dialog — config is already saved if connectionSaved is true + // Close dialog — config was already saved above onOpenChange(false); if (!editingConnection) { setConfig(defaultConfig); @@ -486,6 +528,15 @@ const handleCancelConnectionAttempt = async () => { privateKeyPath: config.privateKeyPath, passphrase: config.passphrase, ftpsEnabled: config.ftpsEnabled, + proxyType: config.proxyType, + proxyHost: config.proxyHost, + proxyPort: config.proxyPort, + proxyUsername: config.proxyUsername, + proxyPassword: config.proxyPassword, + compression: config.compression, + keepAlive: config.keepAlive, + keepAliveInterval: config.keepAliveInterval, + serverAliveCountMax: config.serverAliveCountMax, domain: config.domain, rdpResolution: config.rdpResolution, vncColorDepth: config.vncColorDepth, diff --git a/src/lib/connection-storage.ts b/src/lib/connection-storage.ts index ca105178..7bf707fc 100644 --- a/src/lib/connection-storage.ts +++ b/src/lib/connection-storage.ts @@ -25,6 +25,17 @@ export interface ConnectionData { passphrase?: string; // FTP-specific ftpsEnabled?: boolean; + // Proxy + proxyType?: 'none' | 'http' | 'socks4' | 'socks5'; + proxyHost?: string; + proxyPort?: number; + proxyUsername?: string; + proxyPassword?: string; + // SSH-specific advanced + compression?: boolean; + keepAlive?: boolean; + keepAliveInterval?: number; + serverAliveCountMax?: number; // RDP-specific domain?: string; rdpResolution?: string; diff --git a/src/lib/ssh-connect-request.ts b/src/lib/ssh-connect-request.ts new file mode 100644 index 00000000..b55a277f --- /dev/null +++ b/src/lib/ssh-connect-request.ts @@ -0,0 +1,85 @@ +/** + * Builds the `ssh_connect` invoke request payload. + * + * The connection dialog stores advanced SSH options (compression, keepalive) + * and proxy options on ConnectionConfig / ConnectionData, but the backend only + * applies them if they are actually sent across IPC. This helper centralises + * the mapping so every connect path (dialog, quick connect, restore, duplicate, + * reconnect) carries the same fields. + */ + +/** Subset of ConnectionConfig / ConnectionData that the SSH connect request needs. */ +export interface SshConnectRequestSource { + host: string; + port: number; + username: string; + authMethod?: string; + password?: string; + privateKeyPath?: string; + passphrase?: string; + compression?: boolean; + keepAlive?: boolean; + keepAliveInterval?: number; + serverAliveCountMax?: number; + proxyType?: string; + proxyHost?: string; + proxyPort?: number; + proxyUsername?: string; + proxyPassword?: string; +} + +export interface SshConnectRequest { + connection_id: string; + host: string; + port: number; + username: string; + auth_method: string; + password: string | null; + key_path: string | null; + passphrase: string | null; + compression: boolean; + keepalive_enabled: boolean; + keepalive_interval: number | null; + keepalive_max: number | null; + proxy_type: string; + proxy_host: string | null; + proxy_port: number | null; + proxy_username: string | null; + proxy_password: string | null; +} + +/** + * Build the `ssh_connect` request payload from a connection config. + * + * Defaults mirror the connection dialog UI: compression and keepalive enabled + * (60 s interval, 3 max), no proxy. When keepalive or proxy is disabled the + * corresponding fields are sent as null so the backend disables them. + */ +export function buildSshConnectRequest( + connectionId: string, + source: SshConnectRequestSource, +): SshConnectRequest { + const keepAlive = source.keepAlive !== false; + const proxyType = source.proxyType ?? 'none'; + const proxyEnabled = proxyType !== 'none'; + + return { + connection_id: connectionId, + host: source.host, + port: source.port || 22, + username: source.username, + auth_method: source.authMethod || 'password', + password: source.password || null, + key_path: source.privateKeyPath || null, + passphrase: source.passphrase || null, + compression: source.compression !== false, + keepalive_enabled: keepAlive, + keepalive_interval: keepAlive ? (source.keepAliveInterval ?? 60) : null, + keepalive_max: keepAlive ? (source.serverAliveCountMax ?? 3) : null, + proxy_type: proxyType, + proxy_host: proxyEnabled ? (source.proxyHost || null) : null, + proxy_port: proxyEnabled ? (source.proxyPort ?? null) : null, + proxy_username: proxyEnabled ? (source.proxyUsername || null) : null, + proxy_password: proxyEnabled ? (source.proxyPassword || null) : null, + }; +}