feat(ssh): support Kerberos/GSSAPI hosts via the system OpenSSH transport (#7507)
* feat(ssh): support Kerberos/GSSAPI hosts via the system OpenSSH transport ssh2 has no gssapi-with-mic support, and adding it would mean forking its protocol layer plus packaging the kerberos native module for three platforms. Instead, route GSSAPI hosts through the existing system-OpenSSH transport, which delegates Kerberos (tickets, SSPI on Windows) to the platform ssh binary. Two tiers, because RHEL-family distros enable GSSAPIAuthentication globally in /etc/ssh/ssh_config and ssh -G therefore reports it for every host: - Targets whose ~/.ssh/config Host block explicitly sets GSSAPIAuthentication yes (imported as target.gssapiAuthentication) try system ssh first, falling through to ssh2 so key auth and credential prompts still work when no ticket is available. - When ssh2 exhausts key/agent auth and the ssh -G-resolved config enables GSSAPI, retry over system ssh before prompting for credentials, so Kerberos-only hosts on distro-default configs connect without a password prompt. Hosts where keys work never leave the ssh2 path. Manual targets flagged for GSSAPI pass -o GSSAPIAuthentication=yes explicitly since they bypass ssh_config. Both tiers work headless (no credential callbacks required). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ssh): harden GSSAPI transport selection (review fixes for PR #7507) Review fixes on top of the Kerberos/GSSAPI feature branch (s546126/kerberos-ssh): - HIGH: reset useSystemSshTransport on the ssh2 fall-through. doSystemSshProbe sets the flag before spawnSystemSshCommand, which throws synchronously when no system ssh binary is on PATH (outside the probe try/catch). The proactive fall-through previously reset only 2 of 3 transport fields, so exec/sftp kept routing through the failed transport - breaking GSSAPI on Windows-with-Git-ssh and headless Linux. - MEDIUM: throw a cancellation error (not the stale ssh2 authError) when a disconnect supersedes the reactive probe mid-flight, and guard connect()'s catch on disposed, so a deliberate disconnect is not overwritten with auth-failed. - MEDIUM: skip the encrypted-key passphrase prompt when the GSSAPI fallback applies, so a Kerberos ticket is tried before prompting; the general prompt still fires if the probe fails. Adds 3 mutation-verified regression tests and hardens two existing tests to assert the probe actually ran. Not connected to any PR remote. Co-authored-by: Orca <help@stably.ai> * fix(ssh): isolate GSSAPI system transport Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9e2c63ec7c
commit
0302ae86b8
|
|
@ -0,0 +1,39 @@
|
|||
# Kerberos / GSSAPI SSH Authentication
|
||||
|
||||
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
|
||||
has no GSSAPI userauth support, and adding it would mean forking ssh2's
|
||||
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
|
||||
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
|
||||
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
|
||||
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
|
||||
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
|
||||
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
|
||||
and Windows (Win32-OpenSSH uses SSPI).
|
||||
|
||||
## Transport selection
|
||||
|
||||
Two tiers, deliberately asymmetric because RHEL-family distros ship
|
||||
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
|
||||
`ssh -G` report GSSAPI enabled for *every* host:
|
||||
|
||||
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
|
||||
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
|
||||
set on the target directly) tries the system-ssh probe first. If that fails
|
||||
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
|
||||
path where passphrase/password prompts remain available — OpenSSH semantics
|
||||
allow other auth methods alongside GSSAPI.
|
||||
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
|
||||
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
|
||||
in `ssh-connection-utils.ts`), the connection retries over system ssh
|
||||
*before* prompting the user for credentials. Kerberos-only hosts on
|
||||
distro-default configs connect this way; hosts where keys work never leave
|
||||
the ssh2 path.
|
||||
|
||||
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
|
||||
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
|
||||
inherit the option from their own `Host` block since the system binary re-reads
|
||||
ssh_config.
|
||||
|
||||
Both tiers work headless (`orca serve`): the system-ssh probe needs no
|
||||
credential callbacks, and GSSAPI itself is non-interactive once a ticket
|
||||
exists.
|
||||
|
|
@ -163,6 +163,40 @@ Host myserver
|
|||
expect(hosts[0].identitiesOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('parses GSSAPIAuthentication', () => {
|
||||
const config = `
|
||||
Host krb-host
|
||||
HostName krb.example.com
|
||||
GSSAPIAuthentication yes
|
||||
|
||||
Host plain-host
|
||||
HostName plain.example.com
|
||||
GSSAPIAuthentication no
|
||||
|
||||
Host silent-host
|
||||
HostName silent.example.com
|
||||
`
|
||||
const hosts = parseSshConfig(config)
|
||||
expect(hosts[0].gssapiAuthentication).toBe(true)
|
||||
expect(hosts[1].gssapiAuthentication).toBe(false)
|
||||
expect(hosts[2].gssapiAuthentication).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the first GSSAPIAuthentication value like OpenSSH', () => {
|
||||
const hosts = parseSshConfig(`
|
||||
Host enabled
|
||||
GSSAPIAuthentication yes
|
||||
GSSAPIAuthentication no
|
||||
|
||||
Host disabled
|
||||
GSSAPIAuthentication no
|
||||
GSSAPIAuthentication yes
|
||||
`)
|
||||
|
||||
expect(hosts[0].gssapiAuthentication).toBe(true)
|
||||
expect(hosts[1].gssapiAuthentication).toBe(false)
|
||||
})
|
||||
|
||||
it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => {
|
||||
const config = `
|
||||
Host internal
|
||||
|
|
@ -359,6 +393,12 @@ describe('sshConfigHostsToTargets', () => {
|
|||
expect(targets[0].jumpHost).toBe('bastion.example.com')
|
||||
})
|
||||
|
||||
it('carries through gssapiAuthentication', () => {
|
||||
const hosts = [{ host: 'krb-host', hostname: 'krb.example.com', gssapiAuthentication: true }]
|
||||
const targets = sshConfigHostsToTargets(hosts, new Set())
|
||||
expect(targets[0].gssapiAuthentication).toBe(true)
|
||||
})
|
||||
|
||||
it('imports duplicate aliases only once and keeps the first concrete host', () => {
|
||||
const hosts = [
|
||||
{ host: 'dup', hostname: 'first.example.com', user: 'first' },
|
||||
|
|
@ -508,6 +548,15 @@ describe('parseSshGOutput', () => {
|
|||
expect(result.identitiesOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('parses gssapiauthentication', () => {
|
||||
const output = 'hostname example.com\ngssapiauthentication yes\nport 22'
|
||||
const result = parseSshGOutput(output)
|
||||
expect(result.gssapiAuthentication).toBe(true)
|
||||
|
||||
const offResult = parseSshGOutput('hostname example.com\ngssapiauthentication no\nport 22')
|
||||
expect(offResult.gssapiAuthentication).toBe(false)
|
||||
})
|
||||
|
||||
it('parses controlmaster options and filters controlpath none', () => {
|
||||
const output = [
|
||||
'hostname example.com',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type SshConfigHost = {
|
|||
identityFile?: string
|
||||
identityAgent?: string
|
||||
identitiesOnly?: boolean
|
||||
gssapiAuthentication?: boolean
|
||||
proxyCommand?: string
|
||||
proxyUseFdpass?: boolean
|
||||
proxyJump?: string
|
||||
|
|
@ -104,6 +105,12 @@ export function parseSshConfig(content: string): SshConfigHost[] {
|
|||
host.identitiesOnly = value.toLowerCase() === 'yes'
|
||||
}
|
||||
break
|
||||
case 'gssapiauthentication':
|
||||
for (const host of current) {
|
||||
// Why: OpenSSH uses the first obtained value for each parameter.
|
||||
host.gssapiAuthentication ??= value.toLowerCase() === 'yes'
|
||||
}
|
||||
break
|
||||
case 'proxycommand':
|
||||
for (const host of current) {
|
||||
// Why: OpenSSH treats ProxyCommand as a shell snippet and preserves
|
||||
|
|
@ -249,6 +256,7 @@ export function sshConfigHostsToTargets(
|
|||
identityFile: entry.identityFile,
|
||||
identityAgent: entry.identityAgent,
|
||||
identitiesOnly: entry.identitiesOnly,
|
||||
gssapiAuthentication: entry.gssapiAuthentication,
|
||||
proxyCommand: entry.proxyCommand,
|
||||
jumpHost: entry.proxyJump
|
||||
})
|
||||
|
|
|
|||
|
|
@ -260,6 +260,34 @@ describe('SshConnectionStore', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('refreshes gssapiAuthentication on sync', () => {
|
||||
mockStore.addSshTarget({
|
||||
id: 'ssh-1',
|
||||
label: 'krb-box',
|
||||
configHost: 'krb-box',
|
||||
host: 'krb.example.com',
|
||||
port: 22,
|
||||
username: 'dev',
|
||||
source: 'ssh-config'
|
||||
})
|
||||
loadUserSshConfigMock.mockReturnValue([{ host: 'krb-box' }])
|
||||
sshConfigHostsToTargetsMock.mockReturnValue([
|
||||
candidate({
|
||||
configHost: 'krb-box',
|
||||
host: 'krb.example.com',
|
||||
username: 'dev',
|
||||
gssapiAuthentication: true
|
||||
})
|
||||
])
|
||||
|
||||
sshStore.importFromSshConfig()
|
||||
|
||||
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
expect.objectContaining({ gssapiAuthentication: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('never overwrites a manual target that owns the alias', () => {
|
||||
mockStore.addSshTarget({
|
||||
id: 'ssh-m',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export class SshConnectionStore {
|
|||
identityFile: candidate.identityFile,
|
||||
identityAgent: candidate.identityAgent,
|
||||
identitiesOnly: candidate.identitiesOnly,
|
||||
gssapiAuthentication: candidate.gssapiAuthentication,
|
||||
proxyCommand: candidate.proxyCommand,
|
||||
jumpHost: candidate.jumpHost
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ vi.mock('fs', () => ({
|
|||
import {
|
||||
isTransientError,
|
||||
isSystemSshFallbackError,
|
||||
isGssapiSystemSshFallbackCandidate,
|
||||
isAuthError,
|
||||
isAgentFallbackError,
|
||||
sleep,
|
||||
|
|
@ -166,6 +167,49 @@ describe('isSystemSshFallbackError', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ── isGssapiSystemSshFallbackCandidate ───────────────────────────────
|
||||
|
||||
describe('isGssapiSystemSshFallbackCandidate', () => {
|
||||
const authErr = new Error('All configured authentication methods failed')
|
||||
|
||||
it('returns true for auth failures when resolved config enables GSSAPI', () => {
|
||||
expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: true })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns true for passphrase failures so Kerberos SSO runs before prompting', () => {
|
||||
const passphraseErr = new Error('Encrypted private OpenSSH key detected, but no passphrase')
|
||||
expect(
|
||||
isGssapiSystemSshFallbackCandidate(passphraseErr, {}, { gssapiAuthentication: true })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the target already tried system ssh proactively', () => {
|
||||
expect(
|
||||
isGssapiSystemSshFallbackCandidate(
|
||||
authErr,
|
||||
{ gssapiAuthentication: true },
|
||||
{ gssapiAuthentication: true }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false without GSSAPI in the resolved config', () => {
|
||||
expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: false })).toBe(
|
||||
false
|
||||
)
|
||||
expect(isGssapiSystemSshFallbackCandidate(authErr, {}, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for network errors so retry semantics stay unchanged', () => {
|
||||
const netErr = new Error('connect ETIMEDOUT 1.2.3.4:22')
|
||||
expect(isGssapiSystemSshFallbackCandidate(netErr, {}, { gssapiAuthentication: true })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── isAuthError ──────────────────────────────────────────────────────
|
||||
|
||||
describe('isAuthError', () => {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ export function isSystemSshFallbackError(err: Error): boolean {
|
|||
return err.message.includes('EHOSTUNREACH') || err.message.includes('ENETUNREACH')
|
||||
}
|
||||
|
||||
// Why: ssh2 has no gssapi-with-mic support. When the effective OpenSSH config
|
||||
// enables GSSAPIAuthentication (often a distro-wide /etc/ssh default), a
|
||||
// Kerberos ticket can still authenticate through the system ssh binary after
|
||||
// key/agent auth fails — but only auth-shaped failures qualify, so network
|
||||
// errors keep their existing retry semantics.
|
||||
export function isGssapiSystemSshFallbackCandidate(
|
||||
err: Error,
|
||||
target: Pick<SshTarget, 'gssapiAuthentication'>,
|
||||
resolved: Pick<SshResolvedConfig, 'gssapiAuthentication'> | null
|
||||
): boolean {
|
||||
// Why: targets with an explicit per-host flag already tried system ssh
|
||||
// proactively during this attempt; probing again cannot succeed.
|
||||
if (target.gssapiAuthentication === true) {
|
||||
return false
|
||||
}
|
||||
return (isAuthError(err) || isPassphraseError(err)) && resolved?.gssapiAuthentication === true
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1060,6 +1060,236 @@ describe('SshConnection', () => {
|
|||
expect(conn.usesSystemSshTransport()).toBe(false)
|
||||
})
|
||||
|
||||
it('tries system SSH first for targets that explicitly request GSSAPI authentication', async () => {
|
||||
const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks())
|
||||
|
||||
await conn.connect()
|
||||
await conn.exec('echo after-connect')
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(clientInstances).toHaveLength(0)
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ gssapiAuthentication: true }),
|
||||
'echo ORCA-SYSTEM-SSH-OK',
|
||||
{
|
||||
gssapiOnly: true,
|
||||
wrapCommand: false
|
||||
}
|
||||
)
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ gssapiAuthentication: true }),
|
||||
'echo after-connect',
|
||||
{ gssapiOnly: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to ssh2 when the GSSAPI-first system SSH attempt fails', async () => {
|
||||
spawnSystemSshCommandMock.mockImplementation(() =>
|
||||
createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,publickey)')
|
||||
)
|
||||
const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks())
|
||||
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(false)
|
||||
expect(clientInstances).toHaveLength(1)
|
||||
// Why: proves the GSSAPI-first probe actually ran before the ssh2 fallback,
|
||||
// so the test fails if the proactive block is removed.
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gssapiAuthentication: true }),
|
||||
'echo ORCA-SYSTEM-SSH-OK',
|
||||
{
|
||||
gssapiOnly: true,
|
||||
wrapCommand: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to system SSH after an ssh2 auth failure when resolved config enables GSSAPI', async () => {
|
||||
connectBehavior = 'error'
|
||||
connectErrorMessage = 'All configured authentication methods failed'
|
||||
vi.mocked(resolveWithSshG).mockResolvedValue(
|
||||
createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true })
|
||||
)
|
||||
const onCredentialRequest = vi.fn(async () => 'password-123')
|
||||
const conn = new SshConnection(
|
||||
createTarget({ configHost: 'krb-host' }),
|
||||
createCallbacks({ onCredentialRequest })
|
||||
)
|
||||
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(onCredentialRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connects through the GSSAPI fallback without credential callbacks (headless)', async () => {
|
||||
connectBehavior = 'error'
|
||||
connectErrorMessage = 'All configured authentication methods failed'
|
||||
vi.mocked(resolveWithSshG).mockResolvedValue(
|
||||
createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true })
|
||||
)
|
||||
const conn = new SshConnection(createTarget({ configHost: 'krb-host' }), createCallbacks())
|
||||
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps prompting for credentials when the GSSAPI fallback probe fails', async () => {
|
||||
// Why: identityAgent 'none' makes resolveAgentSocket return undefined on
|
||||
// every platform (SSH_AUTH_SOCK='' alone leaves the Windows agent pipe), so
|
||||
// ssh2's first connect carries any default key directly and the agent
|
||||
// fallback retry never consumes the second connectSequence entry —
|
||||
// deterministic on dev machines with both ~/.ssh/id_* and a live agent.
|
||||
vi.stubEnv('SSH_AUTH_SOCK', '')
|
||||
connectSequence = [new Error('All configured authentication methods failed'), 'ready']
|
||||
spawnSystemSshCommandMock.mockImplementation(() =>
|
||||
createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,password)')
|
||||
)
|
||||
vi.mocked(resolveWithSshG).mockResolvedValue(
|
||||
createResolvedConfig({
|
||||
proxyUseFdpass: false,
|
||||
gssapiAuthentication: true,
|
||||
identityAgent: 'none'
|
||||
})
|
||||
)
|
||||
const onCredentialRequest = vi.fn(async () => 'password-123')
|
||||
const conn = new SshConnection(
|
||||
createTarget({ configHost: 'krb-host' }),
|
||||
createCallbacks({ onCredentialRequest })
|
||||
)
|
||||
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(false)
|
||||
// Why: proves the reactive GSSAPI probe actually ran before prompting, so
|
||||
// the test fails if the reactive fallback block is removed.
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configHost: 'krb-host' }),
|
||||
'echo ORCA-SYSTEM-SSH-OK',
|
||||
expect.objectContaining({ wrapCommand: false })
|
||||
)
|
||||
expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', expect.any(String))
|
||||
})
|
||||
|
||||
it('tries the GSSAPI probe before prompting for an encrypted key passphrase', async () => {
|
||||
// Why: a valid Kerberos ticket should connect silently before the user is
|
||||
// ever asked for the key passphrase. Agent auth fails, the explicit-key
|
||||
// retry fails with a passphrase error, and resolved GSSAPI is on — so the
|
||||
// reactive probe must run before onCredentialRequest.
|
||||
vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock')
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'orca-ssh-key-'))
|
||||
const keyPath = join(tempDir, 'id_ed25519')
|
||||
writeFileSync(keyPath, 'test-key')
|
||||
connectSequence = [
|
||||
new Error('All configured authentication methods failed'),
|
||||
new Error('Encrypted private OpenSSH key detected, but no passphrase given')
|
||||
]
|
||||
vi.mocked(resolveWithSshG).mockResolvedValue(
|
||||
createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true })
|
||||
)
|
||||
const order: string[] = []
|
||||
spawnSystemSshCommandMock.mockImplementation(() => {
|
||||
order.push('probe')
|
||||
return createSystemCommandChannel()
|
||||
})
|
||||
const onCredentialRequest = vi.fn(async () => {
|
||||
order.push('prompt')
|
||||
return 'secret'
|
||||
})
|
||||
|
||||
try {
|
||||
const conn = new SshConnection(
|
||||
createTarget({ configHost: 'krb-host', identityFile: keyPath }),
|
||||
createCallbacks({ onCredentialRequest })
|
||||
)
|
||||
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
// Why: the probe must precede any passphrase prompt (which here never runs).
|
||||
expect(order[0]).toBe('probe')
|
||||
expect(onCredentialRequest).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not try system SSH for auth failures when resolved config leaves GSSAPI off', async () => {
|
||||
connectBehavior = 'error'
|
||||
connectErrorMessage = 'All configured authentication methods failed'
|
||||
vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig({ proxyUseFdpass: false }))
|
||||
const conn = new SshConnection(createTarget({ configHost: 'plain-host' }), createCallbacks())
|
||||
|
||||
await expect(conn.connect()).rejects.toThrow('All configured authentication methods failed')
|
||||
expect(conn.getState().status).toBe('auth-failed')
|
||||
expect(spawnSystemSshCommandMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears system SSH transport when the GSSAPI-first probe throws synchronously', async () => {
|
||||
// Why: no system ssh binary makes spawnSystemSshCommand throw before the
|
||||
// probe's try/catch, so the ssh2 fall-through must still reset the flag —
|
||||
// otherwise exec/sftp keep routing through the unusable system transport.
|
||||
spawnSystemSshCommandMock.mockImplementation(() => {
|
||||
throw new Error('No system ssh binary found. Install OpenSSH.')
|
||||
})
|
||||
connectSequence = ['ready']
|
||||
const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks())
|
||||
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(false)
|
||||
expect(clientInstances).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps disconnected state when a disconnect cancels the reactive GSSAPI probe', async () => {
|
||||
connectBehavior = 'error'
|
||||
connectErrorMessage = 'All configured authentication methods failed'
|
||||
vi.mocked(resolveWithSshG).mockResolvedValue(
|
||||
createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true })
|
||||
)
|
||||
// Why: a probe channel that stays open until close() leaves the reactive
|
||||
// fallback pending, so we can disconnect mid-probe; disconnect() then calls
|
||||
// close() (bumping the generation first), which settles the probe as a
|
||||
// cancellation rather than a probe failure.
|
||||
let pendingChannel: ReturnType<typeof createSystemCommandChannel> | null = null
|
||||
spawnSystemSshCommandMock.mockImplementation(() => {
|
||||
const channel = new EventEmitter() as ReturnType<typeof createSystemCommandChannel>
|
||||
channel.stdin = { end: vi.fn(), write: vi.fn() }
|
||||
channel.stderr = new EventEmitter()
|
||||
channel.close = vi.fn(() => channel.emit('close', null))
|
||||
pendingChannel = channel
|
||||
return channel
|
||||
})
|
||||
const onStateChange = vi.fn()
|
||||
const conn = new SshConnection(
|
||||
createTarget({ configHost: 'krb-host' }),
|
||||
createCallbacks({ onStateChange })
|
||||
)
|
||||
|
||||
const connectPromise = conn.connect()
|
||||
// Wait until the reactive probe has spawned its (never-closing) channel.
|
||||
await vi.waitFor(() => expect(pendingChannel).not.toBeNull())
|
||||
|
||||
await conn.disconnect()
|
||||
await connectPromise.catch(() => {})
|
||||
|
||||
expect(conn.getState().status).toBe('disconnected')
|
||||
const statuses = onStateChange.mock.calls.map((call) => call[1].status)
|
||||
expect(statuses).not.toContain('auth-failed')
|
||||
expect(statuses).not.toContain('error')
|
||||
})
|
||||
|
||||
it('passes the detected host platform to system SSH file operations', async () => {
|
||||
vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig())
|
||||
const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks())
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
isAuthError,
|
||||
isAgentFallbackError,
|
||||
isSystemSshFallbackError,
|
||||
isGssapiSystemSshFallbackCandidate,
|
||||
isPassphraseError,
|
||||
sleep,
|
||||
buildConnectConfig,
|
||||
|
|
@ -73,6 +74,7 @@ export class SshConnection {
|
|||
private systemOperationAbortController = new AbortController()
|
||||
private systemSshResolvedConfig: SshResolvedConfig | null = null
|
||||
private systemSshControlMasterDisabledForSession = false
|
||||
private systemSshGssapiOnlyForSession = false
|
||||
private useSystemSshTransport = false
|
||||
private state: SshConnectionState
|
||||
private callbacks: SshConnectionCallbacks
|
||||
|
|
@ -491,6 +493,12 @@ export class SshConnection {
|
|||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err))
|
||||
|
||||
// Why: a concurrent disconnect() already set 'disconnected'; a cancelled
|
||||
// attempt's late error must not overwrite it with auth-failed/error.
|
||||
if (this.disposed) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (isAuthError(lastError) || isPassphraseError(lastError)) {
|
||||
this.setState('auth-failed', lastError.message)
|
||||
throw lastError
|
||||
|
|
@ -525,8 +533,27 @@ export class SshConnection {
|
|||
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved)
|
||||
return
|
||||
}
|
||||
// Why: ssh2 has no gssapi-with-mic support, so hosts that explicitly
|
||||
// request GSSAPIAuthentication try Kerberos SSO via the system OpenSSH
|
||||
// binary first. Restrict the probe to GSSAPI so missing tickets fall through
|
||||
// to Orca's existing key and credential-prompt path.
|
||||
if (this.target.gssapiAuthentication === true) {
|
||||
try {
|
||||
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true)
|
||||
return
|
||||
} catch (probeErr) {
|
||||
if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) {
|
||||
throw probeErr
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: a synchronous spawn throw (no system ssh binary) bypasses the probe's
|
||||
// own catch, so the ssh2 fall-through must clear all system-transport state
|
||||
// itself — otherwise exec/sftp keep routing through the failed transport.
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.useSystemSshTransport = false
|
||||
|
||||
const config = buildConnectConfig(this.target, resolved)
|
||||
|
||||
|
|
@ -566,6 +593,7 @@ export class SshConnection {
|
|||
} catch {
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.useSystemSshTransport = false
|
||||
throw err
|
||||
}
|
||||
|
|
@ -604,7 +632,15 @@ export class SshConnection {
|
|||
throw keyErr
|
||||
}
|
||||
authError = keyErr
|
||||
if (isPassphraseError(authError) && !this.cachedPassphrase) {
|
||||
// Why: when the effective config enables GSSAPI, let the reactive
|
||||
// system-ssh probe (below) try a Kerberos ticket before prompting
|
||||
// for the key passphrase; the general passphrase prompt still runs
|
||||
// if that probe fails, since passphrasePromptHandled stays false.
|
||||
if (
|
||||
isPassphraseError(authError) &&
|
||||
!this.cachedPassphrase &&
|
||||
!isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)
|
||||
) {
|
||||
passphrasePromptHandled = true
|
||||
const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)'
|
||||
const val = await this.callbacks.onCredentialRequest?.(
|
||||
|
|
@ -624,6 +660,28 @@ export class SshConnection {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: a Kerberos ticket may authenticate where keys did not; try the
|
||||
// system ssh binary before falling back to interactive prompts.
|
||||
if (isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)) {
|
||||
this.proxyProcess?.kill()
|
||||
this.proxyProcess = null
|
||||
try {
|
||||
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true)
|
||||
return
|
||||
} catch {
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.useSystemSshTransport = false
|
||||
}
|
||||
// Why: if a disconnect/reconnect superseded this attempt mid-probe, throw
|
||||
// the cancellation error — not the stale ssh2 authError — so connect()
|
||||
// does not post auth-failed after the target was deliberately disconnected.
|
||||
if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) {
|
||||
throw this.createCancelledConnectAttemptError()
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.callbacks.onCredentialRequest) {
|
||||
this.proxyProcess?.kill()
|
||||
this.proxyProcess = null
|
||||
|
|
@ -762,12 +820,15 @@ export class SshConnection {
|
|||
|
||||
private async doSystemSshProbeWithControlMasterRetry(
|
||||
connectGeneration: number,
|
||||
resolved: SshResolvedConfig | null
|
||||
resolved: SshResolvedConfig | null,
|
||||
gssapiOnly = false
|
||||
): Promise<void> {
|
||||
this.systemSshResolvedConfig = cloneResolvedConfig(resolved)
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = gssapiOnly
|
||||
const controlPath = getOrcaControlSocketPath(this.target, {
|
||||
resolvedConfig: this.systemSshResolvedConfig
|
||||
resolvedConfig: this.systemSshResolvedConfig,
|
||||
gssapiOnly: this.systemSshGssapiOnlyForSession
|
||||
})
|
||||
try {
|
||||
await this.doSystemSshProbe(connectGeneration)
|
||||
|
|
@ -937,6 +998,9 @@ export class SshConnection {
|
|||
if (this.systemSshControlMasterDisabledForSession) {
|
||||
options.disableControlMaster = true
|
||||
}
|
||||
if (this.systemSshGssapiOnlyForSession) {
|
||||
options.gssapiOnly = true
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
|
|
@ -1117,6 +1181,7 @@ export class SshConnection {
|
|||
this.systemSsh = null
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.useSystemSshTransport = false
|
||||
}
|
||||
|
||||
|
|
@ -1129,6 +1194,7 @@ export class SshConnection {
|
|||
this.systemSsh = null
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.useSystemSshTransport = false
|
||||
this.setState('connecting')
|
||||
try {
|
||||
|
|
@ -1170,6 +1236,7 @@ export class SshConnection {
|
|||
this.useSystemSshTransport = false
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.setState('error', err instanceof Error ? err.message : String(err))
|
||||
throw err
|
||||
}
|
||||
|
|
@ -1198,6 +1265,7 @@ export class SshConnection {
|
|||
this.systemSsh = null
|
||||
this.systemSshResolvedConfig = null
|
||||
this.systemSshControlMasterDisabledForSession = false
|
||||
this.systemSshGssapiOnlyForSession = false
|
||||
this.useSystemSshTransport = false
|
||||
this.setState('disconnected')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ describe.skipIf(process.platform === 'win32')('getControlSocketPath', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('uses a separate socket for GSSAPI-only authentication', () => {
|
||||
const ordinary = getControlSocketPath(createTarget(), createResolved())
|
||||
const gssapiOnly = getControlSocketPath(createTarget(), createResolved(), true)
|
||||
|
||||
expect(gssapiOnly).not.toBe(ordinary)
|
||||
})
|
||||
|
||||
it('changes the path when a config-backed target resolves to a different host', () => {
|
||||
const before = getControlSocketPath(createTarget({ host: '10.0.0.5' }), createResolved())
|
||||
const after = getControlSocketPath(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ const CONTROL_SOCKET_PATH_MAX_LENGTH = UNIX_SOCKET_PATH_LIMIT - OPENSSH_CONTROL_
|
|||
|
||||
export function getControlSocketPath(
|
||||
target: SshTarget,
|
||||
resolvedConfig?: SystemSshResolvedConfig | null
|
||||
resolvedConfig?: SystemSshResolvedConfig | null,
|
||||
gssapiOnly = false
|
||||
): string | null {
|
||||
if (process.platform === 'win32') {
|
||||
return null
|
||||
|
|
@ -58,7 +59,9 @@ export function getControlSocketPath(
|
|||
identityAgent: target.identityAgent || '',
|
||||
identitiesOnly: target.identitiesOnly || false
|
||||
},
|
||||
resolved: normalizeResolvedConfig(resolvedConfig)
|
||||
resolved: normalizeResolvedConfig(resolvedConfig),
|
||||
// Why: a Kerberos-only session must not reuse a master authenticated by a key.
|
||||
gssapiOnly
|
||||
})
|
||||
const hash = createHash('sha256').update(key).digest('hex').slice(0, 16)
|
||||
const socketPath = pathJoin(dir, hash)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export type SshResolvedConfig = {
|
|||
identityAgent?: string
|
||||
identitiesOnly: boolean
|
||||
forwardAgent: boolean
|
||||
/** Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults. */
|
||||
gssapiAuthentication?: boolean
|
||||
proxyCommand?: string
|
||||
proxyUseFdpass: boolean
|
||||
proxyJump?: string
|
||||
|
|
@ -106,6 +108,7 @@ function buildSshResolvedConfig(
|
|||
identityAgent,
|
||||
identitiesOnly: map.get('identitiesonly') === 'yes',
|
||||
forwardAgent: map.get('forwardagent') === 'yes',
|
||||
gssapiAuthentication: map.get('gssapiauthentication') === 'yes',
|
||||
proxyCommand,
|
||||
proxyUseFdpass: map.get('proxyusefdpass') === 'yes',
|
||||
proxyJump,
|
||||
|
|
|
|||
|
|
@ -291,6 +291,53 @@ describe('spawnSystemSsh', () => {
|
|||
expect(args).toContain('deploy@127.0.0.1')
|
||||
})
|
||||
|
||||
it('requests GSSAPI authentication explicitly for manual targets', () => {
|
||||
const args = buildSshArgs(
|
||||
createTarget({ source: 'manual', configHost: 'krb.example.com', gssapiAuthentication: true })
|
||||
)
|
||||
|
||||
expect(args).toContain('GSSAPIAuthentication=yes')
|
||||
})
|
||||
|
||||
it('restricts Kerberos probes to non-interactive GSSAPI authentication', () => {
|
||||
spawnSystemSshCommand(
|
||||
createTarget({
|
||||
configHost: 'krb-host; touch /tmp/not-run',
|
||||
source: 'ssh-config',
|
||||
gssapiAuthentication: true
|
||||
}),
|
||||
'echo ready',
|
||||
{ gssapiOnly: true, wrapCommand: false }
|
||||
)
|
||||
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'GSSAPIAuthentication=yes',
|
||||
'-o',
|
||||
'PreferredAuthentications=gssapi-with-mic'
|
||||
])
|
||||
)
|
||||
expect(args).not.toContain('BatchMode=no')
|
||||
const standaloneControlIdx = args.indexOf('-S')
|
||||
expect(standaloneControlIdx).toBeGreaterThan(-1)
|
||||
expect(args[standaloneControlIdx + 1]).toBe('none')
|
||||
expect(args.at(-2)).toBe('deploy@krb-host; touch /tmp/not-run')
|
||||
expect(args.at(-1)).toBe('echo ready')
|
||||
})
|
||||
|
||||
it('leaves GSSAPI to the Host block for ssh-config targets', () => {
|
||||
const args = buildSshArgs(
|
||||
createTarget({ configHost: 'krb-host', source: 'ssh-config', gssapiAuthentication: true })
|
||||
)
|
||||
|
||||
expect(args).not.toContain('GSSAPIAuthentication=yes')
|
||||
expect(args).toContain('deploy@krb-host')
|
||||
})
|
||||
|
||||
it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => {
|
||||
const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), {
|
||||
resolvedConfig: createResolvedConfig({
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function writeFakeSsh(dir: string): string {
|
|||
`#!/bin/sh
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o|-p|-i|-J) shift 2 ;;
|
||||
-o|-p|-i|-J|-S) shift 2 ;;
|
||||
-T) shift ;;
|
||||
--) shift; break ;;
|
||||
-*) shift ;;
|
||||
|
|
@ -221,6 +221,25 @@ describe('system SSH transport integration', () => {
|
|||
20_000
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'connects GSSAPI-flagged targets through system ssh without the force override',
|
||||
async () => {
|
||||
delete process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT
|
||||
const conn = new SshConnection(
|
||||
{ ...makeTarget(), gssapiAuthentication: true },
|
||||
{ onStateChange: vi.fn() }
|
||||
)
|
||||
await conn.connect()
|
||||
try {
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
} finally {
|
||||
await conn.disconnect()
|
||||
}
|
||||
},
|
||||
20_000
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'uploads a directory through the system ssh stdin/stdout path',
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -5,21 +5,30 @@ export type SystemSshBuildArgsOptions = {
|
|||
resolvedConfig?: SystemSshResolvedConfig | null
|
||||
disableControlMaster?: boolean
|
||||
suppressOrcaControlMaster?: boolean
|
||||
gssapiOnly?: boolean
|
||||
}
|
||||
|
||||
export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOptions): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
args.push('-o', 'BatchMode=no')
|
||||
args.push('-o', options?.gssapiOnly ? 'BatchMode=yes' : 'BatchMode=no')
|
||||
if (options?.gssapiOnly) {
|
||||
// Why: the probe must neither authenticate with a key nor open an OpenSSH
|
||||
// credential prompt; failure belongs to Orca's existing ssh2 prompt path.
|
||||
args.push('-o', 'GSSAPIAuthentication=yes')
|
||||
args.push('-o', 'PreferredAuthentications=gssapi-with-mic')
|
||||
}
|
||||
// Forward stdin/stdout for relay communication
|
||||
args.push('-T')
|
||||
|
||||
// Why: ControlMaster multiplexes all SSH exec commands over a single connection,
|
||||
// eliminating the ~9s handshake overhead per command. Without this, each
|
||||
// spawnSystemSshCommand call opens a new TCP connection.
|
||||
const forceDisableControlMaster =
|
||||
options?.disableControlMaster === true || target.systemSshConnectionReuse === false
|
||||
const controlPath = getOrcaControlSocketPath(target, options)
|
||||
const forceDisableControlMaster =
|
||||
options?.disableControlMaster === true ||
|
||||
target.systemSshConnectionReuse === false ||
|
||||
(options?.gssapiOnly === true && controlPath === null)
|
||||
if (forceDisableControlMaster) {
|
||||
// Why: muxed OpenSSH forwards remain registered on the master after the
|
||||
// client exits. Also honors the per-target compatibility opt-out even if
|
||||
|
|
@ -53,6 +62,12 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
|
|||
args.push('-o', 'IdentitiesOnly=yes')
|
||||
}
|
||||
|
||||
if (!useConfigHost && target.gssapiAuthentication && !options?.gssapiOnly) {
|
||||
// Why: manual targets bypass ssh_config, so Kerberos auth must be
|
||||
// requested explicitly; config-backed hosts inherit it from their entry.
|
||||
args.push('-o', 'GSSAPIAuthentication=yes')
|
||||
}
|
||||
|
||||
if (!useConfigHost && target.jumpHost) {
|
||||
args.push('-J', target.jumpHost)
|
||||
}
|
||||
|
|
@ -75,7 +90,7 @@ export function getOrcaControlSocketPath(
|
|||
if (shouldDisableOrcaControlMaster(target, options)) {
|
||||
return null
|
||||
}
|
||||
return getControlSocketPath(target, options?.resolvedConfig)
|
||||
return getControlSocketPath(target, options?.resolvedConfig, options?.gssapiOnly === true)
|
||||
}
|
||||
|
||||
export function getSystemSshBuildArgsFromOperationOptions(
|
||||
|
|
@ -91,6 +106,9 @@ export function getSystemSshBuildArgsFromOperationOptions(
|
|||
if (options?.suppressOrcaControlMaster === true) {
|
||||
buildArgsOptions.suppressOrcaControlMaster = true
|
||||
}
|
||||
if (options?.gssapiOnly === true) {
|
||||
buildArgsOptions.gssapiOnly = true
|
||||
}
|
||||
return Object.keys(buildArgsOptions).length === 0 ? undefined : buildArgsOptions
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +125,7 @@ function shouldDisableOrcaControlMaster(
|
|||
options?.suppressOrcaControlMaster === true ||
|
||||
target.systemSshConnectionReuse === false ||
|
||||
unresolvedConfigBackedTarget ||
|
||||
hasUserConfiguredControlMaster(options?.resolvedConfig)
|
||||
(hasUserConfiguredControlMaster(options?.resolvedConfig) && options?.gssapiOnly !== true)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ export type SshTarget = {
|
|||
identityAgent?: string
|
||||
/** Whether OpenSSH IdentitiesOnly should limit public-key auth attempts. */
|
||||
identitiesOnly?: boolean
|
||||
/** Whether the host's SSH config explicitly requests GSSAPIAuthentication
|
||||
* (Kerberos). ssh2 has no gssapi-with-mic support, so these targets try the
|
||||
* system OpenSSH transport first. */
|
||||
gssapiAuthentication?: boolean
|
||||
/** ProxyCommand from SSH config, if any. */
|
||||
proxyCommand?: string
|
||||
/** Jump host (ProxyJump), if any. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue