Files
my-pi/pi-ssh/scripts/ssh-config.mjs

212 lines
8.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { existsSync } from "node:fs";
import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import {
effectiveIdentityAgent,
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 agentSocket = effectiveIdentityAgent(effective);
const agentAvailable = agentSocket && (agentSocket === "pageant" || existsSync(expandUserPath(agentSocket)));
const defaultMode = agentAvailable ? "agent" : identityFiles.length > 0 ? "key" : "password";
const modeInput = (await question(`Authentication [agent/key/password] (${defaultMode}): `)).toLowerCase();
const mode = modeInput || defaultMode;
let auth;
if (mode === "agent") {
const socketPath = await question(`SSH agent socket${agentSocket ? ` (${agentSocket})` : ""}: `) || agentSocket;
if (!socketPath || (socketPath !== "pageant" && !existsSync(expandUserPath(socketPath)))) {
throw new Error(`SSH agent socket does not exist: ${socketPath ?? ""}`);
}
auth = { type: "agent", socketPath };
} else 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 agent, 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 <host-id>");
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 <alias>|remove <id>|rotate-key]");
}
const config = loadVaultOrEmpty();
const aliases = command === "update"
? [arguments_[0] || (() => { throw new Error("usage: ssh_config.sh update <alias>"); })()]
: 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;
});