/** * SshConfigParser - Parses ~/.ssh/config to resolve host aliases. * * Responsibilities: * - Parse SSH config with Include directive support * - Return all defined Host aliases (excluding wildcards) * - Resolve alias to HostName, Port, User, IdentityFile * - Gracefully handle missing/unreadable files */ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import SSHConfig from 'ssh-config'; import type { SshConfigHostEntry } from '@shared/types'; const logger = createLogger('Infrastructure:SshConfigParser'); export class SshConfigParser { private configPath: string; constructor(configPath?: string) { this.configPath = configPath ?? path.join(os.homedir(), '.ssh', 'config'); } /** * Returns all defined Host aliases (excluding `*` wildcards and patterns). */ async getHosts(): Promise { try { const config = await this.parseConfig(); if (!config) return []; const entries: SshConfigHostEntry[] = []; for (const section of config) { if (section.type !== SSHConfig.DIRECTIVE) continue; if (section.param !== 'Host') continue; const hostValue = section.value; if (typeof hostValue !== 'string') continue; // Skip wildcard-only entries and patterns with * or ? const aliases = hostValue.split(/\s+/).filter((h) => !h.includes('*') && !h.includes('?')); for (const alias of aliases) { const resolved = this.resolveFromConfig(config, alias); entries.push(resolved); } } return entries; } catch (err) { logger.error('Failed to get SSH config hosts:', err); return []; } } /** * Resolves a host alias to its SSH config values. * Returns null if the alias is not found in config. */ async resolveHost(alias: string): Promise { try { const config = await this.parseConfig(); if (!config) return null; const resolved = this.resolveFromConfig(config, alias); // If nothing was resolved beyond the alias itself, check if host was actually defined if (!resolved.hostName && !resolved.user && !resolved.port && !resolved.hasIdentityFile) { // Check if there's an explicit Host entry for this alias const hasEntry = config.some( (section) => section.type === SSHConfig.DIRECTIVE && section.param === 'Host' && typeof section.value === 'string' && section.value.split(/\s+/).includes(alias) ); if (!hasEntry) return null; } return resolved; } catch (err) { logger.error(`Failed to resolve SSH host "${alias}":`, err); return null; } } // =========================================================================== // Private Methods // =========================================================================== private resolveFromConfig(config: SSHConfig, alias: string): SshConfigHostEntry { const computed = config.compute(alias); const rawHostName = computed.HostName; const hostName = Array.isArray(rawHostName) ? rawHostName[0] : rawHostName; const rawUser = computed.User; const user = Array.isArray(rawUser) ? rawUser[0] : (rawUser ?? undefined); const portStr = computed.Port; const port = portStr ? parseInt(String(portStr), 10) : undefined; const identityFile = computed.IdentityFile; const hasIdentityFile = Array.isArray(identityFile) ? identityFile.length > 0 : identityFile != null; return { alias, hostName: hostName && hostName !== alias ? hostName : undefined, user, port: port && port !== 22 ? port : undefined, hasIdentityFile, }; } private async parseConfig(): Promise { try { let content = await fs.promises.readFile(this.configPath, 'utf8'); // Process Include directives by expanding them inline content = await this.expandIncludes(content); return SSHConfig.parse(content); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { logger.info('No SSH config file found at', this.configPath); } else { logger.error('Failed to parse SSH config:', err); } return null; } } private async expandIncludes(content: string): Promise { const lines = content.split('\n'); const result: string[] = []; for (const line of lines) { const trimmed = line.trim(); const match = trimmed.startsWith('Include ') || trimmed.startsWith('include ') ? /^[Ii]nclude\s+(\S.*)$/.exec(trimmed) : null; if (!match) { result.push(line); continue; } const pattern = match[1].trim(); const expandedPattern = pattern.replace(/^~/, os.homedir()); try { // Handle glob-like patterns by checking if the path contains wildcards if (expandedPattern.includes('*') || expandedPattern.includes('?')) { const dir = path.dirname(expandedPattern); const globPart = path.basename(expandedPattern); const files = await this.globFiles(dir, globPart); for (const file of files) { try { const included = await fs.promises.readFile(file, 'utf8'); result.push(included); } catch { // Skip unreadable included files } } } else { const included = await fs.promises.readFile(expandedPattern, 'utf8'); result.push(included); } } catch { // Skip unresolvable includes } } return result.join('\n'); } private async globFiles(dir: string, pattern: string): Promise { try { const entries = await fs.promises.readdir(dir); const regex = new RegExp( '^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$' ); return entries.filter((e) => regex.test(e)).map((e) => path.join(dir, e)); } catch { return []; } } }