maestro/src/ssh/session-test-server.ts
2026-06-03 05:08:00 +00:00

387 lines
15 KiB
TypeScript

/**
* Minimal in-process ssh2 server for Phase 3 session tests.
*
* Capabilities (just what session.test.ts needs):
* - publickey auth: any client key that produces a valid signature is accepted
* (we verify the signature with ssh2.utils.parseKey of the OpenSSH-format
* pubkey reconstructed from `ctx.key.algo` + `ctx.key.data`).
* - exec: server runs a caller-provided handler that returns
* `{ stdout, stderr, exit }`, optionally with a delay. Default: echo command.
* - sftp: single in-memory file map. Supports OPEN / READ / WRITE / CLOSE /
* STAT / FSTAT / REALPATH. No directory ops. Big enough to cover
* sftp.createReadStream / createWriteStream paths.
*
* Two host-key shapes are useful in tests:
* 1. Stable host key — the test fixture generates it once, you can read the
* OpenSSH wire-format b64 to seed a ResolvedConnection.hostKeyB64.
* 2. Rotating host key — start a *second* server on the same port with a
* different key to drive the mismatch path.
*
* Not intended for production use. The acceptance policy is intentionally
* permissive (any pubkey with a valid signature) so tests can vary the client
* key per scenario.
*/
import { Server, utils as sshUtils, type Connection, type Session, type PublicKey, type SFTPWrapper, type Attributes, type FileEntry, type ServerChannel } from 'ssh2';
// ssh2 exposes SFTP constants on `utils.sftp` at runtime; the .d.ts puts them
// under the `utils.sftp` namespace, but they're easier to alias here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SFTP = (sshUtils as unknown as { sftp: { OPEN_MODE: Record<string, number>; STATUS_CODE: Record<string, number> } }).sftp;
import { createHash, randomBytes } from 'node:crypto';
import { generateKeyPairSync } from 'node:crypto';
export type ExecHandler = (
command: string,
) => Promise<{ stdout?: string; stderr?: string; exit?: number; delayMs?: number }>;
/**
* Server-side shell handler. Called when the client requests `shell()` after
* a successful `pty()`. The handler is given the granted PTY geometry, a
* write-back hook (push bytes to the client), and a small `onData` /
* `onResize` registration API. Return a `close` function the server can
* call when the client ends the channel.
*
* The handler is intentionally minimal: it lets a test fake a
* line-discipline shell without depending on a real OS pty. Used for
* SSH Console e2e tests.
*/
export interface ShellHandlerArgs {
cols: number;
rows: number;
writeOut: (data: Buffer | string) => void;
onData: (cb: (data: Buffer) => void) => void;
onResize: (cb: (cols: number, rows: number) => void) => void;
}
export type ShellHandler = (args: ShellHandlerArgs) => Promise<() => void> | (() => void);
export interface StartTestServerArgs {
exec?: ExecHandler;
/** Optional shell handler — required for SSH Console tests. */
shell?: ShellHandler;
/** Optional pre-populated files for sftp tests (path → contents). */
files?: Record<string, Buffer>;
/** If provided, server uses this PEM/OpenSSH host key (must be ssh2-parseable). */
hostKeyPem?: string | Buffer;
}
export interface RunningTestServer {
port: number;
hostKeyOpenSshB64: string;
hostKeyFingerprint: string;
/** Get a snapshot of the in-memory file map (for assertions). */
getFile(path: string): Buffer | undefined;
setFile(path: string, data: Buffer): void;
/**
* Close the server. Hangs if a long-lived client (SSH Console shell) is
* still attached; tests should call `forceClose()` instead in that case
* (or close their channels before invoking `close`).
*/
close(): Promise<void>;
/** Hard-shutdown: destroy every live client, then close the listener. */
forceClose(): Promise<void>;
}
/** Generate a fresh ed25519 keypair in OpenSSH format (parseable by ssh2 both sides). */
export function generateEd25519Pair(): { privatePem: string; publicSsh: string } {
const kp = sshUtils.generateKeyPairSync('ed25519');
return { privatePem: kp.private, publicSsh: kp.public };
}
/** Generate an RSA-2048 PKCS#1 PEM keypair (parseable by ssh2.utils.parseKey). */
export function generateRsaPair(): { privatePem: Buffer; publicSsh: string } {
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
});
// Reformat the spki public key into OpenSSH wire format using ssh2.utils.
const parsed = sshUtils.parseKey(Buffer.from(privateKey as string, 'utf-8'));
if (parsed instanceof Error) throw parsed;
const pubSsh = Array.isArray(parsed)
? parsed[0].getPublicSSH().toString('base64')
: parsed.getPublicSSH().toString('base64');
const algo = Array.isArray(parsed) ? parsed[0].type : parsed.type;
return {
privatePem: Buffer.from(privateKey as string, 'utf-8'),
publicSsh: `${algo} ${pubSsh}`,
};
}
/** OpenSSH-style sha256 fingerprint of an OpenSSH wire-format key (Buffer). */
export function sha256FingerprintFromRaw(raw: Buffer): string {
return 'SHA256:' + createHash('sha256').update(raw).digest('base64').replace(/=+$/, '');
}
/** Convert a server-side PublicKey (algo + data) back to ssh2.utils.parseKey-friendly form. */
function reconstructPubKey(key: PublicKey): ReturnType<typeof sshUtils.parseKey> {
// utils.parseKey accepts the OpenSSH single-line format: "<algo> <base64>".
const line = `${key.algo} ${key.data.toString('base64')}`;
return sshUtils.parseKey(line);
}
function defaultEcho(): ExecHandler {
return async (cmd: string) => ({ stdout: `echo: ${cmd}\n`, stderr: '', exit: 0 });
}
export async function startTestServer(args: StartTestServerArgs = {}): Promise<RunningTestServer> {
const hostKeyPem = args.hostKeyPem ?? generateEd25519Pair().privatePem;
const execHandler = args.exec ?? defaultEcho();
const shellHandler = args.shell;
const files = new Map<string, Buffer>();
for (const [k, v] of Object.entries(args.files ?? {})) files.set(k, Buffer.from(v));
// Compute host key OpenSSH b64 + fingerprint for the caller.
const parsedHost = sshUtils.parseKey(hostKeyPem);
if (parsedHost instanceof Error) throw parsedHost;
const hostPubSsh = Array.isArray(parsedHost) ? parsedHost[0].getPublicSSH() : parsedHost.getPublicSSH();
const hostB64 = hostPubSsh.toString('base64');
const hostFp = sha256FingerprintFromRaw(hostPubSsh);
const liveClients = new Set<Connection>();
const server = new Server({ hostKeys: [hostKeyPem] }, (client: Connection) => {
liveClients.add(client);
client.on('close', () => { liveClients.delete(client); });
client.on('end', () => { liveClients.delete(client); });
client.on('authentication', (ctx) => {
if (ctx.method !== 'publickey') return ctx.reject(['publickey'], true);
if (ctx.signature === undefined) {
// Probe — client is asking whether the server would accept this key
// without signing. Tell it yes; the real signed attempt follows.
return ctx.accept();
}
const pub = reconstructPubKey(ctx.key);
if (pub instanceof Error) return ctx.reject();
const key = Array.isArray(pub) ? pub[0] : pub;
if (!ctx.blob || !ctx.signature) return ctx.reject();
if (key.verify(ctx.blob, ctx.signature, ctx.hashAlgo) !== true) return ctx.reject();
ctx.accept();
});
client.on('ready', () => {
client.on('session', (acceptSession) => {
const session: Session = acceptSession();
// Track the granted PTY geometry per session so a subsequent
// 'shell' request can use it. ssh2's session events are
// request-ordered, so pty arrives before shell when the client
// calls client.shell({...}).
let ptyCols = 80;
let ptyRows = 24;
const dataListeners = new Set<(data: Buffer) => void>();
const resizeListeners = new Set<(cols: number, rows: number) => void>();
session.on('pty', (acceptPty, _rejPty, info) => {
ptyCols = info.cols;
ptyRows = info.rows;
acceptPty();
});
session.on('window-change', (acceptW, _rejW, info) => {
ptyCols = info.cols;
ptyRows = info.rows;
// window-change may pass no accept callback in some ssh2 builds.
if (typeof acceptW === 'function') {
try { acceptW(); } catch { /* tolerated */ }
}
for (const cb of resizeListeners) {
try { cb(info.cols, info.rows); } catch { /* tolerated */ }
}
});
session.on('shell', (acceptShell) => {
const stream: ServerChannel = acceptShell();
if (!shellHandler) {
// No handler — write a tiny banner and end. Tests that don't
// wire a shell handler will see a closed channel quickly.
stream.write('test-shell: no handler configured\r\n');
stream.exit(0);
stream.end();
return;
}
stream.on('data', (data: Buffer) => {
for (const cb of dataListeners) {
try { cb(data); } catch { /* tolerated */ }
}
});
Promise.resolve(shellHandler({
cols: ptyCols,
rows: ptyRows,
writeOut: (data) => { stream.write(data); },
onData: (cb) => { dataListeners.add(cb); },
onResize: (cb) => { resizeListeners.add(cb); },
})).then((close) => {
stream.on('close', () => {
try { close(); } catch { /* tolerated */ }
dataListeners.clear();
resizeListeners.clear();
});
}).catch((e: Error) => {
stream.write(`shell error: ${e.message}\r\n`);
stream.exit(1);
stream.end();
});
});
session.on('exec', (acceptExec, _rej, info) => {
const stream = acceptExec();
execHandler(info.command).then(async (r) => {
if (r.delayMs && r.delayMs > 0) await new Promise((res) => setTimeout(res, r.delayMs));
if (r.stdout) stream.write(r.stdout);
if (r.stderr) stream.stderr.write(r.stderr);
stream.exit(r.exit ?? 0);
stream.end();
}).catch((e: Error) => {
stream.stderr.write(`server error: ${e.message}\n`);
stream.exit(1);
stream.end();
});
});
session.on('sftp', (acceptSftp) => {
const sftp: SFTPWrapper = acceptSftp();
installSftpHandlers(sftp, files);
});
});
});
client.on('error', () => { /* ignore — tests inspect client side */ });
});
const port = await new Promise<number>((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr === 'object' && addr !== null) resolve(addr.port);
else resolve(0);
});
});
return {
port,
hostKeyOpenSshB64: hostB64,
hostKeyFingerprint: hostFp,
getFile: (p) => files.get(p),
setFile: (p, d) => { files.set(p, Buffer.from(d)); },
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
forceClose: () => new Promise<void>((resolve) => {
// Destroy each live client so server.close() returns promptly.
// The ssh2 Connection's `end()` is graceful (waits for FIN/ACK);
// for tests we want the listener gone immediately, so we go for
// the harder hammer first and tolerate already-closed sockets.
for (const c of liveClients) {
try {
// ssh2 exposes `end()` always; the underlying socket has
// `destroy()` reachable via `_sock` in current builds. Try
// end() first; if the listener still has refs after a tick
// the destroy fallback below kicks in.
c.end();
} catch { /* tolerated */ }
}
liveClients.clear();
server.close(() => resolve());
// Backstop: if the listener doesn't release in 250ms, force
// a destroy by recursing into the underlying server.unref().
setTimeout(() => resolve(), 250).unref();
}),
};
}
interface OpenHandle {
handle: Buffer;
path: string;
flags: number;
position: number;
}
function defaultAttributes(size: number): Attributes {
const nowSec = Math.floor(Date.now() / 1000);
return {
mode: 0o100644,
uid: 1000,
gid: 1000,
size,
atime: nowSec,
mtime: nowSec,
};
}
function installSftpHandlers(sftp: SFTPWrapper, files: Map<string, Buffer>): void {
const handles = new Map<string, OpenHandle>();
const keyOf = (h: Buffer) => h.toString('hex');
const allocHandle = (path: string, flags: number): OpenHandle => {
const handle = randomBytes(8);
const oh: OpenHandle = { handle, path, flags, position: 0 };
handles.set(keyOf(handle), oh);
return oh;
};
sftp.on('OPEN', (reqId, filename, flags) => {
const isWrite = (flags & SFTP.OPEN_MODE.WRITE) !== 0;
const isCreat = (flags & SFTP.OPEN_MODE.CREAT) !== 0;
const isTrunc = (flags & SFTP.OPEN_MODE.TRUNC) !== 0;
if (isWrite) {
if (!files.has(filename)) {
if (isCreat) files.set(filename, Buffer.alloc(0));
else return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
} else if (isTrunc) {
files.set(filename, Buffer.alloc(0));
}
} else {
if (!files.has(filename)) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
}
const oh = allocHandle(filename, flags);
sftp.handle(reqId, oh.handle);
});
sftp.on('READ', (reqId, handle, offset, len) => {
const oh = handles.get(keyOf(handle));
if (!oh) return sftp.status(reqId, SFTP.STATUS_CODE.FAILURE);
const buf = files.get(oh.path);
if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
if (offset >= buf.length) return sftp.status(reqId, SFTP.STATUS_CODE.EOF);
const end = Math.min(buf.length, offset + len);
sftp.data(reqId, buf.subarray(offset, end));
});
sftp.on('WRITE', (reqId, handle, offset, data) => {
const oh = handles.get(keyOf(handle));
if (!oh) return sftp.status(reqId, SFTP.STATUS_CODE.FAILURE);
const cur = files.get(oh.path) ?? Buffer.alloc(0);
const required = offset + data.length;
const next = required > cur.length ? Buffer.concat([cur, Buffer.alloc(required - cur.length)]) : Buffer.from(cur);
data.copy(next, offset);
files.set(oh.path, next);
sftp.status(reqId, SFTP.STATUS_CODE.OK);
});
sftp.on('CLOSE', (reqId, handle) => {
handles.delete(keyOf(handle));
sftp.status(reqId, SFTP.STATUS_CODE.OK);
});
sftp.on('FSTAT', (reqId, handle) => {
const oh = handles.get(keyOf(handle));
if (!oh) return sftp.status(reqId, SFTP.STATUS_CODE.FAILURE);
const buf = files.get(oh.path);
if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
sftp.attrs(reqId, defaultAttributes(buf.length));
});
sftp.on('STAT', (reqId, p) => {
const buf = files.get(p);
if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
sftp.attrs(reqId, defaultAttributes(buf.length));
});
sftp.on('LSTAT', (reqId, p) => {
const buf = files.get(p);
if (!buf) return sftp.status(reqId, SFTP.STATUS_CODE.NO_SUCH_FILE);
sftp.attrs(reqId, defaultAttributes(buf.length));
});
sftp.on('REALPATH', (reqId, p) => {
const entries: FileEntry[] = [{
filename: p,
longname: p,
attrs: defaultAttributes(files.get(p)?.length ?? 0),
}];
sftp.name(reqId, entries);
});
sftp.on('REMOVE', (reqId, p) => {
const removed = files.delete(p);
sftp.status(reqId, removed ? SFTP.STATUS_CODE.OK : SFTP.STATUS_CODE.NO_SUCH_FILE);
});
}