feat: add pure ssh2 remote operations

This commit is contained in:
云服务部-叶林立
2026-08-21 19:51:43 +08:00
parent d3bf562189
commit 0ac50eb581
62 changed files with 3701 additions and 47 deletions
+171
View File
@@ -0,0 +1,171 @@
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
export interface PasswordAuthConfig {
type: "password";
password: string;
method?: "auto" | "password" | "keyboard-interactive";
}
export interface PrivateKeyAuthConfig {
type: "private-key";
identityFile: string;
passphrase?: string;
}
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig;
export interface SshHostConfig {
label?: string;
sourceAlias?: string;
hostName: string;
user: string;
port: number;
defaultCwd?: string;
auth: SshAuthConfig;
hostKey: {
algorithm: string;
fingerprint: string;
};
}
export interface SshGroupConfig {
label: string;
hosts: string[];
}
export interface PiSshConfig {
version: 1;
hosts: Record<string, SshHostConfig>;
groups?: Record<string, SshGroupConfig>;
}
export interface VaultPaths {
directory: string;
encryptedConfig: string;
key: string;
}
const HOST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function resolveVaultPaths(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
home = homedir(),
): VaultPaths {
const base = platform === "win32"
? env.APPDATA || join(home, "AppData", "Roaming")
: env.XDG_CONFIG_HOME || join(home, ".config");
const directory = join(base, "my-pi", "pi-ssh");
return {
directory,
encryptedConfig: join(directory, "hosts.enc"),
key: join(directory, "vault.key"),
};
}
export function expandUserPath(value: string, home = homedir()): string {
if (value === "~") return home;
if (value.startsWith("~/")) return join(home, value.slice(2));
return isAbsolute(value) ? value : resolve(home, value);
}
function record(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${name} must be an object`);
}
return value as Record<string, unknown>;
}
function nonEmptyString(value: unknown, name: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${name} must be a non-empty string`);
}
return value;
}
function optionalString(value: unknown, name: string): string | undefined {
return value === undefined ? undefined : nonEmptyString(value, name);
}
function validateAuth(value: unknown, name: string): SshAuthConfig {
const auth = record(value, name);
if (auth.type === "password") {
const method = auth.method;
if (method !== undefined && method !== "auto" && method !== "password" && method !== "keyboard-interactive") {
throw new Error(`${name}.method must be auto, password, or keyboard-interactive`);
}
return {
type: "password",
password: nonEmptyString(auth.password, `${name}.password`),
...(method === undefined ? {} : { method }),
};
}
if (auth.type === "private-key") {
return {
type: "private-key",
identityFile: nonEmptyString(auth.identityFile, `${name}.identityFile`),
...(auth.passphrase === undefined ? {} : { passphrase: nonEmptyString(auth.passphrase, `${name}.passphrase`) }),
};
}
throw new Error(`${name}.type must be password or private-key`);
}
function validateHost(value: unknown, name: string): SshHostConfig {
const host = record(value, name);
const port = host.port;
if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535) {
throw new Error(`${name}.port must be an integer from 1 to 65535`);
}
const hostKey = record(host.hostKey, `${name}.hostKey`);
const fingerprint = nonEmptyString(hostKey.fingerprint, `${name}.hostKey.fingerprint`);
if (!fingerprint.startsWith("SHA256:")) {
throw new Error(`${name}.hostKey.fingerprint must use SHA256 format`);
}
return {
...(host.label === undefined ? {} : { label: optionalString(host.label, `${name}.label`) }),
...(host.sourceAlias === undefined ? {} : { sourceAlias: optionalString(host.sourceAlias, `${name}.sourceAlias`) }),
hostName: nonEmptyString(host.hostName, `${name}.hostName`),
user: nonEmptyString(host.user, `${name}.user`),
port: port as number,
...(host.defaultCwd === undefined ? {} : { defaultCwd: optionalString(host.defaultCwd, `${name}.defaultCwd`) }),
auth: validateAuth(host.auth, `${name}.auth`),
hostKey: {
algorithm: nonEmptyString(hostKey.algorithm, `${name}.hostKey.algorithm`),
fingerprint,
},
};
}
export function validatePiSshConfig(value: unknown): PiSshConfig {
const root = record(value, "config");
if (root.version !== 1) throw new Error("config.version must be 1");
const hostsValue = record(root.hosts, "config.hosts");
const hosts: Record<string, SshHostConfig> = {};
for (const [id, host] of Object.entries(hostsValue)) {
if (!HOST_ID_PATTERN.test(id)) throw new Error(`invalid host id: ${id}`);
hosts[id] = validateHost(host, `config.hosts.${id}`);
}
let groups: Record<string, SshGroupConfig> | undefined;
if (root.groups !== undefined) {
groups = {};
for (const [id, value] of Object.entries(record(root.groups, "config.groups"))) {
if (!HOST_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id}`);
const group = record(value, `config.groups.${id}`);
if (!Array.isArray(group.hosts) || group.hosts.some((host) => typeof host !== "string" || !hosts[host])) {
throw new Error(`config.groups.${id}.hosts must reference configured hosts`);
}
groups[id] = {
label: nonEmptyString(group.label, `config.groups.${id}.label`),
hosts: [...new Set(group.hosts as string[])],
};
}
}
return { version: 1, hosts, ...(groups === undefined ? {} : { groups }) };
}
export function emptyPiSshConfig(): PiSshConfig {
return { version: 1, hosts: {} };
}
+53
View File
@@ -0,0 +1,53 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type EffectiveSshConfig = Map<string, string[]>;
export function parseSshG(text: string): EffectiveSshConfig {
const values: EffectiveSshConfig = new Map();
for (const line of text.split(/\r?\n/)) {
const separator = line.indexOf(" ");
if (separator < 1) continue;
const key = line.slice(0, separator).trim().toLowerCase();
const value = line.slice(separator + 1).trim();
if (!key || !value) continue;
const current = values.get(key) ?? [];
current.push(value);
values.set(key, current);
}
return values;
}
export function resolveOpenSshAlias(alias: string): EffectiveSshConfig {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(alias)) throw new Error(`invalid SSH alias: ${alias}`);
const output = execFileSync("ssh", ["-G", "--", alias], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return parseSshG(output);
}
export function effectiveValue(config: EffectiveSshConfig, key: string): string | undefined {
return config.get(key.toLowerCase())?.at(-1);
}
export function effectiveValues(config: EffectiveSshConfig, key: string): string[] {
return config.get(key.toLowerCase()) ?? [];
}
export function listDirectSshAliases(configPath = join(homedir(), ".ssh", "config")): string[] {
if (!existsSync(configPath)) return [];
const aliases: string[] = [];
const text = readFileSync(configPath, "utf8");
for (const line of text.split(/\r?\n/)) {
const match = /^\s*Host\s+(.+?)\s*$/i.exec(line);
if (!match) continue;
for (const alias of match[1].split(/\s+/)) {
if (!alias || alias.includes("*") || alias.includes("?") || alias.startsWith("!")) continue;
if (!aliases.includes(alias)) aliases.push(alias);
}
}
return aliases;
}
+243
View File
@@ -0,0 +1,243 @@
import { posix as posixPath } from "node:path";
import type { RemoteTransport } from "./ssh2-transport.ts";
export interface RemoteFindInput {
pattern: string;
path?: string;
caseSensitive?: boolean;
includeHidden?: boolean;
limit?: number;
}
export interface RemoteGrepInput {
pattern: string;
path?: string;
literal?: boolean;
caseSensitive?: boolean;
include?: string;
limit?: number;
}
export interface RemoteSearchResult {
text: string;
backend: string;
matchCount: number;
truncated: boolean;
}
const MARKER = "__PI_SSH_SEARCH_BACKEND__:";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
const MAX_LINE_CHARS = 800;
const MAX_CAPTURE_CHARS = 512_000;
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function validateField(value: unknown, name: string, optional = false): string | undefined {
if (value === undefined && optional) return undefined;
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
if (/[\0\r\n]/u.test(value)) throw new Error(`${name} must not contain NUL or newline characters`);
return value;
}
function normalizeLimit(value: unknown): number {
if (value === undefined) return DEFAULT_LIMIT;
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_LIMIT) {
throw new Error(`limit must be an integer from 1 to ${MAX_LIMIT}`);
}
return value as number;
}
export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: string, remoteHome: string): string {
const value = path ?? ".";
validateField(value, "path");
if (value === "~") return remoteHome;
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
if (value.startsWith("/")) return posixPath.normalize(value);
return posixPath.normalize(posixPath.join(remoteCwd, value));
}
const STATUS_HELPER = [
`pi_ssh_accept_status() {`,
` local actual="$1" accepted`,
` shift`,
` for accepted in "$@"; do`,
` if [ "$actual" -eq "$accepted" ]; then return 0; fi`,
` done`,
` return "$actual"`,
`}`,
].join("\n");
function findPipeline(input: RemoteFindInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string;
const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case";
const fdHidden = input.includeHidden ? "--hidden" : "";
const grepCase = input.caseSensitive ? "" : "-i";
const hiddenFilter = input.includeHidden ? "cat" : "grep -Ev '(^|/)\\.'";
const take = limit + 1;
return [
STATUS_HELPER,
`if command -v fd >/dev/null 2>&1; then`,
` printf '${MARKER}fd\\n'`,
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v fdfind >/dev/null 2>&1; then`,
` printf '${MARKER}fdfind\\n'`,
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
` printf '${MARKER}git-ls-files\\n'`,
` git -C ${shellQuote(root)} ls-files -co --exclude-standard 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
`else`,
` printf '${MARKER}find\\n'`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
`fi`,
].join("\n");
}
function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string;
const include = validateField(input.include, "include", true);
const fixed = input.literal === false ? "" : "-F";
const insensitive = input.caseSensitive ? "" : "-i";
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
const gitPath = include ? `-- ${shellQuote(include)}` : "";
const findName = include ? `-name ${shellQuote(include)}` : "";
const take = limit + 1;
return [
STATUS_HELPER,
`if command -v rg >/dev/null 2>&1; then`,
` printf '${MARKER}ripgrep\\n'`,
` rg --line-number --no-heading --color never --with-filename --max-columns 500 --max-columns-preview ${fixed} ${insensitive} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
` printf '${MARKER}git-grep\\n'`,
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -n -I ${fixed} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
`else`,
` printf '${MARKER}grep\\n'`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -exec grep -nH -I ${fixed} ${insensitive} -- ${shellQuote(pattern)} {} + 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`fi`,
].join("\n");
}
export function buildRemoteFindCommand(input: RemoteFindInput, root: string): { command: string; limit: number } {
const limit = normalizeLimit(input.limit);
validateField(root, "resolved path");
return { command: findPipeline(input, root, limit), limit };
}
export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): { command: string; limit: number } {
const limit = normalizeLimit(input.limit);
validateField(root, "resolved path");
return { command: grepPipeline(input, root, limit), limit };
}
function prefixGitPath(line: string, root: string, grep: boolean): string {
if (line.startsWith("/") || line.startsWith("../")) return line;
if (!grep) return posixPath.join(root, line);
const separator = line.indexOf(":");
if (separator < 1) return line;
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`;
}
export function formatRemoteSearchOutput(
raw: string,
root: string,
limit: number,
kind: "find" | "grep",
): RemoteSearchResult {
const lines = raw.replace(/\r\n?/gu, "\n").split("\n");
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER));
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`);
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown";
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0);
const truncated = sourceRows.length > limit;
const rows = sourceRows.slice(0, limit).map((line) => {
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line;
return normalized.length > MAX_LINE_CHARS ? `${normalized.slice(0, MAX_LINE_CHARS - 1)}` : normalized;
});
const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`;
return {
text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`,
backend,
matchCount: rows.length,
truncated,
};
}
async function runRemoteSearch(
transport: RemoteTransport,
command: string,
root: string,
limit: number,
kind: "find" | "grep",
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const chunks: Buffer[] = [];
let captured = 0;
const result = await transport.exec(command, root, {
signal,
timeout: 30,
onData(data) {
if (captured >= MAX_CAPTURE_CHARS) return;
const remaining = MAX_CAPTURE_CHARS - captured;
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
chunks.push(chunk);
captured += chunk.length;
},
});
const output = Buffer.concat(chunks).toString("utf8");
if (result.exitCode !== 0) {
const detail = output
.replace(/\r\n?/gu, "\n")
.split("\n")
.filter((line) => !line.startsWith(MARKER))
.join(" ")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300);
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
}
return formatRemoteSearchOutput(output, root, limit, kind);
}
export function runRemoteFind(
transport: RemoteTransport,
input: RemoteFindInput,
remoteCwd: string,
remoteHome: string,
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteFindCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal);
}
export function runRemoteGrep(
transport: RemoteTransport,
input: RemoteGrepInput,
remoteCwd: string,
remoteHome: string,
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteGrepCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal);
}
+48
View File
@@ -0,0 +1,48 @@
declare module "ssh2" {
import type { EventEmitter } from "node:events";
import type { Stats } from "node:fs";
export interface ConnectConfig {
host: string;
port?: number;
username: string;
password?: string;
privateKey?: Buffer | string;
passphrase?: string;
tryKeyboard?: boolean;
readyTimeout?: number;
keepaliveInterval?: number;
keepaliveCountMax?: number;
hostVerifier?: (key: Buffer) => boolean;
}
export interface ClientChannel extends EventEmitter {
stderr: EventEmitter;
close(): void;
signal(signal: string): void;
}
export interface SFTPWrapper {
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void;
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void;
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void;
close(handle: Buffer, callback: (error?: Error) => void): void;
mkdir(path: string, callback: (error?: Error) => void): void;
stat(path: string, callback: (error: Error | undefined, stats: Stats) => void): void;
rename(oldPath: string, newPath: string, callback: (error?: Error) => void): void;
unlink(path: string, callback: (error?: Error) => void): void;
end(): void;
ext_openssh_rename?(oldPath: string, newPath: string, callback: (error?: Error) => void): void;
}
export class Client extends EventEmitter {
connect(config: ConnectConfig): this;
exec(
command: string,
callback: (error: Error | undefined, channel: ClientChannel) => void,
): void;
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void;
end(): this;
destroy(): this;
}
}
+363
View File
@@ -0,0 +1,363 @@
/// <reference path="./ssh2-shim.d.ts" />
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { posix as posixPath } from "node:path";
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
import { expandUserPath, type SshHostConfig } from "./config.ts";
export interface RemoteExecOptions {
onData: (data: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
}
export interface RemoteTransport {
connect(): Promise<void>;
dispose(): Promise<void>;
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>;
capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>;
readFile(remotePath: string): Promise<Buffer>;
ensureReadable(remotePath: string): Promise<void>;
ensureReadableWritable(remotePath: string): Promise<void>;
detectImageMimeType(remotePath: string): Promise<string | null>;
mkdir(remoteDir: string): Promise<void>;
writeFile(remotePath: string, content: Buffer): Promise<void>;
}
const DEFAULT_TIMEOUT_SECONDS = 300;
class CommandQueue {
private tail: Promise<void> = Promise.resolve();
enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.tail.then(task, task);
this.tail = run.then(() => undefined, () => undefined);
return run;
}
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function keyAlgorithm(key: Buffer): string {
if (key.length < 4) return "unknown";
const length = key.readUInt32BE(0);
if (length < 1 || length > key.length - 4) return "unknown";
return key.subarray(4, 4 + length).toString("ascii");
}
export function fingerprintHostKey(key: Buffer): { algorithm: string; fingerprint: string } {
return {
algorithm: keyAlgorithm(key),
fingerprint: `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`,
};
}
function buildConnectConfig(host: SshHostConfig): ConnectConfig {
const config: ConnectConfig = {
host: host.hostName,
port: host.port,
username: host.user,
readyTimeout: 20_000,
keepaliveInterval: 15_000,
keepaliveCountMax: 3,
hostVerifier: (key) => fingerprintHostKey(key).fingerprint === host.hostKey.fingerprint,
};
if (host.auth.type === "password") {
config.password = host.auth.password;
config.tryKeyboard = host.auth.method !== "password";
} else {
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
}
return config;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class Ssh2Transport implements RemoteTransport {
private readonly client: Client;
private readonly queue = new CommandQueue();
private connected = false;
private disposed = false;
private disconnectError: Error | null = null;
private sftpClient: SFTPWrapper | null = null;
private readonly host: SshHostConfig;
constructor(host: SshHostConfig, client: Client = new Client()) {
this.host = host;
this.client = client;
}
async connect(): Promise<void> {
if (this.connected) return;
if (this.disposed) throw new Error("SSH2 transport is disposed");
await new Promise<void>((resolve, reject) => {
let settled = false;
const succeed = () => {
if (settled) return;
settled = true;
this.connected = true;
resolve();
};
const fail = (error: unknown) => {
const normalized = error instanceof Error ? error : new Error(String(error));
this.disconnectError = normalized;
if (settled) return;
settled = true;
reject(normalized);
};
this.client.once("ready", succeed);
this.client.on("error", fail);
this.client.on("close", () => {
this.connected = false;
this.sftpClient = null;
if (!this.disposed && !this.disconnectError) this.disconnectError = new Error("SSH2 connection closed unexpectedly");
});
if (this.host.auth.type === "password" && this.host.auth.method !== "password") {
this.client.on("keyboard-interactive", (_name, _instructions, _language, prompts, finish) => {
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
});
}
try {
this.client.connect(buildConnectConfig(this.host));
} catch (error) {
fail(error);
}
});
}
async dispose(): Promise<void> {
this.disposed = true;
this.connected = false;
this.sftpClient = null;
this.client.end();
}
private assertConnected(): void {
if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active");
}
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
return this.queue.enqueue(() => this.execUnqueued(command, cwd, options));
}
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.assertConnected();
const remoteCommand = `cd -- ${shellQuote(cwd)} && bash -lc ${shellQuote(command)} </dev/null`;
return new Promise((resolve, reject) => {
let channel: ClientChannel | undefined;
let settled = false;
let timeoutHandle: NodeJS.Timeout | undefined;
const effectiveTimeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (options.signal) options.signal.removeEventListener("abort", onAbort);
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onAbort = () => {
try {
channel?.signal("KILL");
channel?.close();
} catch {
// channel may already be closed
}
fail(new Error("SSH command aborted"));
};
if (options.signal?.aborted) {
fail(new Error("SSH command aborted"));
return;
}
options.signal?.addEventListener("abort", onAbort, { once: true });
if (effectiveTimeout > 0) {
timeoutHandle = setTimeout(() => {
try {
channel?.signal("KILL");
channel?.close();
} catch {
// channel may already be closed
}
fail(new Error(`SSH command timed out after ${effectiveTimeout}s`));
}, effectiveTimeout * 1000);
}
this.client.exec(remoteCommand, (error, stream) => {
if (error) {
fail(error);
return;
}
channel = stream;
stream.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
stream.stderr.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
stream.once("error", fail);
stream.once("close", (code: number | undefined) => {
if (settled) return;
settled = true;
cleanup();
resolve({ exitCode: typeof code === "number" ? code : null });
});
});
});
}
async capture(command: string, cwd = ".", timeout = 30): Promise<{ exitCode: number | null; output: Buffer }> {
const chunks: Buffer[] = [];
const result = await this.exec(command, cwd, { timeout, onData: (data) => chunks.push(data) });
return { exitCode: result.exitCode, output: Buffer.concat(chunks) };
}
private async sftp(): Promise<SFTPWrapper> {
this.assertConnected();
if (this.sftpClient) return this.sftpClient;
this.sftpClient = await new Promise<SFTPWrapper>((resolve, reject) => {
this.client.sftp((error, sftp) => error ? reject(error) : resolve(sftp));
});
return this.sftpClient;
}
async readFile(remotePath: string): Promise<Buffer> {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
return new Promise<Buffer>((resolve, reject) => {
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
});
});
}
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
await new Promise<void>((resolve, reject) => {
sftp.open(remotePath, flags, (error, handle) => {
if (error) {
reject(error);
return;
}
sftp.close(handle, (closeError) => closeError ? reject(closeError) : resolve());
});
});
});
}
ensureReadable(remotePath: string): Promise<void> {
return this.ensureOpen(remotePath, "r");
}
ensureReadableWritable(remotePath: string): Promise<void> {
return this.ensureOpen(remotePath, "r+");
}
async detectImageMimeType(remotePath: string): Promise<string | null> {
const content = await this.readFile(remotePath);
if (content.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
if (content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
const prefix = content.subarray(0, 6).toString("ascii");
if (prefix === "GIF87a" || prefix === "GIF89a") return "image/gif";
if (content.subarray(0, 4).toString("ascii") === "RIFF" && content.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
return null;
}
async mkdir(remoteDir: string): Promise<void> {
return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir));
}
private async mkdirUnqueued(remoteDir: string): Promise<void> {
const sftp = await this.sftp();
const normalized = posixPath.normalize(remoteDir);
const segments = normalized.split("/").filter(Boolean);
let current = normalized.startsWith("/") ? "/" : "";
for (const segment of segments) {
current = current === "/" ? `/${segment}` : current ? `${current}/${segment}` : segment;
await new Promise<void>((resolve, reject) => {
sftp.mkdir(current, (error) => {
if (!error) {
resolve();
return;
}
sftp.stat(current, (statError) => statError ? reject(error) : resolve());
});
});
}
}
async writeFile(remotePath: string, content: Buffer): Promise<void> {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
await this.mkdirUnqueued(posixPath.dirname(remotePath));
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
const write = (path: string, data: Buffer) => new Promise<void>((resolve, reject) => {
sftp.writeFile(path, data, (error) => error ? reject(error) : resolve());
});
const standardRename = (from: string, to: string) => new Promise<void>((resolve, reject) => {
sftp.rename(from, to, (error) => error ? reject(error) : resolve());
});
const atomicRename = typeof sftp.ext_openssh_rename === "function"
? (from: string, to: string) => new Promise<void>((resolve, reject) => {
sftp.ext_openssh_rename?.(from, to, (error) => error ? reject(error) : resolve());
})
: undefined;
try {
await write(temporary, content);
if (atomicRename) {
await atomicRename(temporary, remotePath);
} else {
try {
await standardRename(temporary, remotePath);
} catch {
// SFTP v3 rename commonly refuses to replace an existing target.
// Fall back to a direct write without deleting the existing file first.
await write(remotePath, content);
await new Promise<void>((resolve) => sftp.unlink(temporary, () => resolve()));
}
}
} catch (error) {
await new Promise<void>((resolve) => sftp.unlink(temporary, () => resolve()));
throw new Error(`remote write failed: ${errorMessage(error)}`);
}
});
}
}
export async function probeHostKey(
hostName: string,
port: number,
user: string,
client: Client = new Client(),
timeoutMs = 15_000,
): Promise<{ algorithm: string; fingerprint: string }> {
return new Promise((resolve, reject) => {
let observed: { algorithm: string; fingerprint: string } | undefined;
const timer = setTimeout(() => {
client.destroy();
reject(new Error("timed out while obtaining SSH host key"));
}, timeoutMs);
client.on("error", (error) => {
clearTimeout(timer);
client.destroy();
observed ? resolve(observed) : reject(error);
});
client.on("close", () => {
clearTimeout(timer);
if (observed) resolve(observed);
});
client.connect({
host: hostName,
port,
username: user,
readyTimeout: Math.min(timeoutMs, 12_000),
hostVerifier: (key) => {
observed = fingerprintHostKey(key);
return false;
},
});
});
}
+183
View File
@@ -0,0 +1,183 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { basename, join } from "node:path";
import {
emptyPiSshConfig,
resolveVaultPaths,
validatePiSshConfig,
type PiSshConfig,
type VaultPaths,
} from "./config.ts";
const FORMAT = "my-pi-ssh-v1";
const KEY_BYTES = 32;
const IV_BYTES = 12;
interface EncryptedEnvelope {
format: typeof FORMAT;
iv: string;
tag: string;
ciphertext: string;
}
function assertOwnerOnly(path: string): void {
if (process.platform === "win32") return;
const mode = statSync(path).mode & 0o777;
if ((mode & 0o077) !== 0) {
throw new Error(`${basename(path)} permissions must be 600 or stricter`);
}
}
function ensureDirectory(paths: VaultPaths): void {
mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") chmodSync(paths.directory, 0o700);
}
function parseKey(text: string): Buffer {
const normalized = text.trim();
const key = Buffer.from(normalized, "base64");
if (key.length !== KEY_BYTES || key.toString("base64") !== normalized) {
throw new Error("vault.key is invalid");
}
return key;
}
function readKey(paths: VaultPaths): Buffer {
if (!existsSync(paths.key)) throw new Error("pi-ssh vault key is missing; run ssh_config.sh import");
assertOwnerOnly(paths.key);
return parseKey(readFileSync(paths.key, "utf8"));
}
function getOrCreateKey(paths: VaultPaths): Buffer {
ensureDirectory(paths);
if (existsSync(paths.key)) return readKey(paths);
const key = randomBytes(KEY_BYTES);
writeFileSync(paths.key, `${key.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
if (process.platform !== "win32") chmodSync(paths.key, 0o600);
return key;
}
function parseEnvelope(text: string): EncryptedEnvelope {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error("pi-ssh encrypted configuration is invalid JSON");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("pi-ssh encrypted configuration is invalid");
}
const envelope = value as Record<string, unknown>;
for (const field of ["iv", "tag", "ciphertext"] as const) {
if (typeof envelope[field] !== "string" || envelope[field].length === 0) {
throw new Error(`pi-ssh encrypted configuration is missing ${field}`);
}
}
if (envelope.format !== FORMAT) throw new Error("unsupported pi-ssh encrypted configuration format");
return envelope as unknown as EncryptedEnvelope;
}
export function encryptConfig(config: PiSshConfig, key: Buffer): string {
if (key.length !== KEY_BYTES) throw new Error("pi-ssh vault key must be 32 bytes");
const validated = validatePiSshConfig(config);
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(Buffer.from(FORMAT, "utf8"));
const plaintext = Buffer.from(JSON.stringify(validated), "utf8");
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const envelope: EncryptedEnvelope = {
format: FORMAT,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
return `${JSON.stringify(envelope, null, 2)}\n`;
}
export function decryptConfig(encrypted: string, key: Buffer): PiSshConfig {
if (key.length !== KEY_BYTES) throw new Error("pi-ssh vault key must be 32 bytes");
const envelope = parseEnvelope(encrypted);
try {
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64"));
decipher.setAAD(Buffer.from(FORMAT, "utf8"));
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(envelope.ciphertext, "base64")),
decipher.final(),
]).toString("utf8");
return validatePiSshConfig(JSON.parse(plaintext));
} catch (error) {
if (error instanceof SyntaxError) throw new Error("decrypted pi-ssh configuration is invalid JSON");
throw new Error("pi-ssh configuration could not be decrypted or failed integrity verification");
}
}
export function loadVault(paths: VaultPaths = resolveVaultPaths()): PiSshConfig {
if (!existsSync(paths.encryptedConfig)) {
throw new Error("pi-ssh is not configured; run ssh_config.sh import");
}
assertOwnerOnly(paths.encryptedConfig);
const key = readKey(paths);
return decryptConfig(readFileSync(paths.encryptedConfig, "utf8"), key);
}
export function loadVaultOrEmpty(paths: VaultPaths = resolveVaultPaths()): PiSshConfig {
return existsSync(paths.encryptedConfig) ? loadVault(paths) : emptyPiSshConfig();
}
export function saveVault(config: PiSshConfig, paths: VaultPaths = resolveVaultPaths()): void {
const key = getOrCreateKey(paths);
const encrypted = encryptConfig(config, key);
const temp = join(paths.directory, `.hosts.enc.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
try {
writeFileSync(temp, encrypted, { encoding: "utf8", mode: 0o600, flag: "wx" });
if (process.platform !== "win32") chmodSync(temp, 0o600);
renameSync(temp, paths.encryptedConfig);
if (process.platform !== "win32") chmodSync(paths.encryptedConfig, 0o600);
} finally {
if (existsSync(temp)) unlinkSync(temp);
}
}
export function rotateVaultKey(paths: VaultPaths = resolveVaultPaths()): void {
const config = loadVault(paths);
const newKey = randomBytes(KEY_BYTES);
const suffix = `${process.pid}.${randomBytes(6).toString("hex")}`;
const keyTemp = join(paths.directory, `.vault.key.${suffix}.tmp`);
const configTemp = join(paths.directory, `.hosts.enc.${suffix}.tmp`);
const keyBackup = join(paths.directory, `.vault.key.${suffix}.backup`);
const configBackup = join(paths.directory, `.hosts.enc.${suffix}.backup`);
let committed = false;
try {
writeFileSync(keyTemp, `${newKey.toString("base64")}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
writeFileSync(configTemp, encryptConfig(config, newKey), { encoding: "utf8", mode: 0o600, flag: "wx" });
renameSync(paths.key, keyBackup);
renameSync(paths.encryptedConfig, configBackup);
renameSync(keyTemp, paths.key);
renameSync(configTemp, paths.encryptedConfig);
committed = true;
} finally {
if (!committed) {
if (existsSync(keyBackup)) {
if (existsSync(paths.key)) unlinkSync(paths.key);
renameSync(keyBackup, paths.key);
}
if (existsSync(configBackup)) {
if (existsSync(paths.encryptedConfig)) unlinkSync(paths.encryptedConfig);
renameSync(configBackup, paths.encryptedConfig);
}
}
for (const path of [keyTemp, configTemp, keyBackup, configBackup]) {
if (existsSync(path)) unlinkSync(path);
}
}
}