mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: add pure ssh2 remote operations
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { validatePiSshConfig } from "../src/config.ts";
|
||||
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||
import { fingerprintHostKey } from "../src/ssh2-transport.ts";
|
||||
|
||||
test("parses the effective ssh -G output including repeated identity files", () => {
|
||||
const parsed = parseSshG([
|
||||
"host packaging-server",
|
||||
"hostname 192.0.2.20",
|
||||
"user builder",
|
||||
"port 2222",
|
||||
"identityfile ~/.ssh/first",
|
||||
"identityfile ~/.ssh/second",
|
||||
"proxyjump none",
|
||||
].join("\n"));
|
||||
assert.equal(effectiveValue(parsed, "hostname"), "192.0.2.20");
|
||||
assert.equal(effectiveValue(parsed, "port"), "2222");
|
||||
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
|
||||
});
|
||||
|
||||
test("validates password and private-key host definitions", () => {
|
||||
const password = validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
build: {
|
||||
hostName: "build.example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password", password: "secret" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||
},
|
||||
},
|
||||
groups: { builders: { label: "Builders", hosts: ["build"] } },
|
||||
});
|
||||
assert.equal(password.hosts.build.auth.type, "password");
|
||||
|
||||
assert.throws(() => validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
build: {
|
||||
hostName: "build.example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password", password: "" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||
},
|
||||
},
|
||||
}), /password/);
|
||||
});
|
||||
|
||||
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
|
||||
const algorithm = Buffer.from("ssh-ed25519", "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(algorithm.length);
|
||||
const key = Buffer.concat([length, algorithm, Buffer.from("public-key-fixture")]);
|
||||
const expected = createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
|
||||
assert.deepEqual(fingerprintHostKey(key), {
|
||||
algorithm: "ssh-ed25519",
|
||||
fingerprint: `SHA256:${expected}`,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { resolveVaultPaths, type PiSshConfig } from "../src/config.ts";
|
||||
import { saveVault } from "../src/vault.ts";
|
||||
|
||||
const fixture: PiSshConfig = {
|
||||
version: 1,
|
||||
hosts: {
|
||||
packaging: {
|
||||
label: "Packaging",
|
||||
hostName: "192.0.2.15",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password", password: "never-print-this" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("the configuration CLI lists hosts without revealing credentials", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "pi-ssh-cli-"));
|
||||
const configHome = join(home, "config");
|
||||
saveVault(fixture, resolveVaultPaths({ XDG_CONFIG_HOME: configHome }, process.platform, home));
|
||||
const result = spawnSync(resolve("../ssh_config.sh"), ["list"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: configHome },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /packaging\s+Packaging\s+builder@192\.0\.2\.15:22\s+password/);
|
||||
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /never-print-this/);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const extensionSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||
|
||||
test("does not discover or inject remote project instruction files", () => {
|
||||
assert.doesNotMatch(extensionSource, /AGENTS\.md|CLAUDE\.md/);
|
||||
assert.doesNotMatch(extensionSource, /loadRemoteContext|Remote Project Context/);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { PermissionsService } from "@gotgenes/pi-permission-system";
|
||||
import {
|
||||
formatSshPermissionInput,
|
||||
installSshPermissionIntegration,
|
||||
type SshPermissionConnection,
|
||||
} from "../permission-integration.ts";
|
||||
|
||||
function makeService() {
|
||||
const formatters = new Map<string, (input: Record<string, unknown>) => string | undefined>();
|
||||
const extractors = new Map<string, (input: Record<string, unknown>) => string | undefined>();
|
||||
const service = {
|
||||
registerToolInputFormatter(name: string, formatter: (input: Record<string, unknown>) => string | undefined) {
|
||||
formatters.set(name, formatter);
|
||||
return () => formatters.delete(name);
|
||||
},
|
||||
registerToolAccessExtractor(name: string, extractor: (input: Record<string, unknown>) => string | undefined) {
|
||||
extractors.set(name, extractor);
|
||||
return () => extractors.delete(name);
|
||||
},
|
||||
} as unknown as PermissionsService;
|
||||
return { service, formatters, extractors };
|
||||
}
|
||||
|
||||
function makePi() {
|
||||
const hooks = new Map<string, Array<() => void>>();
|
||||
const eventHooks = new Map<string, Array<() => void>>();
|
||||
const add = (map: Map<string, Array<() => void>>, name: string, handler: () => void) => {
|
||||
map.set(name, [...(map.get(name) ?? []), handler]);
|
||||
};
|
||||
return {
|
||||
api: {
|
||||
on: (name: string, handler: () => void) => add(hooks, name, handler),
|
||||
events: { on: (name: string, handler: () => void) => add(eventHooks, name, handler) },
|
||||
},
|
||||
emit: (name: string) => hooks.get(name)?.forEach((handler) => handler()),
|
||||
emitEvent: (name: string) => eventHooks.get(name)?.forEach((handler) => handler()),
|
||||
};
|
||||
}
|
||||
|
||||
const connection: SshPermissionConnection = {
|
||||
remote: "packaging-server",
|
||||
port: 2222,
|
||||
remoteCwd: "/srv/build",
|
||||
};
|
||||
|
||||
test("formats the SSH target and bounded operation details", () => {
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path 'src/main.ts', offset 5, limit 20",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_write", { path: "dist/a.txt", content: "one\ntwo" }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path 'dist/a.txt' (2 lines, 7 characters)",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_grep", { pattern: "TODO", path: "src", include: "*.ts", limit: 25 }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under 'src', for 'TODO', limit 25, file glob '*.ts'",
|
||||
);
|
||||
});
|
||||
|
||||
test("registers previews and disables local path extraction for remote file tools", () => {
|
||||
const { service, formatters, extractors } = makeService();
|
||||
const pi = makePi();
|
||||
const dispose = installSshPermissionIntegration(
|
||||
pi.api as never,
|
||||
() => connection,
|
||||
{ getPermissionsService: () => service, permissionsReadyChannel: "permissions:ready" },
|
||||
);
|
||||
|
||||
assert.deepEqual([...formatters.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
|
||||
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
|
||||
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
|
||||
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
|
||||
|
||||
dispose();
|
||||
assert.equal(formatters.size, 0);
|
||||
assert.equal(extractors.size, 0);
|
||||
});
|
||||
|
||||
test("registers when the permission service becomes ready and cleans up on shutdown", () => {
|
||||
const { service, formatters, extractors } = makeService();
|
||||
const pi = makePi();
|
||||
let published: PermissionsService | undefined;
|
||||
installSshPermissionIntegration(pi.api as never, () => connection, {
|
||||
getPermissionsService: () => published,
|
||||
permissionsReadyChannel: "permissions:ready",
|
||||
});
|
||||
assert.equal(formatters.size, 0);
|
||||
|
||||
published = service;
|
||||
pi.emitEvent("permissions:ready");
|
||||
assert.equal(formatters.size, 6);
|
||||
assert.equal(extractors.size, 5);
|
||||
|
||||
pi.emit("session_shutdown");
|
||||
assert.equal(formatters.size, 0);
|
||||
assert.equal(extractors.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildRemoteFindCommand,
|
||||
buildRemoteGrepCommand,
|
||||
formatRemoteSearchOutput,
|
||||
resolveRemoteSearchPath,
|
||||
runRemoteFind,
|
||||
runRemoteGrep,
|
||||
} from "../src/remote-search.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class SearchTransport implements RemoteTransport {
|
||||
command = "";
|
||||
cwd = "";
|
||||
private readonly output: string;
|
||||
private readonly exitCode: number | null;
|
||||
constructor(output: string, exitCode: number | null = 0) {
|
||||
this.output = output;
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.command = command;
|
||||
this.cwd = cwd;
|
||||
options.onData(Buffer.from(this.output));
|
||||
return Promise.resolve({ exitCode: this.exitCode });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("resolves remote search roots without using local filesystem semantics", () => {
|
||||
assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app");
|
||||
assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src");
|
||||
assert.equal(resolveRemoteSearchPath("~/logs", "/srv/app", "/home/build"), "/home/build/logs");
|
||||
assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log");
|
||||
});
|
||||
|
||||
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
|
||||
const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir");
|
||||
assert.match(find.command, /command -v fd/);
|
||||
assert.match(find.command, /git-ls-files/);
|
||||
assert.match(find.command, /head -n 13/);
|
||||
assert.match(find.command, /'it'"'"'s-app'/);
|
||||
assert.match(find.command, /'\/srv\/app dir'/);
|
||||
|
||||
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 20 }, "/srv/app");
|
||||
assert.match(grep.command, /command -v rg/);
|
||||
assert.match(grep.command, /git-grep/);
|
||||
assert.match(grep.command, /find .* -exec grep/);
|
||||
assert.match(grep.command, /head -n 21/);
|
||||
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
|
||||
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
|
||||
});
|
||||
|
||||
test("normalizes git paths, truncates rows, and reports the backend", () => {
|
||||
const result = formatRemoteSearchOutput(
|
||||
"__PI_SSH_SEARCH_BACKEND__:git-grep\nsrc/a.ts:2:TODO\nsrc/b.ts:3:TODO\nsrc/c.ts:4:TODO\n",
|
||||
"/srv/app",
|
||||
2,
|
||||
"grep",
|
||||
);
|
||||
assert.equal(result.backend, "git-grep");
|
||||
assert.equal(result.matchCount, 2);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/);
|
||||
assert.doesNotMatch(result.text, /src\/c\.ts/);
|
||||
});
|
||||
|
||||
test("executes remote find through the structured transport with bounded output", async () => {
|
||||
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/srv/app/a.ts\n");
|
||||
const result = await runRemoteFind(transport, { pattern: "a", limit: 5 }, "/srv/app", "/home/build");
|
||||
assert.equal(transport.cwd, "/srv/app");
|
||||
assert.match(transport.command, /command -v fd/);
|
||||
assert.equal(result.matchCount, 1);
|
||||
assert.match(result.text, /backend: fd/);
|
||||
});
|
||||
|
||||
test("propagates backend failures without exposing the internal marker", async () => {
|
||||
const transport = new SearchTransport(
|
||||
"__PI_SSH_SEARCH_BACKEND__:git-grep\nfatal: invalid regular expression\n",
|
||||
128,
|
||||
);
|
||||
await assert.rejects(
|
||||
runRemoteGrep(transport, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
|
||||
(error: unknown) => {
|
||||
assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/);
|
||||
assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("adaptive commands preserve no-match success and propagate real backend errors", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-"));
|
||||
try {
|
||||
mkdirSync(join(root, "src"));
|
||||
writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8");
|
||||
execFileSync("/usr/bin/git", ["init", "-q", root]);
|
||||
execFileSync("/usr/bin/git", ["-C", root, "add", "src/a.ts"]);
|
||||
const environment = { ...process.env, PATH: "/usr/bin:/bin" };
|
||||
const execute = (command: string) => execFileSync("/bin/bash", ["-c", command], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: environment,
|
||||
});
|
||||
|
||||
const find = buildRemoteFindCommand({ pattern: "a.ts", limit: 5 }, root);
|
||||
const findResult = formatRemoteSearchOutput(execute(find.command), root, find.limit, "find");
|
||||
assert.equal(findResult.backend, "git-ls-files");
|
||||
assert.match(findResult.text, /src\/a\.ts/);
|
||||
|
||||
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
|
||||
const grepResult = formatRemoteSearchOutput(execute(grep.command), root, grep.limit, "grep");
|
||||
assert.equal(grepResult.backend, "git-grep");
|
||||
assert.match(grepResult.text, /src\/a\.ts:1:/);
|
||||
|
||||
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
|
||||
const noMatchProcess = spawnSync("/bin/bash", ["-c", noMatch.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.equal(noMatchProcess.status, 0);
|
||||
assert.equal(formatRemoteSearchOutput(noMatchProcess.stdout, root, noMatch.limit, "grep").matchCount, 0);
|
||||
|
||||
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
|
||||
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.notEqual(invalidProcess.status, 0);
|
||||
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/i);
|
||||
|
||||
const missingRoot = join(root, "missing");
|
||||
const missing = buildRemoteFindCommand({ pattern: "anything" }, missingRoot);
|
||||
const missingProcess = spawnSync("/bin/bash", ["-c", missing.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.notEqual(missingProcess.status, 0);
|
||||
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
|
||||
|
||||
const transport = new SearchTransport(execute(grep.command));
|
||||
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
|
||||
assert.equal(throughTransport.matchCount, 1);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("propagates fd and ripgrep failures while preserving ripgrep no-match", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-backends-"));
|
||||
try {
|
||||
const bin = join(root, "bin");
|
||||
mkdirSync(bin);
|
||||
const fd = join(bin, "fd");
|
||||
const rg = join(bin, "rg");
|
||||
writeFileSync(fd, "#!/bin/sh\necho 'fd exploded' >&2\nexit 3\n", { mode: 0o755 });
|
||||
writeFileSync(rg, "#!/bin/sh\necho 'rg exploded' >&2\nexit 2\n", { mode: 0o755 });
|
||||
const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` };
|
||||
|
||||
const find = buildRemoteFindCommand({ pattern: "anything" }, root);
|
||||
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.equal(failedFind.status, 3);
|
||||
assert.match(failedFind.stdout, /fd exploded/);
|
||||
|
||||
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
|
||||
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.equal(failedGrep.status, 2);
|
||||
assert.match(failedGrep.stdout, /rg exploded/);
|
||||
|
||||
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.equal(noMatch.status, 0);
|
||||
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { 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 result = await transport.exec("printf ok", "/srv/build", {
|
||||
onData: (data) => output.push(data),
|
||||
timeout: 5,
|
||||
});
|
||||
assert.equal(result.exitCode, 7);
|
||||
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\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("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 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);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import type { PiSshConfig, VaultPaths } from "../src/config.ts";
|
||||
import { decryptConfig, encryptConfig, loadVault, rotateVaultKey, saveVault } from "../src/vault.ts";
|
||||
|
||||
function fixture(): PiSshConfig {
|
||||
return {
|
||||
version: 1,
|
||||
hosts: {
|
||||
packaging: {
|
||||
label: "Packaging",
|
||||
hostName: "192.0.2.10",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password", password: "server-secret", method: "auto" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function paths(): VaultPaths {
|
||||
const directory = mkdtempSync(join(tmpdir(), "pi-ssh-vault-"));
|
||||
return {
|
||||
directory,
|
||||
encryptedConfig: join(directory, "hosts.enc"),
|
||||
key: join(directory, "vault.key"),
|
||||
};
|
||||
}
|
||||
|
||||
test("encrypts and authenticates the complete configuration", () => {
|
||||
const key = randomBytes(32);
|
||||
const encrypted = encryptConfig(fixture(), key);
|
||||
assert.doesNotMatch(encrypted, /server-secret|192\.0\.2\.10|builder/);
|
||||
assert.deepEqual(decryptConfig(encrypted, key), fixture());
|
||||
|
||||
const envelope = JSON.parse(encrypted);
|
||||
envelope.ciphertext = `${envelope.ciphertext.slice(0, -2)}AA`;
|
||||
assert.throws(() => decryptConfig(JSON.stringify(envelope), key), /integrity verification/);
|
||||
assert.throws(() => decryptConfig(encrypted, randomBytes(32)), /integrity verification/);
|
||||
});
|
||||
|
||||
test("writes an owner-only key and encrypted config without plaintext", () => {
|
||||
const target = paths();
|
||||
saveVault(fixture(), target);
|
||||
assert.deepEqual(loadVault(target), fixture());
|
||||
assert.doesNotMatch(readFileSync(target.encryptedConfig, "utf8"), /server-secret/);
|
||||
assert.equal(Buffer.from(readFileSync(target.key, "utf8").trim(), "base64").length, 32);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(statSync(target.directory).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(target.encryptedConfig).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(target.key).mode & 0o777, 0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test("fails closed when vault files are missing or too broadly readable", () => {
|
||||
const target = paths();
|
||||
assert.throws(() => loadVault(target), /not configured/);
|
||||
mkdirSync(target.directory, { recursive: true });
|
||||
writeFileSync(target.key, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600 });
|
||||
assert.throws(() => loadVault(target), /not configured/);
|
||||
saveVault(fixture(), target);
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(target.encryptedConfig, 0o644);
|
||||
assert.throws(() => loadVault(target), /permissions/);
|
||||
}
|
||||
});
|
||||
|
||||
test("rotates the adjacent key while preserving the encrypted configuration", () => {
|
||||
const target = paths();
|
||||
saveVault(fixture(), target);
|
||||
const oldKey = readFileSync(target.key, "utf8");
|
||||
rotateVaultKey(target);
|
||||
assert.notEqual(readFileSync(target.key, "utf8"), oldKey);
|
||||
assert.deepEqual(loadVault(target), fixture());
|
||||
});
|
||||
Reference in New Issue
Block a user