diff --git a/src/main/ipc/ssh/ssh-host-server-connect.ts b/src/main/ipc/ssh/ssh-host-server-connect.ts index 439ba301920..14b585e9f6b 100644 --- a/src/main/ipc/ssh/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh/ssh-host-server-connect.ts @@ -16,6 +16,8 @@ import { import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { broadcastSshState, getPublicSshState } from './ssh-renderer-broadcast' import { isAuthError } from '../../ssh/ssh-reconnect-error-classification' +import { isSshSessionStartFailure } from '../../ssh/ssh-session-start-failure' +import { describeSshConnectFailure } from '../../ssh/ssh-connect-failure-next-step' export async function decideHostServer(target: SshTarget): Promise { // Load managed-server deploy/migration/tunnel dependencies only when connecting. @@ -59,7 +61,7 @@ export function publishHostServerDecisionFailure(targetId: string, error: unknow broadcastSshState(getCurrentMainWindow, targetId, { targetId, status: isAuthError(failure) ? 'auth-failed' : 'error', - error: failure.message, + error: isSshSessionStartFailure(failure) ? describeSshConnectFailure(failure) : failure.message, reconnectAttempt: 0 }) } diff --git a/src/main/ipc/ssh/ssh-host-server-decision-failure.test.ts b/src/main/ipc/ssh/ssh-host-server-decision-failure.test.ts index 61ff873feb7..87902958784 100644 --- a/src/main/ipc/ssh/ssh-host-server-decision-failure.test.ts +++ b/src/main/ipc/ssh/ssh-host-server-decision-failure.test.ts @@ -27,6 +27,7 @@ const { decideHostServer, publishHostServerDecisionFailure } = const { broadcastSshState } = await import('./ssh-renderer-broadcast') const { getSshHostServerStatus, setSshHostServerStatus } = await import('../../ssh/ssh-host-server-status') +const { markSshSessionStartFailure } = await import('../../ssh/ssh-session-start-failure') const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } const fenced: SshTarget = { @@ -53,4 +54,14 @@ describe('a managed-server decision that fails', () => { error: 'listen EADDRINUSE' }) }) + + it('leads a failed SSH session with what to do about it', () => { + publishHostServerDecisionFailure( + 'ssh-1', + markSshSessionStartFailure(new Error('connect ECONNREFUSED 10.0.0.1:22')) + ) + expect(vi.mocked(broadcastSshState).mock.calls.at(-1)?.[2].error).toMatch( + /^Nothing accepted the SSH connection at this address\..*\(connect ECONNREFUSED 10\.0\.0\.1:22\)$/ + ) + }) }) diff --git a/src/main/ssh/ssh-connect-failure-next-step.test.ts b/src/main/ssh/ssh-connect-failure-next-step.test.ts new file mode 100644 index 00000000000..257fa699b32 --- /dev/null +++ b/src/main/ssh/ssh-connect-failure-next-step.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { describeSshConnectFailure } from './ssh-connect-failure-next-step' +import { HostKeyVerificationError } from './ssh-host-key-decision' + +const withCode = (message: string, code: string): Error => + Object.assign(new Error(message), { code }) + +describe('describeSshConnectFailure', () => { + it.each([ + [ + withCode('connect ECONNREFUSED 10.0.0.1:22', 'ECONNREFUSED'), + /^Nothing accepted the SSH connection/ + ], + [ + new Error('All configured authentication methods failed'), + /^The host didn’t accept any sign-in/ + ], + [ + new Error('Timed out while waiting for SSH authentication'), + /^The connection didn’t finish in time/ + ], + [ + withCode('getaddrinfo ENOTFOUND box.invalid', 'ENOTFOUND'), + /^The host name couldn’t be looked up/ + ], + [withCode('connect EHOSTUNREACH 10.0.0.9:22', 'EHOSTUNREACH'), /^There’s no network route/], + [ + new Error('System SSH probe failed (exit 255). stderr: Host key verification failed.'), + /^SSH couldn’t confirm this host’s identity/ + ] + ])('leads %s with what to do, keeping SSH’s words', (error, nextStep) => { + const text = describeSshConnectFailure(error) + expect(text).toMatch(nextStep) + expect(text).toContain(`(${error.message})`) + }) + + it('names a key that would not unlock, not the host, for a passphrase failure', () => { + expect(describeSshConnectFailure(new Error('Cannot parse privateKey: bad decrypt'))).toMatch( + /^Orca couldn’t unlock the SSH key/ + ) + }) + + it('leaves a cancelled sign-in and Orca’s own host-key refusals as they are', () => { + const cancelled = Object.assign( + new Error('Keyboard-interactive authentication cancelled for Box'), + { level: 'client-authentication' } + ) + expect(describeSshConnectFailure(cancelled)).toBe(cancelled.message) + const refused = new HostKeyVerificationError( + 'Host key verification failed for box. Remove its entry from the Orca host key store.', + 'mismatch' + ) + expect(describeSshConnectFailure(refused)).toBe(refused.message) + }) + + it('leaves a failure it cannot name as SSH said it', () => { + expect(describeSshConnectFailure(new Error('Connection lost before handshake'))).toBe( + 'Connection lost before handshake' + ) + }) +}) diff --git a/src/main/ssh/ssh-connect-failure-next-step.ts b/src/main/ssh/ssh-connect-failure-next-step.ts new file mode 100644 index 00000000000..4aa6e103f15 --- /dev/null +++ b/src/main/ssh/ssh-connect-failure-next-step.ts @@ -0,0 +1,66 @@ +import { isHostKeyVerificationError } from './ssh-host-key-decision' +import { isAuthError, isPassphraseError } from './ssh-reconnect-error-classification' + +type FailureKind = + | 'host-key' + | 'auth' + | 'passphrase' + | 'refused' + | 'host-name' + | 'unreachable' + | 'timeout' + +const NEXT_STEPS: Record = { + 'host-key': + 'SSH couldn’t confirm this host’s identity. Run ssh to this host in a terminal to see why and trust or update its key, then connect again.', + auth: 'The host didn’t accept any sign-in Orca offered. Check the user name and the key, ssh-agent or password for this host, then connect again.', + passphrase: 'Orca couldn’t unlock the SSH key. Check its passphrase, then connect again.', + refused: + 'Nothing accepted the SSH connection at this address. Check the host name and port, and that sshd is running on the host.', + 'host-name': 'The host name couldn’t be looked up. Check the name and your network connection.', + unreachable: + 'There’s no network route to the host. Check your network or VPN, then connect again.', + timeout: + 'The connection didn’t finish in time. Check that the host is reachable from this computer (network, VPN, firewall), then connect again.' +} + +// SSH's own wording, raw or through system ssh, names the failure; match it, not exact codes alone. +function classify(error: Error): FailureKind | null { + // Orca's own host-key refusals already name their exact fix. + if (isHostKeyVerificationError(error) || /cancel/i.test(error.message)) { + return null + } + if (/host key verification failed/i.test(error.message)) { + return 'host-key' + } + if (isPassphraseError(error)) { + return 'passphrase' + } + if (isAuthError(error)) { + return 'auth' + } + const text = `${'code' in error ? String(error.code) : ''} ${error.message}`.toLowerCase() + if (/econnrefused|connection refused/.test(text)) { + return 'refused' + } + if ( + /enotfound|eai_again|could not resolve hostname|name or service not known|nodename nor servname/.test( + text + ) + ) { + return 'host-name' + } + if (/ehostunreach|enetunreach|no route to host|network is unreachable/.test(text)) { + return 'unreachable' + } + if (/etimedout|timed out/.test(text)) { + return 'timeout' + } + return null +} + +/** SSH's reason, led by what to do about it when the failure is one users can act on. */ +export function describeSshConnectFailure(error: Error): string { + const kind = classify(error) + return kind ? `${NEXT_STEPS[kind]} (${error.message})` : error.message +} diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.ts index 7d9f88c86ba..39e8e3d7d3c 100644 --- a/src/renderer/src/ssh/ssh-host-server-status-copy.ts +++ b/src/renderer/src/ssh/ssh-host-server-status-copy.ts @@ -6,7 +6,7 @@ import type { } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { compactIpcErrorMessage, extractIpcErrorMessage } from '@/lib/ipc-error' import { launchSessionEndedLine } from './ssh-host-launch-session-copy' import { bridgedTerminalsLine } from './ssh-host-bridged-terminals-copy' import { managedUpdateLine, withDetail } from './ssh-host-server-update-copy' @@ -91,10 +91,13 @@ export function sshTargetConnectFailureText( error: unknown, fallback: string ): string { - return sshConnectFailureText( - useAppStore.getState().sshConnectionStates.get(targetId), - extractIpcErrorMessage(error, fallback) - ) + const state = useAppStore.getState().sshConnectionStates.get(targetId) + // Main publishes the failure with its next step; the rejection carries only SSH's raw words. + const published = + state?.status === 'error' || state?.status === 'auth-failed' + ? compactIpcErrorMessage(state.error ?? '') + : undefined + return sshConnectFailureText(state, published || extractIpcErrorMessage(error, fallback)) } // Mirror main's ORCAD_TUNNEL_UNAVAILABLE_REASON. diff --git a/tests/e2e/ssh-orcad-ssh-failure-reason.spec.ts b/tests/e2e/ssh-orcad-ssh-failure-reason.spec.ts index c05c89dbe44..c3ad6739c6c 100644 --- a/tests/e2e/ssh-orcad-ssh-failure-reason.spec.ts +++ b/tests/e2e/ssh-orcad-ssh-failure-reason.spec.ts @@ -26,8 +26,18 @@ test.afterEach(() => { }) const CASES = [ - { name: 'a refused SSH port', reason: /ECONNREFUSED/, refused: true }, - { name: 'a key the host rejects', reason: /authentication methods failed/i, refused: false } + { + name: 'a refused SSH port', + reason: /ECONNREFUSED/, + nextStep: 'Nothing accepted the SSH connection at this address', + refused: true + }, + { + name: 'a key the host rejects', + reason: /authentication methods failed/i, + nextStep: 'The host didn’t accept any sign-in Orca offered', + refused: false + } ] as const for (const fault of CASES) { @@ -64,6 +74,7 @@ for (const fault of CASES) { } await expect(card).toContainText(fault.reason, { timeout: 2 * 60_000 }) await page.screenshot({ path: testInfo.outputPath('card.png') }) + await expect(card).toContainText(fault.nextStep) await expect(card).not.toContainText('couldn’t start its server') await expect(card.getByRole('button', { name: 'Open plain SSH terminal' })).toHaveCount(0) const toasts = page.locator('[data-sonner-toast]')