mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(ssh): lead an SSH connect failure with what to do about it (#33990)
* fix(ssh): say why SSH failed when Connect never reaches the host When the SSH session itself could not open (key rejected, port refused, host not answering, host key not trusted), Connect on a host Orca had not set up yet said "Orca couldn't start its server on this host" and offered a plain SSH terminal that could not work either. Errors from opening the SSH session are now marked where they are dialed, and a setup that fails on one ends the connect with SSH's own reason. A held move keeps its status. The connect-failure toast also drops Electron's IPC prefix. * fix(ssh): lead an SSH connect failure with what to do about it #33928 shows SSH's own reason when Connect never reaches the host. Raw reasons like "connect ECONNREFUSED 10.0.0.1:22" or "Timed out while waiting for SSH authentication" still leave users guessing. A failure from opening the SSH session is now published with a next step for the kinds users can act on (host key mismatch, rejected sign-in, refused port, unknown host name, no route, timeout), and SSH's words follow in parentheses. The connect-failure toast reads the same published text.
This commit is contained in:
@@ -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<HostServerOnConnectResult> {
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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\)$/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<FailureKind, string> = {
|
||||
'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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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]')
|
||||
|
||||
Reference in New Issue
Block a user