387 lines
15 KiB
TypeScript
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);
|
|
});
|
|
}
|