mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
204 lines
8.4 KiB
JavaScript
Executable File
204 lines
8.4 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 {
|
|
effectiveValue,
|
|
effectiveValues,
|
|
listDirectSshAliases,
|
|
resolveOpenSshAlias,
|
|
} from "../src/import.ts";
|
|
import { expandUserPath } from "../src/config.ts";
|
|
import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.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();
|
|
const result = await transport.capture('printf "%s\\n%s" "$HOME" "$(pwd)"', ".", 20);
|
|
if (result.exitCode !== 0) throw new Error("remote HOME/cwd probe failed");
|
|
const [remoteHome, remoteCwd] = result.output.toString("utf8").trim().split(/\r?\n/, 2);
|
|
if (!remoteHome || !remoteCwd) throw new Error("remote HOME/cwd probe returned incomplete output");
|
|
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;
|
|
});
|