mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +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>
267 lines
6.7 KiB
TypeScript
267 lines
6.7 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { homedir } from 'node:os'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import { expandSshConfigIncludes } from './ssh-config-include-expander'
|
|
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
|
|
export { parseSshGOutput, resolveWithSshG, type SshResolvedConfig } from './ssh-g-config-resolution'
|
|
|
|
export type SshConfigHost = {
|
|
host: string
|
|
hostname?: string
|
|
port?: number
|
|
user?: string
|
|
identityFile?: string
|
|
identityAgent?: string
|
|
identitiesOnly?: boolean
|
|
gssapiAuthentication?: boolean
|
|
proxyCommand?: string
|
|
proxyUseFdpass?: boolean
|
|
proxyJump?: string
|
|
}
|
|
|
|
/**
|
|
* Parse an OpenSSH config file into structured host entries.
|
|
* Handles Host blocks with single or multiple patterns.
|
|
* Ignores wildcard-only patterns (e.g. "Host *").
|
|
*/
|
|
export function parseSshConfig(content: string): SshConfigHost[] {
|
|
const hosts: SshConfigHost[] = []
|
|
let current: SshConfigHost[] = []
|
|
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.trim()
|
|
if (!line || line.startsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
const directive = parseConfigDirective(line)
|
|
if (!directive) {
|
|
continue
|
|
}
|
|
|
|
const { key, rawValue } = directive
|
|
|
|
if (key === 'host') {
|
|
if (current.length > 0) {
|
|
appendHosts(hosts, current)
|
|
}
|
|
|
|
const patterns = splitHostPatterns(rawValue)
|
|
const concretePatterns = patterns.filter(
|
|
(pattern) => !pattern.startsWith('!') && !pattern.includes('*') && !pattern.includes('?')
|
|
)
|
|
if (concretePatterns.length === 0) {
|
|
current = []
|
|
continue
|
|
}
|
|
|
|
current = concretePatterns.map((pattern) => ({ host: pattern }))
|
|
continue
|
|
}
|
|
|
|
if (key === 'match') {
|
|
if (current.length > 0) {
|
|
appendHosts(hosts, current)
|
|
}
|
|
current = []
|
|
continue
|
|
}
|
|
|
|
if (current.length === 0) {
|
|
continue
|
|
}
|
|
|
|
const value = parseScalarConfigValue(rawValue)
|
|
|
|
switch (key) {
|
|
case 'hostname':
|
|
for (const host of current) {
|
|
host.hostname = value
|
|
}
|
|
break
|
|
case 'port':
|
|
for (const host of current) {
|
|
host.port = Number.parseInt(value, 10) || 22
|
|
}
|
|
break
|
|
case 'user':
|
|
for (const host of current) {
|
|
host.user = value
|
|
}
|
|
break
|
|
case 'identityfile':
|
|
for (const host of current) {
|
|
host.identityFile = resolveSshConfigHomePath(value)
|
|
}
|
|
break
|
|
case 'identityagent':
|
|
for (const host of current) {
|
|
host.identityAgent = resolveSshConfigHomePath(value)
|
|
}
|
|
break
|
|
case 'identitiesonly':
|
|
for (const host of current) {
|
|
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
|
|
// the rest of the line, including quotes and `#` characters.
|
|
host.proxyCommand = rawValue.trim()
|
|
}
|
|
break
|
|
case 'proxyusefdpass':
|
|
for (const host of current) {
|
|
host.proxyUseFdpass = value.toLowerCase() === 'yes'
|
|
}
|
|
break
|
|
case 'proxyjump':
|
|
for (const host of current) {
|
|
host.proxyJump = value
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if (current.length > 0) {
|
|
appendHosts(hosts, current)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
function appendHosts(target: SshConfigHost[], entries: SshConfigHost[]): void {
|
|
// Why: generated SSH configs can put many concrete aliases on one Host line;
|
|
// spreading that block into push can exceed JavaScript's argument limit.
|
|
for (const entry of entries) {
|
|
target.push(entry)
|
|
}
|
|
}
|
|
|
|
function parseConfigDirective(line: string): { key: string; rawValue: string } | null {
|
|
const match = line.match(/^([^=\s]+)(?:\s*=\s*|\s+)(.*)$/)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
key: match[1].toLowerCase(),
|
|
rawValue: match[2].trim()
|
|
}
|
|
}
|
|
|
|
function parseScalarConfigValue(input: string): string {
|
|
// Why: scalar OpenSSH directives strip wrapping quotes and inline comments;
|
|
// keeping them would turn valid config values into bad hostnames/users/paths.
|
|
return splitOpenSshArguments(input)[0] ?? ''
|
|
}
|
|
|
|
function splitHostPatterns(input: string): string[] {
|
|
return splitOpenSshArguments(input)
|
|
}
|
|
|
|
function splitOpenSshArguments(input: string): string[] {
|
|
const args: string[] = []
|
|
let current = ''
|
|
let inQuotes = false
|
|
let escaped = false
|
|
|
|
for (const char of input) {
|
|
if (escaped) {
|
|
current += char
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
if (inQuotes && char === '\\') {
|
|
escaped = true
|
|
continue
|
|
}
|
|
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes
|
|
continue
|
|
}
|
|
|
|
// Why: multi-alias import must not turn OpenSSH inline comments into targets.
|
|
if (!inQuotes && char === '#') {
|
|
break
|
|
}
|
|
|
|
if (!inQuotes && /\s/.test(char)) {
|
|
if (current) {
|
|
args.push(current)
|
|
current = ''
|
|
}
|
|
continue
|
|
}
|
|
|
|
current += char
|
|
}
|
|
|
|
if (current) {
|
|
args.push(current)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
/** Read and parse the user's ~/.ssh/config file. Returns empty array if not found. */
|
|
export function loadUserSshConfig(): SshConfigHost[] {
|
|
const configPath = join(homedir(), '.ssh', 'config')
|
|
if (!existsSync(configPath)) {
|
|
return []
|
|
}
|
|
|
|
try {
|
|
const content = expandSshConfigIncludes(configPath)
|
|
return parseSshConfig(content)
|
|
} catch {
|
|
console.warn(`[ssh] Failed to read SSH config at ${configPath}`)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Convert parsed SSH config hosts into SshTarget objects for import. */
|
|
export function sshConfigHostsToTargets(
|
|
hosts: SshConfigHost[],
|
|
existingTargetHosts: Set<string>
|
|
): SshTarget[] {
|
|
const targets: SshTarget[] = []
|
|
const seenLabels = new Set(existingTargetHosts)
|
|
|
|
for (const entry of hosts) {
|
|
const effectiveHost = entry.hostname || entry.host
|
|
const label = entry.host
|
|
|
|
if (seenLabels.has(label)) {
|
|
continue
|
|
}
|
|
seenLabels.add(label)
|
|
|
|
targets.push({
|
|
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
label,
|
|
configHost: entry.host,
|
|
host: effectiveHost,
|
|
port: entry.port ?? 22,
|
|
username: entry.user ?? '',
|
|
identityFile: entry.identityFile,
|
|
identityAgent: entry.identityAgent,
|
|
identitiesOnly: entry.identitiesOnly,
|
|
gssapiAuthentication: entry.gssapiAuthentication,
|
|
proxyCommand: entry.proxyCommand,
|
|
jumpHost: entry.proxyJump
|
|
})
|
|
}
|
|
|
|
return targets
|
|
}
|