/** * 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; STATUS_CODE: Record } }).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; /** 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; /** Hard-shutdown: destroy every live client, then close the listener. */ forceClose(): Promise; } /** 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 { // utils.parseKey accepts the OpenSSH single-line format: " ". 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 { const hostKeyPem = args.hostKeyPem ?? generateEd25519Pair().privatePem; const execHandler = args.exec ?? defaultEcho(); const shellHandler = args.shell; const files = new Map(); 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(); 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((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((resolve) => server.close(() => resolve())), forceClose: () => new Promise((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): void { const handles = new Map(); 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); }); }