mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
/// <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;
|
|
},
|
|
});
|
|
});
|
|
}
|