mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
227 lines
6.1 KiB
TypeScript
227 lines
6.1 KiB
TypeScript
import { readFileSync, existsSync } from 'fs'
|
|
import { execFile } from 'child_process'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
|
|
export type SshConfigHost = {
|
|
host: string
|
|
hostname?: string
|
|
port?: number
|
|
user?: string
|
|
identityFile?: string
|
|
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 | null = null
|
|
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.trim()
|
|
if (!line || line.startsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
const match = line.match(/^(\S+)\s+(.+)$/)
|
|
if (!match) {
|
|
continue
|
|
}
|
|
|
|
const [, keyword, rawValue] = match
|
|
const key = keyword.toLowerCase()
|
|
const value = rawValue.trim()
|
|
|
|
if (key === 'host') {
|
|
if (current) {
|
|
hosts.push(current)
|
|
}
|
|
|
|
// Skip wildcard-only entries (e.g. "Host *" or "Host *.*")
|
|
const patterns = value.split(/\s+/)
|
|
const hasConcretePattern = patterns.some((p) => !p.includes('*') && !p.includes('?'))
|
|
if (!hasConcretePattern) {
|
|
current = null
|
|
continue
|
|
}
|
|
|
|
current = { host: patterns[0] }
|
|
continue
|
|
}
|
|
|
|
if (key === 'match') {
|
|
// Match blocks are complex conditionals — push current and skip
|
|
if (current) {
|
|
hosts.push(current)
|
|
}
|
|
current = null
|
|
continue
|
|
}
|
|
|
|
if (!current) {
|
|
continue
|
|
}
|
|
|
|
switch (key) {
|
|
case 'hostname':
|
|
current.hostname = value
|
|
break
|
|
case 'port':
|
|
current.port = parseInt(value, 10) || 22
|
|
break
|
|
case 'user':
|
|
current.user = value
|
|
break
|
|
case 'identityfile':
|
|
current.identityFile = resolveHomePath(value)
|
|
break
|
|
case 'proxycommand':
|
|
current.proxyCommand = value
|
|
break
|
|
case 'proxyusefdpass':
|
|
current.proxyUseFdpass = value.toLowerCase() === 'yes'
|
|
break
|
|
case 'proxyjump':
|
|
current.proxyJump = value
|
|
break
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
hosts.push(current)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
function resolveHomePath(filepath: string): string {
|
|
if (filepath.startsWith('~/') || filepath === '~') {
|
|
return join(homedir(), filepath.slice(1))
|
|
}
|
|
return filepath
|
|
}
|
|
|
|
/** 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 = readFileSync(configPath, 'utf-8')
|
|
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[] = []
|
|
|
|
for (const entry of hosts) {
|
|
const effectiveHost = entry.hostname || entry.host
|
|
const label = entry.host
|
|
|
|
// Skip if already imported (match on label, which is the Host alias)
|
|
if (existingTargetHosts.has(label)) {
|
|
continue
|
|
}
|
|
|
|
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,
|
|
proxyCommand: entry.proxyCommand,
|
|
jumpHost: entry.proxyJump
|
|
})
|
|
}
|
|
|
|
return targets
|
|
}
|
|
|
|
// ── ssh -G config resolution ──────────────────────────────────────────
|
|
|
|
export type SshResolvedConfig = {
|
|
hostname: string
|
|
user?: string
|
|
port: number
|
|
identityFile: string[]
|
|
forwardAgent: boolean
|
|
proxyCommand?: string
|
|
proxyUseFdpass: boolean
|
|
proxyJump?: string
|
|
}
|
|
|
|
const SSH_G_TIMEOUT_MS = 5000
|
|
|
|
// Why: `ssh -G <host>` asks OpenSSH to resolve the full effective config
|
|
// for a host, including Include directives, Match blocks, wildcard
|
|
// inheritance, and ProxyCommand expansion. This gives us correct config
|
|
// resolution without reimplementing OpenSSH's complex matching logic.
|
|
export function resolveWithSshG(host: string): Promise<SshResolvedConfig | null> {
|
|
return new Promise((resolve) => {
|
|
// Why: '--' prevents a host label starting with '-' from being interpreted
|
|
// as an SSH flag (classic argument injection vector).
|
|
execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => {
|
|
if (err) {
|
|
resolve(null)
|
|
return
|
|
}
|
|
resolve(parseSshGOutput(stdout))
|
|
})
|
|
})
|
|
}
|
|
|
|
export function parseSshGOutput(stdout: string): SshResolvedConfig {
|
|
const map = new Map<string, string>()
|
|
const identityFiles: string[] = []
|
|
|
|
for (const line of stdout.split('\n')) {
|
|
const spaceIdx = line.indexOf(' ')
|
|
if (spaceIdx === -1) {
|
|
continue
|
|
}
|
|
const key = line.substring(0, spaceIdx).toLowerCase()
|
|
const value = line.substring(spaceIdx + 1).trim()
|
|
if (key === 'identityfile') {
|
|
identityFiles.push(resolveHomePath(value))
|
|
} else {
|
|
map.set(key, value)
|
|
}
|
|
}
|
|
|
|
// Why: `ssh -G` outputs `proxycommand none` / `proxyjump none` when no
|
|
// proxy is configured. Treating "none" as real would spawn bad commands.
|
|
const rawProxy = map.get('proxycommand')
|
|
const proxyCommand = rawProxy && rawProxy !== 'none' ? rawProxy : undefined
|
|
const rawJump = map.get('proxyjump')
|
|
const proxyJump = rawJump && rawJump !== 'none' ? rawJump : undefined
|
|
|
|
return {
|
|
hostname: map.get('hostname') ?? '',
|
|
user: map.get('user') || undefined,
|
|
port: parseInt(map.get('port') ?? '22', 10),
|
|
identityFile: identityFiles,
|
|
forwardAgent: map.get('forwardagent') === 'yes',
|
|
proxyCommand,
|
|
proxyUseFdpass: map.get('proxyusefdpass') === 'yes',
|
|
proxyJump
|
|
}
|
|
}
|