From 9d815ac8c4020881cf05b7dad9f77acbd58d65a1 Mon Sep 17 00:00:00 2001 From: sunxiaobin89 <285584806@qq.com> Date: Fri, 31 Jul 2026 15:13:17 +0800 Subject: [PATCH 1/2] fix(connections): persist advanced SSH and proxy options across save/edit - Add proxy and advanced SSH fields to ConnectionData so they survive storage round trips - Include these fields in every save path in ConnectionDialog (edit save, new connect, SSH/SFTP/FTP/Desktop branches) - Add toConnectionConfig helper in App.tsx to carry the fields when opening the edit dialog, replacing 9 duplicated ConnectionConfig builds - Merge undefined fields back to defaults when editing legacy connections saved before these fields were persisted, so the Advanced/Proxy tabs show the same pre-filled values as new connections Test: 516 tests pass, 6 new tests cover persistence and default fallback --- src/App.tsx | 168 +++++------------- .../connection-dialog-advanced-save.test.tsx | 141 +++++++++++++++ ...connection-storage-advanced-fields.test.ts | 92 ++++++++++ src/components/connection-dialog.tsx | 72 +++++++- src/lib/connection-storage.ts | 11 ++ 5 files changed, 354 insertions(+), 130 deletions(-) create mode 100644 src/__tests__/connection-dialog-advanced-save.test.tsx create mode 100644 src/__tests__/connection-storage-advanced-fields.test.ts diff --git a/src/App.tsx b/src/App.tsx index caf54c71..9252b27a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ 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 { registerRestoration, clearAllRestorations } from './lib/restoration-manager'; import { useLayout, LayoutProvider } from './lib/layout-context'; @@ -37,6 +37,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; @@ -506,22 +539,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; @@ -631,18 +649,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 +659,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); } @@ -873,22 +869,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; @@ -1296,21 +1277,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 { @@ -1541,22 +1508,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 +1516,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 }), @@ -1603,18 +1543,7 @@ function AppContent() { 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 +1555,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-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/components/connection-dialog.tsx b/src/components/connection-dialog.tsx index 8feaa556..ab3919d0 100644 --- a/src/components/connection-dialog.tsx +++ b/src/components/connection-dialog.tsx @@ -70,6 +70,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 +180,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 +322,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 +349,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, @@ -357,6 +392,15 @@ 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; @@ -372,6 +416,15 @@ 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; } @@ -486,6 +539,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; From c87d361e6630cead078e67ea07a3e714153311dc Mon Sep 17 00:00:00 2001 From: sunxiaobin89 <285584806@qq.com> Date: Fri, 31 Jul 2026 18:05:16 +0800 Subject: [PATCH 2/2] fix(connections): apply advanced SSH and proxy settings to connections The Advanced (compression, keepalive) and Proxy tabs in the connection dialog were persisted but never reached the backend, so they had no effect on real SSH connections. - Add keepalive interval/max, zlib compression negotiation, and HTTP/SOCKS4/SOCKS5 proxy tunneling to the SSH backend (new proxy.rs, SshConfig/ConnectRequest fields, SshClient::connect) - Wire the stored advanced/proxy values through every ssh_connect call via a shared buildSshConnectRequest helper, replacing 7 duplicated request payloads - Fix compression negotiation: advertise zlib before none so russh actually negotiates zlib instead of always falling back to none - Add proxy handshake tests (HTTP CONNECT, SOCKS4, SOCKS5 incl. auth and error paths), compression preference tests, and edit round-trip regression tests Test: 526 frontend tests pass (10 new), 122 Rust tests pass (16 new) --- src-tauri/src/commands.rs | 146 +++- src-tauri/src/lib.rs | 1 + src-tauri/src/proxy.rs | 641 ++++++++++++++++++ src-tauri/src/ssh/mod.rs | 73 +- src-tauri/src/ssh/tests.rs | 58 ++ src/App.tsx | 67 +- .../connection-dialog-edit-roundtrip.test.tsx | 143 ++++ src/__tests__/ssh-connect-request.test.ts | 118 ++++ src/components/connection-dialog.tsx | 19 +- src/lib/ssh-connect-request.ts | 85 +++ 10 files changed, 1265 insertions(+), 86 deletions(-) create mode 100644 src-tauri/src/proxy.rs create mode 100644 src/__tests__/connection-dialog-edit-roundtrip.test.tsx create mode 100644 src/__tests__/ssh-connect-request.test.ts create mode 100644 src/lib/ssh-connect-request.ts 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 9252b27a..b161f3c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { WelcomeScreen } from './components/welcome-screen'; import { UpdateChecker } from './components/update-checker'; 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 { @@ -420,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, @@ -625,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), } ); @@ -798,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), } ); @@ -936,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), } ); @@ -1417,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) { @@ -1527,16 +1483,7 @@ 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), } ); 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__/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 ab3919d0..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; } @@ -380,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, @@ -403,7 +403,6 @@ export function ConnectionDialog({ serverAliveCountMax: config.serverAliveCountMax, lastConnected: new Date().toISOString(), }); - connectionSaved = true; } else if (saveAsConnection) { ConnectionStorageManager.saveConnectionWithId(connectionId, { name: config.name, @@ -426,23 +425,13 @@ export function ConnectionDialog({ 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), } ); @@ -477,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); 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, + }; +}