#!/usr/bin/env node import { existsSync } from "node:fs"; import { stdin, stdout } from "node:process"; import readline from "node:readline/promises"; import { effectiveValue, effectiveValues, listDirectSshAliases, resolveOpenSshAlias, } from "../src/import.ts"; import { expandUserPath } from "../src/config.ts"; import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts"; import { probeRemotePath } from "../src/remote-probe.ts"; import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts"; async function question(prompt) { const rl = readline.createInterface({ input: stdin, output: stdout }); try { return (await rl.question(prompt)).trim(); } finally { rl.close(); } } async function hiddenQuestion(prompt) { if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { throw new Error("secret input requires an interactive terminal"); } stdout.write(prompt); stdin.setRawMode(true); stdin.resume(); let value = ""; try { return await new Promise((resolve, reject) => { const onData = (chunk) => { const text = chunk.toString("utf8"); for (const char of text) { if (char === "\u0003") { stdin.off("data", onData); stdout.write("\n"); reject(new Error("cancelled")); return; } if (char === "\r" || char === "\n") { stdin.off("data", onData); stdout.write("\n"); resolve(value); return; } if (char === "\u007f" || char === "\b") value = value.slice(0, -1); else if (char >= " ") value += char; } }; stdin.on("data", onData); }); } finally { stdin.setRawMode(false); stdin.pause(); } } async function confirm(prompt, defaultYes = false) { const suffix = defaultYes ? " [Y/n] " : " [y/N] "; const value = (await question(`${prompt}${suffix}`)).toLowerCase(); return value ? value === "y" || value === "yes" : defaultYes; } function requireValue(config, key, alias) { const value = effectiveValue(config, key); if (!value) throw new Error(`ssh -G ${alias} did not provide ${key}`); return value; } async function chooseAliases(arguments_) { if (arguments_.length > 0) return arguments_; const aliases = listDirectSshAliases(); if (aliases.length === 0) { const explicit = await question("SSH Host alias to import: "); if (!explicit) throw new Error("no SSH alias selected"); return [explicit]; } console.log("Available SSH aliases:"); aliases.forEach((alias, index) => console.log(` ${index + 1}. ${alias}`)); const selection = await question("Select numbers or aliases (comma-separated): "); const chosen = selection.split(",").map((value) => value.trim()).filter(Boolean).map((value) => { const index = Number.parseInt(value, 10); return Number.isInteger(index) && String(index) === value && aliases[index - 1] ? aliases[index - 1] : value; }); if (chosen.length === 0) throw new Error("no SSH alias selected"); return [...new Set(chosen)]; } async function importHost(config, alias) { const effective = resolveOpenSshAlias(alias); const proxyJump = effectiveValue(effective, "proxyjump"); const proxyCommand = effectiveValue(effective, "proxycommand"); if (proxyJump && proxyJump !== "none") throw new Error(`${alias} uses ProxyJump, which is not supported yet`); if (proxyCommand && proxyCommand !== "none") throw new Error(`${alias} uses ProxyCommand, which is not supported yet`); const hostName = requireValue(effective, "hostname", alias); const user = requireValue(effective, "user", alias); const port = Number.parseInt(requireValue(effective, "port", alias), 10); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${alias} has an invalid port`); console.log(`\n${alias}: ${user}@${hostName}:${port}`); const identityFiles = effectiveValues(effective, "identityfile") .map((path) => path.replace(/^"|"$/g, "")) .filter((path) => existsSync(expandUserPath(path))); const defaultMode = identityFiles.length > 0 ? "key" : "password"; const modeInput = (await question(`Authentication [key/password] (${defaultMode}): `)).toLowerCase(); const mode = modeInput || defaultMode; let auth; if (mode === "key") { const suggested = identityFiles[0] ?? ""; const identityFile = await question(`Private key path${suggested ? ` (${suggested})` : ""}: `) || suggested; if (!identityFile || !existsSync(expandUserPath(identityFile))) throw new Error(`private key does not exist: ${identityFile}`); const encrypted = await confirm("Does this private key require a passphrase?"); const passphrase = encrypted ? await hiddenQuestion("Private key passphrase: ") : undefined; if (encrypted && !passphrase) throw new Error("private key passphrase cannot be empty"); auth = { type: "private-key", identityFile, ...(passphrase ? { passphrase } : {}) }; } else if (mode === "password") { const password = await hiddenQuestion("Server password: "); if (!password) throw new Error("server password cannot be empty"); auth = { type: "password", password, method: "auto" }; } else { throw new Error("authentication must be key or password"); } console.log("Obtaining SSH host key fingerprint..."); const hostKey = await probeHostKey(hostName, port, user); console.log(`Host key: ${hostKey.algorithm} ${hostKey.fingerprint}`); if (!await confirm("Trust and pin this host key?")) throw new Error(`host key for ${alias} was not trusted`); const label = await question(`Display label (${config.hosts[alias]?.label ?? alias}): `) || config.hosts[alias]?.label || alias; const host = { label, sourceAlias: alias, hostName, user, port, auth, hostKey }; const transport = new Ssh2Transport(host); try { console.log("Testing SSH2 authentication..."); await transport.connect(); await probeRemotePath(transport, "home"); const remoteCwd = await probeRemotePath(transport, "cwd"); host.defaultCwd = remoteCwd; console.log(`Connected successfully; default cwd: ${remoteCwd}`); } finally { await transport.dispose(); } config.hosts[alias] = host; const groupId = await question("Group id (optional): "); if (groupId) { const groupLabel = config.groups?.[groupId]?.label || await question(`Group label (${groupId}): `) || groupId; config.groups ??= {}; const hosts = new Set(config.groups[groupId]?.hosts ?? []); hosts.add(alias); config.groups[groupId] = { label: groupLabel, hosts: [...hosts] }; } } async function main() { const [command = "list", ...arguments_] = process.argv.slice(2); if (command === "list") { const config = loadVaultOrEmpty(); const entries = Object.entries(config.hosts); if (entries.length === 0) { console.log("No pi-ssh hosts configured."); return; } for (const [id, host] of entries) console.log(`${id}\t${host.label ?? id}\t${host.user}@${host.hostName}:${host.port}\t${host.auth.type}`); return; } if (command === "rotate-key") { rotateVaultKey(); console.log("Rotated pi-ssh vault key."); return; } if (command === "remove") { const id = arguments_[0]; if (!id) throw new Error("usage: ssh_config.sh remove "); const config = loadVaultOrEmpty(); if (!config.hosts[id]) throw new Error(`unknown pi-ssh host: ${id}`); delete config.hosts[id]; for (const group of Object.values(config.groups ?? {})) group.hosts = group.hosts.filter((host) => host !== id); saveVault(config); console.log(`Removed ${id}.`); return; } if (command !== "import" && command !== "update") { throw new Error("usage: ssh_config.sh [list|import [alias...]|update |remove |rotate-key]"); } const config = loadVaultOrEmpty(); const aliases = command === "update" ? [arguments_[0] || (() => { throw new Error("usage: ssh_config.sh update "); })()] : await chooseAliases(arguments_); for (const alias of aliases) await importHost(config, alias); saveVault(config); console.log(`Saved ${aliases.length} host(s) to the encrypted pi-ssh vault.`); } main().catch((error) => { console.error(`pi-ssh config: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; });