import type { IIdeSshTarget, IRemoteProbeResult } from '@git.zone/ide-protocol'; import * as plugins from './plugins.js'; export interface ISshHostConfig { alias: string; patterns: string[]; hostName?: string; user?: string; port?: number; identityFiles: string[]; proxyJump?: string; forwardAgent?: boolean; raw: Record; } export interface ISshRunOptions { executable?: string; timeoutMs?: number; batchMode?: boolean; cwd?: string; env?: NodeJS.ProcessEnv; } export interface ISshRunResult { exitCode: number; stdout: string; stderr: string; } export interface ISshTunnelOptions extends ISshRunOptions { localHost?: string; localPort: number; remoteHost?: string; remotePort: number; } export interface ISshTunnelHandle { readonly target: IIdeSshTarget; readonly localHost: string; readonly localPort: number; readonly remoteHost: string; readonly remotePort: number; readonly processId: number | undefined; dispose(signal?: NodeJS.Signals): Promise; } const directiveAliases: Record = { hostname: 'hostName', user: 'user', port: 'port', identityfile: 'identityFiles', proxyjump: 'proxyJump', forwardagent: 'forwardAgent', }; export const defaultSshConfigPath = () => plugins.path.join(plugins.os.homedir(), '.ssh', 'config'); export const expandHome = (filePath: string) => { if (filePath === '~') { return plugins.os.homedir(); } if (filePath.startsWith('~/')) { return plugins.path.join(plugins.os.homedir(), filePath.slice(2)); } return filePath; }; export const parseSshConfig = (configText: string): ISshHostConfig[] => { const hosts: ISshHostConfig[] = []; let currentHosts: ISshHostConfig[] = []; for (const rawLine of configText.split(/\r?\n/)) { const line = stripSshComment(rawLine).trim(); if (!line) { continue; } const tokens = tokenizeSshConfigLine(line); if (tokens.length === 0) { continue; } const key = tokens[0]!.toLowerCase(); const values = tokens.slice(1); if (key === 'host') { currentHosts = values.map((alias) => ({ alias, patterns: values, identityFiles: [], raw: {}, })); hosts.push(...currentHosts); continue; } for (const host of currentHosts) { host.raw[key] = [...(host.raw[key] ?? []), ...values]; applySshDirective(host, key, values); } } return hosts; }; export const readSshConfig = async (filePath = defaultSshConfigPath()) => { try { const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8'); return parseSshConfig(configText); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { return []; } throw error; } }; export const listConnectableHosts = (hosts: ISshHostConfig[]) => { return hosts.filter((host) => !isWildcardHostPattern(host.alias) && !host.alias.startsWith('!')); }; export const buildSshDestination = (target: Pick) => { return target.user ? `${target.user}@${target.hostAlias}` : target.hostAlias; }; export const buildSshArgs = ( target: IIdeSshTarget, remoteCommand?: string, options: ISshRunOptions = {}, ) => { const args = buildSshOptionArgs(target, options); args.push(buildSshDestination(target)); if (remoteCommand) { args.push(remoteCommand); } return args; }; export const runSshCommand = async ( target: IIdeSshTarget, remoteCommand: string, options: ISshRunOptions = {}, ): Promise => { const executable = options.executable ?? 'ssh'; const args = buildSshArgs(target, remoteCommand, options); return new Promise((resolve, reject) => { const child = plugins.childProcess.spawn(executable, args, { cwd: options.cwd, env: options.env ?? process.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let finished = false; const timeout = options.timeoutMs ? setTimeout(() => { if (!finished) { child.kill('SIGTERM'); } }, options.timeoutMs) : undefined; child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); child.on('error', (error) => { finished = true; if (timeout) { clearTimeout(timeout); } reject(error); }); child.on('close', (exitCode) => { finished = true; if (timeout) { clearTimeout(timeout); } resolve({ exitCode: exitCode ?? 1, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), }); }); }); }; export const findFreePort = async (host = '127.0.0.1') => { return new Promise((resolve, reject) => { const server = plugins.net.createServer(); server.unref(); server.on('error', reject); server.listen(0, host, () => { const address = server.address(); server.close(() => { if (typeof address === 'object' && address) { resolve(address.port); } else { reject(new Error('Unable to allocate a free local port')); } }); }); }); }; export const startSshTunnel = ( target: IIdeSshTarget, options: ISshTunnelOptions, ): ISshTunnelHandle => { const executable = options.executable ?? 'ssh'; const localHost = options.localHost ?? '127.0.0.1'; const remoteHost = options.remoteHost ?? '127.0.0.1'; const forward = `${localHost}:${options.localPort}:${remoteHost}:${options.remotePort}`; const args = [ ...buildSshOptionArgs(target, options), '-N', '-L', forward, buildSshDestination(target), ]; const child = plugins.childProcess.spawn(executable, args, { cwd: options.cwd, env: options.env ?? process.env, shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, }); return { target, localHost, localPort: options.localPort, remoteHost, remotePort: options.remotePort, processId: child.pid, dispose: async (signal: NodeJS.Signals = 'SIGTERM') => { if (child.exitCode !== null || child.killed) { return; } await new Promise((resolve) => { const timer = setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } resolve(); }, 5000); child.once('close', () => { clearTimeout(timer); resolve(); }); child.kill(signal); }); }, }; }; export const probeRemoteHost = async ( target: IIdeSshTarget, options: ISshRunOptions = {}, ): Promise => { const command = [ `printf 'platform=%s\\n' "$(uname -s 2>/dev/null || printf unknown)"`, `printf 'arch=%s\\n' "$(uname -m 2>/dev/null || printf unknown)"`, `printf 'homeDir=%s\\n' "$HOME"`, `printf 'shell=%s\\n' "$SHELL"`, `printf 'nodeVersion=%s\\n' "$(node --version 2>/dev/null || true)"`, `printf 'pnpmVersion=%s\\n' "$(pnpm --version 2>/dev/null || true)"`, `printf 'gitVersion=%s\\n' "$(git --version 2>/dev/null || true)"`, `printf 'opencodeVersion=%s\\n' "$(opencode --version 2>/dev/null || true)"`, ].join('; '); const result = await runSshCommand(target, command, options); const fields = parseKeyValueLines(result.stdout); const errors: string[] = []; if (result.exitCode !== 0) { errors.push(result.stderr || `ssh exited with code ${result.exitCode}`); } return { ok: result.exitCode === 0, hostAlias: target.hostAlias, platform: fields.platform, arch: fields.arch, homeDir: fields.homeDir, shell: fields.shell, nodeVersion: fields.nodeVersion, pnpmVersion: fields.pnpmVersion, gitVersion: fields.gitVersion, opencodeVersion: fields.opencodeVersion, errors, }; }; const applySshDirective = (host: ISshHostConfig, key: string, values: string[]) => { const targetKey = directiveAliases[key]; if (!targetKey || values.length === 0) { return; } if (targetKey === 'identityFiles') { host.identityFiles.push(...values.map(expandHome)); return; } if (targetKey === 'port') { const port = Number(values[0]); if (Number.isInteger(port) && port > 0) { host.port = port; } return; } if (targetKey === 'forwardAgent') { host.forwardAgent = /^(yes|true)$/i.test(values[0]!); return; } if (targetKey === 'hostName') { host.hostName = values.join(' '); } else if (targetKey === 'user') { host.user = values.join(' '); } else if (targetKey === 'proxyJump') { host.proxyJump = values.join(' '); } }; const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {}) => { const args: string[] = []; if (options.batchMode !== false) { args.push('-o', 'BatchMode=yes'); } args.push('-o', 'ServerAliveInterval=30'); args.push('-o', 'ServerAliveCountMax=3'); if (target.port) { args.push('-p', `${target.port}`); } return args; }; const stripSshComment = (line: string) => { let quote: string | undefined; for (let index = 0; index < line.length; index++) { const character = line[index]; if ((character === '"' || character === "'") && line[index - 1] !== '\\') { quote = quote === character ? undefined : character; continue; } if (character === '#' && !quote) { return line.slice(0, index); } } return line; }; const tokenizeSshConfigLine = (line: string) => { const tokens: string[] = []; let current = ''; let quote: string | undefined; for (let index = 0; index < line.length; index++) { const character = line[index]!; if ((character === '"' || character === "'") && line[index - 1] !== '\\') { quote = quote === character ? undefined : character; continue; } if (/\s/.test(character) && !quote) { if (current) { tokens.push(current); current = ''; } continue; } current += character; } if (current) { tokens.push(current); } return tokens; }; const isWildcardHostPattern = (alias: string) => /[*?!]/.test(alias); const parseKeyValueLines = (text: string) => { const fields: Record = {}; for (const line of text.split(/\r?\n/)) { const separator = line.indexOf('='); if (separator === -1) { continue; } fields[line.slice(0, separator)] = line.slice(separator + 1); } return fields; };