commit 616a2d9c9639ce1a6e84428f28eeade7d6814e24 Author: Karolis2011 Date: Sat Nov 29 21:18:32 2025 +0200 Add SSH configuration script and update Deno configuration files diff --git a/configure_ssh_key.ts b/configure_ssh_key.ts new file mode 100755 index 0000000..9f50239 --- /dev/null +++ b/configure_ssh_key.ts @@ -0,0 +1,529 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env +/** + * configure_ssh_key.ts + * Add or update an SSH config entry for a specific host/user using a specified key. + * Can optionally generate the key if it does not exist. + * + * Usage: + * ./configure_ssh_key.ts alice@example.com --key ~/.ssh/id_alice_example --alias work-example + * ./configure_ssh_key.ts alice@example.com --generate --alias work-example + * ./configure_ssh_key.ts alice@example.com --alias work-example + * ./configure_ssh_key.ts alice@example.com:2222 + * + * Required positional target: + * user@host[:port] + * + * Options: + * --key /path/to/key Path to private key + * --generate Generate a new ed25519 key if missing + * --alias HOST_ALIAS Host alias (defaults to USER-HOST simplified) + * --port PORT Override / set SSH port + * --force Overwrite existing config block for this alias + * --dry-run Show intended changes without writing + * --help Show help + * + * If neither --key nor --generate is specified, you'll be prompted to select + * from available keys in ~/.ssh or ssh-agent. + */ + +import { parseArgs } from "@std/cli/parse-args"; +import { expandGlob } from "@std/fs/expand-glob"; +import { join } from "@std/path"; + +interface ParsedTarget { + user: string; + host: string; + port?: number; +} + +interface Options { + keyPath?: string; + alias?: string; + generate: boolean; + port?: number; + force: boolean; + dryRun: boolean; + isAgentKey?: boolean; // Track if key only exists in ssh-agent +} + +interface AgentKey { + keyType: string; + publicKey: string; + comment: string; + displayName: string; +} + +const HOME = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || ""; +const SSH_DIR = join(HOME, ".ssh"); +const CONFIG_FILE = join(SSH_DIR, "config"); + +function printHelp() { + console.log(` +configure_ssh_key.ts - SSH Config Manager + +Usage: + ./configure_ssh_key.ts user@host[:port] [options] + +Examples: + ./configure_ssh_key.ts alice@example.com --key ~/.ssh/id_alice_example --alias work-example + ./configure_ssh_key.ts alice@example.com --generate --alias work-example + ./configure_ssh_key.ts alice@example.com --alias work-example + ./configure_ssh_key.ts alice@example.com:2222 + +Options: + --key PATH Path to private key + --generate Generate a new ed25519 key if missing + --alias NAME Host alias (defaults to user-host) + --port PORT Override SSH port + --force Overwrite existing config block + --dry-run Preview changes without writing + --help, -h Show this help + +Note: If neither --key nor --generate is specified, you'll be prompted to +select from available keys in ~/.ssh or ssh-agent. +`); +} + +function parseTarget(target: string): ParsedTarget { + // user@host:port + let match = target.match(/^([^@]+)@([^:]+):(\d+)$/); + if (match) { + return { user: match[1], host: match[2], port: parseInt(match[3]) }; + } + + // user@host + match = target.match(/^([^@]+)@([^:]+)$/); + if (match) { + return { user: match[1], host: match[2] }; + } + + // host:port + match = target.match(/^([^:]+):(\d+)$/); + if (match) { + throw new Error("Target must include user (format: user@host[:port])"); + } + + throw new Error("Invalid target format. Use: user@host[:port]"); +} + +async function findPrivateKeys(): Promise { + const keys: string[] = []; + + try { + for await (const entry of expandGlob(`${SSH_DIR}/id_*`, { includeDirs: false })) { + if (!entry.name.endsWith(".pub")) { + keys.push(entry.path); + } + } + for await (const entry of expandGlob(`${SSH_DIR}/*.pem`, { includeDirs: false })) { + keys.push(entry.path); + } + } catch { + // Directory might not exist yet + } + + return keys.sort(); +} + +async function listAgentKeys(): Promise { + const cmd = new Deno.Command("ssh-add", { + args: ["-L"], + stdout: "piped", + stderr: "piped", + }); + + const { success, stdout } = await cmd.output(); + if (!success) { + return []; + } + + const output = new TextDecoder().decode(stdout); + const lines = output.trim().split("\n").filter(line => line.length > 0); + + return lines.map(line => { + const parts = line.split(" "); + const keyType = parts[0] || ""; + const publicKey = parts[1] || ""; + const comment = parts.slice(2).join(" ") || "(no comment)"; + const displayName = comment.replace(/[/: ]/g, "_").toLowerCase(); + + return { keyType, publicKey, comment, displayName }; + }); +} + +async function generateKey(keyPath: string, user: string, host: string) { + try { + await Deno.stat(keyPath); + console.log(`Key already exists: ${keyPath} (skipping generation)`); + return; + } catch { + // Key doesn't exist, generate it + } + + console.log(`Generating ed25519 key: ${keyPath}`); + + const cmd = new Deno.Command("ssh-keygen", { + args: [ + "-t", "ed25519", + "-a", "100", + "-f", keyPath, + "-C", `${user}@${host}`, + "-N", "", + ], + }); + + const { success } = await cmd.output(); + if (!success) { + throw new Error("Failed to generate SSH key"); + } +} + +async function ensurePermissions(keyPath: string) { + // Verify this is actually a private key file (not .pub) + if (keyPath.endsWith(".pub")) { + throw new Error("Key path should point to private key, not .pub file"); + } + + // Set private key permissions + try { + await Deno.chmod(keyPath, 0o600); + } catch (e) { + // If file doesn't exist yet (e.g., agent key), that's ok + if (!(e instanceof Deno.errors.NotFound)) { + throw e; + } + } + + // Set public key permissions if it exists + const pubKeyPath = `${keyPath}.pub`; + try { + await Deno.stat(pubKeyPath); + await Deno.chmod(pubKeyPath, 0o644); + } catch { + // .pub file doesn't exist, that's ok + } +} + +function generateConfigBlock( + alias: string, + host: string, + user: string, + keyPath: string, + port?: number, +): string { + const lines = [ + `Host ${alias}`, + ` HostName ${host}`, + ` User ${user}`, + ]; + + if (port) { + lines.push(` Port ${port}`); + } + + lines.push( + ` IdentityFile ${keyPath}`, + ` IdentitiesOnly yes`, + ` PreferredAuthentications publickey`, + ); + + return lines.join("\n"); +} + +async function readConfig(): Promise { + try { + return await Deno.readTextFile(CONFIG_FILE); + } catch { + return ""; + } +} + +function removeHostBlock(config: string, alias: string): string { + const lines = config.split("\n"); + const result: string[] = []; + let skipping = false; + + for (const line of lines) { + if (line.match(/^Host\s+/)) { + const hostMatch = line.match(/^Host\s+(\S+)/); + if (hostMatch && hostMatch[1] === alias) { + skipping = true; + continue; + } else { + skipping = false; + } + } + + if (!skipping) { + result.push(line); + } + } + + return result.join("\n"); +} + +function hasHostBlock(config: string, alias: string): boolean { + const pattern = new RegExp(`^Host\\s+${alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "m"); + return pattern.test(config); +} + +async function backupConfig() { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T").join("_").slice(0, 19); + const backupPath = `${CONFIG_FILE}.bak.${timestamp}`; + await Deno.copyFile(CONFIG_FILE, backupPath); + } catch { + // Backup failed, continue anyway + } +} + +function expandTilde(path: string): string { + if (path.startsWith("~/")) { + return join(HOME, path.slice(2)); + } + return path; +} + +function normalizeKeyPath(path: string): string { + // Remove .pub extension if present - we always work with private key paths + return path.endsWith(".pub") ? path.slice(0, -4) : path; +} + +function simplifyForAlias(str: string): string { + return str.toLowerCase().replace(/[^a-z0-9]/g, "-"); +} + +// Utility: Check for mutually exclusive options +function checkExclusiveOptions(opts: boolean[]): boolean { + return opts.filter(Boolean).length <= 1; +} + +// Utility: Central error handler +function handleError(e: unknown) { + console.error(`Error: ${e instanceof Error ? e.message : String(e)}`); + Deno.exit(1); +} + +// Utility: Unified key selection +async function selectKeyUnified(options: Options): Promise<{ keyPath: string, isAgentKey: boolean }> { + if (options.keyPath) return { keyPath: options.keyPath, isAgentKey: false }; + + // Interactive combined selection (file keys + agent keys) + const fileKeys = await findPrivateKeys(); + const agentKeys = await listAgentKeys(); + // ...existing code for combinedKeys... + const fileKeyInfos: Array<{ path: string, pub: string | null }> = []; + for (const path of fileKeys) { + let pub: string | null = null; + try { + pub = await Deno.readTextFile(path + ".pub"); + } catch { + pub = null; + } + fileKeyInfos.push({ path, pub }); + } + async function agentKeyFilename(agentKey: AgentKey) { + const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(agentKey.publicKey)))) + .map(b => b.toString(16).padStart(2, "0")).join(""); + return `id_agent_${hash}`; + } + const combinedKeys: Array<{ type: "file" | "agent", path?: string, agentKey?: AgentKey, display: string, linkedFile?: string }> = []; + for (const fileInfo of fileKeyInfos) { + combinedKeys.push({ type: "file", path: fileInfo.path, display: `[file] ${fileInfo.path}` }); + } + for (const agentKey of agentKeys) { + let linkedFile: string | undefined = undefined; + for (const fileInfo of fileKeyInfos) { + if (fileInfo.pub && fileInfo.pub.includes(agentKey.publicKey)) { + linkedFile = fileInfo.path; + break; + } + } + combinedKeys.push({ + type: "agent", + agentKey, + display: `[agent] ${agentKey.comment} (${agentKey.keyType})${linkedFile ? ` [linked: ${linkedFile}]` : ''}`, + linkedFile + }); + } + if (combinedKeys.length === 0) { + throw new Error("No candidate private keys found in ~/.ssh or ssh-agent"); + } + console.log("\nAvailable keys:"); + combinedKeys.forEach((key, i) => { + console.log(` [${i}] ${key.display}`); + }); + let selectedIdx: number | undefined = undefined; + // Detect non-interactive environment + const isInteractive = typeof Deno.stdin.setRaw === "function" && Deno.stdin.isTerminal(); + if (!isInteractive) { + // If keyPath is not provided, fail in non-interactive mode + if (!options.keyPath) { + console.error("Error: Interactive key selection required, but running in non-interactive mode. Use --key to specify a key."); + Deno.exit(1); + } + // If keyPath is provided, select it automatically + selectedIdx = combinedKeys.findIndex(k => k.type === "file" && k.path === options.keyPath); + if (selectedIdx === -1) { + console.error(`Error: Specified key not found: ${options.keyPath}`); + Deno.exit(1); + } + } else { + while (selectedIdx === undefined) { + const input = prompt("\nSelect key number (or q to quit):"); + if (input === null || input.toLowerCase() === "q") { + console.error("Error: No key selected. Exiting."); + Deno.exit(1); + } + const num = parseInt(input ?? ""); + if (!isNaN(num) && num >= 0 && num < combinedKeys.length) { + selectedIdx = num; + } else { + console.error("Invalid selection. Try again."); + } + } + } + const selected = combinedKeys[selectedIdx]; + if (selected.type === "file" && selected.path) { + return { keyPath: selected.path, isAgentKey: false }; + } else if (selected.type === "agent" && selected.agentKey) { + if (selected.linkedFile) { + return { keyPath: selected.linkedFile, isAgentKey: false }; + } else { + const filename = await agentKeyFilename(selected.agentKey); + const pubKeyPath = join(SSH_DIR, `${filename}.pub`); + let pubKeyExists = false; + try { + await Deno.stat(pubKeyPath); + pubKeyExists = true; + } catch { + // .pub file does not exist + } + if (!pubKeyExists) { + const pubKeyContent = `${selected.agentKey.keyType} ${selected.agentKey.publicKey} ${selected.agentKey.comment}\n`; + await Deno.writeTextFile(pubKeyPath, pubKeyContent); + await Deno.chmod(pubKeyPath, 0o644); + console.log(`\n✅ Saved public key to: ${pubKeyPath}`); + } + // Do not create a dummy private key file for agent keys + return { keyPath: pubKeyPath, isAgentKey: true }; + } + } + throw new Error("No key path selected or generated."); +} + +async function main() { + try { + const args = parseArgs(Deno.args, { + string: ["key", "alias", "port"], + boolean: ["generate", "force", "dry-run", "help", "h"], + alias: { h: "help" }, + "--": true, + }); + + if (args.help || args.h) { + printHelp(); + Deno.exit(0); + } + + const targetSpec = args._[0]?.toString(); + if (!targetSpec) { + throw new Error("Missing required target (user@host[:port])\nRun with --help for usage information"); + } + + let target: ParsedTarget; + try { + target = parseTarget(targetSpec); + } catch (e) { + throw e; + } + + if (args.port) { + target.port = parseInt(args.port); + } + + const keyProvided = !!args.key; + const generate = args.generate || false; + if (!checkExclusiveOptions([keyProvided, generate])) { + throw new Error("Use only one of --key or --generate"); + } + + const options: Options = { + keyPath: args.key ? normalizeKeyPath(expandTilde(args.key)) : undefined, + alias: args.alias, + generate, + port: target.port, + force: args.force || false, + dryRun: args["dry-run"] || false, + }; + + if (!options.alias) { + options.alias = `${target.user}-${simplifyForAlias(target.host)}`; + } + + // Unified key selection + const keySelection = await selectKeyUnified(options); + options.keyPath = keySelection.keyPath; + options.isAgentKey = keySelection.isAgentKey; + + if (!options.keyPath) { + throw new Error("No key path selected or generated."); + } + + await Deno.mkdir(SSH_DIR, { recursive: true }); + + if (options.generate) { + await generateKey(options.keyPath, target.user, target.host); + } + + // Only verify file exists and set permissions for non-agent keys + if (!options.isAgentKey) { + try { + await Deno.stat(options.keyPath); + } catch { + throw new Error(`Private key not found at ${options.keyPath}`); + } + + await ensurePermissions(options.keyPath); + } + + const configBlock = generateConfigBlock( + options.alias!, + target.host, + target.user, + options.keyPath, + options.port, + ); + + let existingConfig = await readConfig(); + if (hasHostBlock(existingConfig, options.alias!)) { + if (!options.force) { + throw new Error(`Host block for '${options.alias}' already exists. Use --force to replace.`); + } + console.log(`Replacing existing Host block for '${options.alias}'.`); + existingConfig = removeHostBlock(existingConfig, options.alias!); + } + + if (options.dryRun) { + console.log("\n--- SSH Config Block ---\n"); + console.log(configBlock); + console.log("\n(Dry run: no changes written)"); + return; + } + + await backupConfig(); + const newConfig = existingConfig.trim().length > 0 + ? `${existingConfig.trim()}\n\n${configBlock}\n` + : `${configBlock}\n`; + await Deno.writeTextFile(CONFIG_FILE, newConfig); + console.log(`\n✅ SSH config updated: ${CONFIG_FILE}`); + console.log(`\nTo connect, use: ssh ${options.alias}`); + } catch (e) { + handleError(e); + } +} + +// Run main function +if (import.meta.main) { + main(); +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f94bcbb --- /dev/null +++ b/deno.json @@ -0,0 +1,10 @@ +{ + "imports": { + "@std/cli": "jsr:@std/cli@^1.0.0", + "@std/fs": "jsr:@std/fs@^1.0.0", + "@std/path": "jsr:@std/path@^1.0.0" + }, + "tasks": { + "configure": "deno run --allow-read --allow-write --allow-run --allow-env configure_ssh_key.ts" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..963dfa5 --- /dev/null +++ b/deno.lock @@ -0,0 +1,38 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/cli@1": "1.0.24", + "jsr:@std/fs@1": "1.0.20", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/path@1": "1.1.3", + "jsr:@std/path@^1.1.3": "1.1.3" + }, + "jsr": { + "@std/cli@1.0.24": { + "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" + }, + "@std/fs@1.0.20": { + "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path@^1.1.3" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/path@1.1.3": { + "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/cli@1", + "jsr:@std/fs@1", + "jsr:@std/path@1" + ] + } +}