mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
* 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>
135 lines
3.9 KiB
TypeScript
135 lines
3.9 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
|
|
const { lstatSyncMock, mkdirSyncMock, tmpdirMock } = vi.hoisted(() => ({
|
|
lstatSyncMock: vi.fn(),
|
|
mkdirSyncMock: vi.fn(),
|
|
tmpdirMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>
|
|
return {
|
|
...actual,
|
|
lstatSync: lstatSyncMock,
|
|
mkdirSync: mkdirSyncMock
|
|
}
|
|
})
|
|
|
|
vi.mock('node:os', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>
|
|
return {
|
|
...actual,
|
|
tmpdir: tmpdirMock
|
|
}
|
|
})
|
|
|
|
import { getControlSocketPath, type SystemSshResolvedConfig } from './ssh-control-socket'
|
|
|
|
const CURRENT_UID = process.getuid?.() ?? 501
|
|
|
|
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
|
|
return {
|
|
id: 'target-1',
|
|
label: 'Test Server',
|
|
configHost: 'devpod',
|
|
host: '10.0.0.5',
|
|
port: 22,
|
|
username: 'deploy',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function createResolved(overrides?: Partial<SystemSshResolvedConfig>): SystemSshResolvedConfig {
|
|
return {
|
|
hostname: '10.0.0.5',
|
|
port: 22,
|
|
user: 'deploy',
|
|
identityFile: ['/Users/me/.ssh/id_ed25519'],
|
|
forwardAgent: false,
|
|
identitiesOnly: true,
|
|
proxyUseFdpass: true,
|
|
controlMaster: 'no',
|
|
controlPersist: 'no',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe.skipIf(process.platform === 'win32')('getControlSocketPath', () => {
|
|
beforeEach(() => {
|
|
vi.unstubAllEnvs()
|
|
vi.stubEnv('XDG_RUNTIME_DIR', '')
|
|
tmpdirMock.mockReset()
|
|
tmpdirMock.mockReturnValue('/tmp')
|
|
mkdirSyncMock.mockReset()
|
|
lstatSyncMock.mockReset()
|
|
lstatSyncMock.mockReturnValue({
|
|
isDirectory: () => true,
|
|
uid: CURRENT_UID,
|
|
mode: 0o40700
|
|
})
|
|
})
|
|
|
|
it('returns a stable short private socket path for the same effective target', () => {
|
|
const first = getControlSocketPath(createTarget(), createResolved())
|
|
const second = getControlSocketPath(createTarget(), createResolved())
|
|
|
|
expect(first).toBe(second)
|
|
expect(first).toMatch(new RegExp(`/orca-ssh-${CURRENT_UID}/[0-9a-f]{16}$`))
|
|
// Why: OpenSSH creates a temporary mux listener by appending a suffix first.
|
|
expect(first!.length).toBeLessThanOrEqual(90)
|
|
expect(mkdirSyncMock).toHaveBeenCalledWith(`/tmp/orca-ssh-${CURRENT_UID}`, {
|
|
recursive: true,
|
|
mode: 0o700
|
|
})
|
|
})
|
|
|
|
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(
|
|
createTarget({ host: '10.0.0.9' }),
|
|
createResolved({ hostname: '10.0.0.9' })
|
|
)
|
|
|
|
expect(after).not.toBe(before)
|
|
})
|
|
|
|
it('changes the path when fresh ssh config resolution changes the route', () => {
|
|
const before = getControlSocketPath(
|
|
createTarget(),
|
|
createResolved({ proxyCommand: 'ssh -W %h:%p old-bastion' })
|
|
)
|
|
const after = getControlSocketPath(
|
|
createTarget(),
|
|
createResolved({ proxyCommand: 'ssh -W %h:%p new-bastion' })
|
|
)
|
|
|
|
expect(after).not.toBe(before)
|
|
})
|
|
|
|
it('uses XDG_RUNTIME_DIR before tmp when it is absolute and private', () => {
|
|
vi.stubEnv('XDG_RUNTIME_DIR', '/run/user/501')
|
|
|
|
const path = getControlSocketPath(createTarget(), createResolved())
|
|
|
|
expect(path).toMatch(/^\/run\/user\/501\/orca-ssh\/[0-9a-f]{16}$/)
|
|
})
|
|
|
|
it('falls back to non-multiplexed SSH when the control directory is unsafe', () => {
|
|
lstatSyncMock.mockReturnValue({
|
|
isDirectory: () => false,
|
|
uid: CURRENT_UID,
|
|
mode: 0o40700
|
|
})
|
|
|
|
expect(getControlSocketPath(createTarget(), createResolved())).toBeNull()
|
|
})
|
|
})
|