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,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: {} };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user