mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
|
import type { SshHostConfig } from "../src/config.ts";
|
|
import { fingerprintHostKey, probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
|
|
|
|
function fixtureKey(label = "fixture"): Buffer {
|
|
const algorithm = Buffer.from("ssh-ed25519");
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(algorithm.length);
|
|
return Buffer.concat([length, algorithm, Buffer.from(label)]);
|
|
}
|
|
|
|
class FakeChannel extends EventEmitter {
|
|
stderr = new EventEmitter();
|
|
closeCalls = 0;
|
|
signals: string[] = [];
|
|
close(): void { this.closeCalls += 1; }
|
|
signal(value: string): void { this.signals.push(value); }
|
|
}
|
|
|
|
type ConnectAction = (config: ConnectConfig, client: FakeClient) => void;
|
|
type ExecAction = (command: string, callback: (error: Error | undefined, channel: ClientChannel) => void) => void;
|
|
|
|
class FakeClient extends EventEmitter {
|
|
connectConfig?: ConnectConfig;
|
|
command?: string;
|
|
channel?: FakeChannel;
|
|
sftpCalls = 0;
|
|
ended = false;
|
|
destroyed = false;
|
|
sftpValue?: SFTPWrapper;
|
|
connectAction?: ConnectAction;
|
|
execAction?: ExecAction;
|
|
|
|
connect(config: ConnectConfig): this {
|
|
this.connectConfig = config;
|
|
queueMicrotask(() => {
|
|
if (this.connectAction) this.connectAction(config, this);
|
|
else this.emit("ready");
|
|
});
|
|
return this;
|
|
}
|
|
|
|
exec(command: string, callback: (error: Error | undefined, channel: ClientChannel) => void): void {
|
|
this.command = command;
|
|
if (this.execAction) {
|
|
this.execAction(command, callback);
|
|
return;
|
|
}
|
|
const channel = new FakeChannel();
|
|
this.channel = channel;
|
|
callback(undefined, channel as ClientChannel);
|
|
queueMicrotask(() => {
|
|
channel.emit("data", Buffer.from("stdout\n"));
|
|
channel.stderr.emit("data", Buffer.from("stderr\n"));
|
|
channel.emit("close", 7);
|
|
});
|
|
}
|
|
|
|
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void {
|
|
this.sftpCalls += 1;
|
|
if (!this.sftpValue) callback(new Error("SFTP unavailable"), undefined as unknown as SFTPWrapper);
|
|
else callback(undefined, this.sftpValue);
|
|
}
|
|
|
|
end(): this {
|
|
this.ended = true;
|
|
this.emit("close");
|
|
return this;
|
|
}
|
|
|
|
destroy(): this {
|
|
this.destroyed = true;
|
|
return this;
|
|
}
|
|
}
|
|
|
|
class FakeSftp {
|
|
readonly files = new Map<string, Buffer>();
|
|
readonly directories = new Set<string>(["/"]);
|
|
readonly openCalls: Array<{ path: string; flags: string }> = [];
|
|
readonly writes: string[] = [];
|
|
readonly renames: Array<{ from: string; to: string; atomic: boolean }> = [];
|
|
readonly unlinks: string[] = [];
|
|
failStandardRename = false;
|
|
ext_openssh_rename?: (from: string, to: string, callback: (error?: Error) => void) => void;
|
|
|
|
constructor(atomicRename = true) {
|
|
if (atomicRename) {
|
|
this.ext_openssh_rename = (from, to, callback) => {
|
|
this.renames.push({ from, to, atomic: true });
|
|
const value = this.files.get(from);
|
|
if (!value) { callback(new Error("source missing")); return; }
|
|
this.files.set(to, value);
|
|
this.files.delete(from);
|
|
callback();
|
|
};
|
|
}
|
|
}
|
|
|
|
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void {
|
|
const value = this.files.get(path);
|
|
if (!value) callback(new Error(`missing file: ${path}`), Buffer.alloc(0));
|
|
else callback(undefined, Buffer.from(value));
|
|
}
|
|
|
|
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void {
|
|
this.writes.push(path);
|
|
this.files.set(path, Buffer.from(data));
|
|
callback();
|
|
}
|
|
|
|
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void {
|
|
this.openCalls.push({ path, flags });
|
|
if (!this.files.has(path)) callback(new Error(`cannot open: ${path}`), Buffer.alloc(0));
|
|
else callback(undefined, Buffer.from(path));
|
|
}
|
|
|
|
close(_handle: Buffer, callback: (error?: Error) => void): void { callback(); }
|
|
|
|
mkdir(path: string, callback: (error?: Error) => void): void {
|
|
if (this.directories.has(path)) { callback(new Error("already exists")); return; }
|
|
this.directories.add(path);
|
|
callback();
|
|
}
|
|
|
|
stat(path: string, callback: (error: Error | undefined, stats: object) => void): void {
|
|
if (this.directories.has(path)) callback(undefined, {});
|
|
else callback(new Error(`missing directory: ${path}`), {});
|
|
}
|
|
|
|
rename(from: string, to: string, callback: (error?: Error) => void): void {
|
|
this.renames.push({ from, to, atomic: false });
|
|
if (this.failStandardRename) { callback(new Error("replace unsupported")); return; }
|
|
const value = this.files.get(from);
|
|
if (!value) { callback(new Error("source missing")); return; }
|
|
this.files.set(to, value);
|
|
this.files.delete(from);
|
|
callback();
|
|
}
|
|
|
|
unlink(path: string, callback: (error?: Error) => void): void {
|
|
this.unlinks.push(path);
|
|
this.files.delete(path);
|
|
callback();
|
|
}
|
|
}
|
|
|
|
function passwordHost(method: "auto" | "password" | "keyboard-interactive" = "auto"): SshHostConfig {
|
|
return {
|
|
hostName: "host.example.test",
|
|
user: "builder",
|
|
port: 2222,
|
|
auth: { type: "password", password: "secret", method },
|
|
hostKey: fingerprintHostKey(fixtureKey()),
|
|
};
|
|
}
|
|
|
|
async function connectedTransport(
|
|
sftp?: FakeSftp,
|
|
host: SshHostConfig = passwordHost(),
|
|
): Promise<{ fake: FakeClient; transport: Ssh2Transport }> {
|
|
const fake = new FakeClient();
|
|
if (sftp) fake.sftpValue = sftp as unknown as SFTPWrapper;
|
|
const transport = new Ssh2Transport(host, fake as unknown as Client);
|
|
await transport.connect();
|
|
return { fake, transport };
|
|
}
|
|
|
|
test("connects with password auth, pins the host key, and streams exec output", async () => {
|
|
const { fake, transport } = await connectedTransport();
|
|
assert.equal(fake.connectConfig?.host, "host.example.test");
|
|
assert.equal(fake.connectConfig?.username, "builder");
|
|
assert.equal(fake.connectConfig?.password, "secret");
|
|
assert.equal(fake.connectConfig?.tryKeyboard, true);
|
|
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey()), true);
|
|
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
|
|
|
|
const output: Buffer[] = [];
|
|
const stderr: Buffer[] = [];
|
|
const result = await transport.exec("printf ok", "/srv/build", {
|
|
onData: (data) => output.push(data),
|
|
onStderr: (data) => stderr.push(data),
|
|
timeout: 5,
|
|
});
|
|
assert.equal(result.exitCode, 7);
|
|
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\n");
|
|
assert.equal(Buffer.concat(stderr).toString("utf8"), "stderr\n");
|
|
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
|
|
await transport.dispose();
|
|
assert.equal(fake.ended, true);
|
|
});
|
|
|
|
test("supports password-only and keyboard-interactive authentication", async () => {
|
|
const passwordOnly = await connectedTransport(undefined, passwordHost("password"));
|
|
assert.equal(passwordOnly.fake.connectConfig?.tryKeyboard, false);
|
|
await passwordOnly.transport.dispose();
|
|
|
|
const interactive = await connectedTransport(undefined, passwordHost("keyboard-interactive"));
|
|
let answers: string[] = [];
|
|
interactive.fake.emit(
|
|
"keyboard-interactive",
|
|
"name",
|
|
"instructions",
|
|
"",
|
|
[{ prompt: "Password:" }, { prompt: "Again:" }],
|
|
(values: string[]) => { answers = values; },
|
|
);
|
|
assert.deepEqual(answers, ["secret", "secret"]);
|
|
await interactive.transport.dispose();
|
|
});
|
|
|
|
test("loads private-key authentication and forwards the passphrase", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "pi-ssh-key-"));
|
|
try {
|
|
const identityFile = join(root, "id_test");
|
|
writeFileSync(identityFile, "PRIVATE KEY FIXTURE", { mode: 0o600 });
|
|
const host: SshHostConfig = {
|
|
...passwordHost(),
|
|
auth: { type: "private-key", identityFile, passphrase: "key-secret" },
|
|
};
|
|
const { fake, transport } = await connectedTransport(undefined, host);
|
|
assert.equal(Buffer.from(fake.connectConfig?.privateKey ?? "").toString("utf8"), "PRIVATE KEY FIXTURE");
|
|
assert.equal(fake.connectConfig?.passphrase, "key-secret");
|
|
assert.equal(fake.connectConfig?.password, undefined);
|
|
assert.equal(fake.connectConfig?.tryKeyboard, undefined);
|
|
await transport.dispose();
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("forwards SSH agent authentication through ssh2", async () => {
|
|
const host: SshHostConfig = {
|
|
...passwordHost(),
|
|
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
|
|
};
|
|
const { fake, transport } = await connectedTransport(undefined, host);
|
|
assert.equal(fake.connectConfig?.agent, join(homedir(), ".1password", "agent.sock"));
|
|
assert.equal(fake.connectConfig?.password, undefined);
|
|
assert.equal(fake.connectConfig?.privateKey, undefined);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
|
|
const sftp = new FakeSftp();
|
|
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
|
|
sftp.files.set("/srv/app/image.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
const { fake, transport } = await connectedTransport(sftp);
|
|
assert.equal((await transport.readFile("/srv/app/a.txt")).toString("utf8"), "hello");
|
|
await transport.ensureReadable("/srv/app/a.txt");
|
|
await transport.ensureReadableWritable("/srv/app/a.txt");
|
|
assert.deepEqual(sftp.openCalls, [
|
|
{ path: "/srv/app/a.txt", flags: "r" },
|
|
{ path: "/srv/app/a.txt", flags: "r+" },
|
|
]);
|
|
assert.equal(await transport.detectImageMimeType("/srv/app/image.png"), "image/png");
|
|
assert.equal(await transport.detectImageMimeType("/srv/app/a.txt"), null);
|
|
assert.equal(fake.sftpCalls, 1);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("creates parent directories and atomically renames remote writes", async () => {
|
|
const sftp = new FakeSftp(true);
|
|
const { transport } = await connectedTransport(sftp);
|
|
await transport.writeFile("/srv/app/output.txt", Buffer.from("new content"));
|
|
assert.equal(sftp.directories.has("/srv"), true);
|
|
assert.equal(sftp.directories.has("/srv/app"), true);
|
|
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "new content");
|
|
assert.equal(sftp.renames.length, 1);
|
|
assert.equal(sftp.renames[0]?.atomic, true);
|
|
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("falls back to direct overwrite when SFTP v3 rename cannot replace", async () => {
|
|
const sftp = new FakeSftp(false);
|
|
sftp.directories.add("/srv");
|
|
sftp.directories.add("/srv/app");
|
|
sftp.files.set("/srv/app/output.txt", Buffer.from("old"));
|
|
sftp.failStandardRename = true;
|
|
const { transport } = await connectedTransport(sftp);
|
|
await transport.writeFile("/srv/app/output.txt", Buffer.from("replacement"));
|
|
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "replacement");
|
|
assert.equal(sftp.renames.length, 1);
|
|
assert.equal(sftp.renames[0]?.atomic, false);
|
|
assert.equal(sftp.unlinks.length, 1);
|
|
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("aborts an in-progress SSH connection attempt", async () => {
|
|
const fake = new FakeClient();
|
|
fake.connectAction = () => {};
|
|
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
|
const controller = new AbortController();
|
|
const connecting = transport.connect(controller.signal);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
await assert.rejects(connecting, /SSH connection aborted/);
|
|
assert.equal(fake.destroyed, true);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("allows four independent exec channels and bounds additional commands", async () => {
|
|
const fake = new FakeClient();
|
|
const channels: FakeChannel[] = [];
|
|
fake.execAction = (_command, callback) => {
|
|
const channel = new FakeChannel();
|
|
channels.push(channel);
|
|
callback(undefined, channel as ClientChannel);
|
|
};
|
|
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
|
await transport.connect();
|
|
const commands = Array.from({ length: 5 }, (_, index) =>
|
|
transport.exec(`command-${index}`, "/srv", { onData() {}, timeout: 0 }),
|
|
);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
assert.equal(channels.length, 4);
|
|
channels[0]?.emit("close", 0);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
assert.equal(channels.length, 5);
|
|
for (const channel of channels.slice(1)) channel.emit("close", 0);
|
|
await Promise.all(commands);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("cancels an exec while it is waiting for a concurrency slot", async () => {
|
|
const fake = new FakeClient();
|
|
const channels: FakeChannel[] = [];
|
|
fake.execAction = (_command, callback) => {
|
|
const channel = new FakeChannel();
|
|
channels.push(channel);
|
|
callback(undefined, channel as ClientChannel);
|
|
};
|
|
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
|
await transport.connect();
|
|
const active = Array.from({ length: 4 }, () => transport.exec("sleep", "/srv", { onData() {}, timeout: 0 }));
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
const controller = new AbortController();
|
|
const queued = transport.exec("queued", "/srv", { onData() {}, timeout: 0, signal: controller.signal });
|
|
controller.abort();
|
|
await assert.rejects(queued, /SSH command aborted/);
|
|
assert.equal(channels.length, 4);
|
|
for (const channel of channels) channel.emit("close", 0);
|
|
await Promise.all(active);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("aborts and times out commands by closing the active channel", async () => {
|
|
const abortClient = new FakeClient();
|
|
const abortChannel = new FakeChannel();
|
|
abortClient.execAction = (_command, callback) => {
|
|
abortClient.channel = abortChannel;
|
|
callback(undefined, abortChannel as ClientChannel);
|
|
};
|
|
const abortTransport = new Ssh2Transport(passwordHost(), abortClient as unknown as Client);
|
|
await abortTransport.connect();
|
|
const controller = new AbortController();
|
|
const aborted = abortTransport.exec("sleep 10", "/srv", { onData() {}, signal: controller.signal, timeout: 0 });
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
await assert.rejects(aborted, /SSH command aborted/);
|
|
assert.deepEqual(abortChannel.signals, ["KILL"]);
|
|
assert.equal(abortChannel.closeCalls, 1);
|
|
await abortTransport.dispose();
|
|
|
|
const timeoutClient = new FakeClient();
|
|
const timeoutChannel = new FakeChannel();
|
|
timeoutClient.execAction = (_command, callback) => {
|
|
timeoutClient.channel = timeoutChannel;
|
|
callback(undefined, timeoutChannel as ClientChannel);
|
|
};
|
|
const timeoutTransport = new Ssh2Transport(passwordHost(), timeoutClient as unknown as Client);
|
|
await timeoutTransport.connect();
|
|
await assert.rejects(
|
|
timeoutTransport.exec("sleep 10", "/srv", { onData() {}, timeout: 0.01 }),
|
|
/timed out after 0.01s/,
|
|
);
|
|
assert.deepEqual(timeoutChannel.signals, ["KILL"]);
|
|
assert.equal(timeoutChannel.closeCalls, 1);
|
|
await timeoutTransport.dispose();
|
|
});
|
|
|
|
test("fails closed after the SSH connection closes", async () => {
|
|
const sftp = new FakeSftp();
|
|
sftp.files.set("/srv/a.txt", Buffer.from("a"));
|
|
const { fake, transport } = await connectedTransport(sftp);
|
|
fake.emit("close");
|
|
await assert.rejects(transport.readFile("/srv/a.txt"), /SSH2 connection closed unexpectedly/);
|
|
await transport.dispose();
|
|
});
|
|
|
|
test("probes and fingerprints a host key without authenticating", async () => {
|
|
const fake = new FakeClient();
|
|
const key = fixtureKey("probe");
|
|
fake.connectAction = (config, client) => {
|
|
assert.equal(config.host, "probe.example.test");
|
|
assert.equal(config.port, 2200);
|
|
assert.equal(config.username, "probe-user");
|
|
assert.equal(config.hostVerifier?.(key), false);
|
|
client.emit("close");
|
|
};
|
|
const result = await probeHostKey("probe.example.test", 2200, "probe-user", fake as unknown as Client);
|
|
assert.deepEqual(result, fingerprintHostKey(key));
|
|
});
|
|
|
|
test("rejects host-key probing errors before a key is observed", async () => {
|
|
const fake = new FakeClient();
|
|
fake.connectAction = (_config, client) => client.emit("error", new Error("network unavailable"));
|
|
await assert.rejects(
|
|
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client),
|
|
/network unavailable/,
|
|
);
|
|
assert.equal(fake.destroyed, true);
|
|
});
|
|
|
|
test("times out host-key probing when the server never responds", async () => {
|
|
const fake = new FakeClient();
|
|
fake.connectAction = () => {};
|
|
await assert.rejects(
|
|
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client, 5),
|
|
/timed out while obtaining SSH host key/,
|
|
);
|
|
assert.equal(fake.destroyed, true);
|
|
assert.equal(fake.connectConfig?.readyTimeout, 5);
|
|
});
|