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:
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user