Files
ide/packages/ssh/ts/index.ts
T
2026-05-10 14:08:25 +00:00

398 lines
10 KiB
TypeScript

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<string, string[]>;
}
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<void>;
}
const directiveAliases: Record<string, keyof ISshHostConfig | 'identityFiles'> = {
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<IIdeSshTarget, 'hostAlias' | 'user'>) => {
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<ISshRunResult> => {
const executable = options.executable ?? 'ssh';
const args = buildSshArgs(target, remoteCommand, options);
return new Promise<ISshRunResult>((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<number>((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<void>((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<IRemoteProbeResult> => {
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<string, string> = {};
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;
};