/** * SSH Key Manager * Handles temporary SSH key files for Ansible execution * Keys are stored encrypted in database, written temporarily to filesystem for Ansible */ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getMachineSshKey } from './machine-pool'; /** * Get the temporary SSH keys directory * Uses OS temp directory to avoid EROFS issues with read-only data mounts */ function getTempKeysDir(): string { return join(tmpdir(), 'celilo-ansible-keys'); } /** * The address the management box registers itself under in its own machine pool. * * celilo-mgr deploys to ITSELF, so it appears in the pool as a machine — but it * has no stored SSH key and must be reached over Ansible's local connection * rather than `ssh root@127.0.0.1`. Three call sites need to know this * (`inventory.ts`, `aspect-runner.ts`, `module-deploy.ts`); one of them silently * didn't, and celilo-mgmt could not complete a self-deploy as a result. Exported * so the next caller finds it instead of retyping the literal. */ export const LOCAL_MACHINE_IP = '127.0.0.1'; /** * Ensure the temp keys directory exists */ function ensureTempKeysDir(): void { const dir = getTempKeysDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); } } /** * Get the temporary key file path for a machine */ export function getTempKeyPath(machineId: string): string { return join(getTempKeysDir(), `machine-${machineId}.key`); } /** * Write a temporary SSH key file for Ansible * The key is decrypted from the database and written to a temporary file * * @param machineId - Machine ID * @returns Absolute path to the temporary key file */ export async function writeTemporarySshKey(machineId: string): Promise { ensureTempKeysDir(); // Get decrypted SSH key from database const keyContent = await getMachineSshKey(machineId); // Refuse to write an empty key. A machine with no stored key (e.g. the // management box, which registers itself as 127.0.0.1 and is reached by // Ansible's LOCAL connection) previously produced a 0-byte file here; ssh // then failed far downstream with "Load key ...: error in libcrypto" and the // host went UNREACHABLE, naming neither the machine nor the missing key. // Callers that legitimately have no key must not ask for one. if (keyContent.trim() === '') { throw new Error( `Machine ${machineId} has no SSH key stored, so no key file can be written. If this is the local management box, use an Ansible local connection (InventoryHost.local) instead of pinning a key.`, ); } // Write to temporary file with restrictive permissions const keyPath = getTempKeyPath(machineId); writeFileSync(keyPath, keyContent, { mode: 0o600 }); return keyPath; } /** * Delete a temporary SSH key file * * @param machineId - Machine ID */ export function deleteTemporarySshKey(machineId: string): void { const keyPath = getTempKeyPath(machineId); if (existsSync(keyPath)) { rmSync(keyPath, { force: true }); } } /** * Clean up all temporary SSH key files * Should be called after Ansible execution or on process exit */ export function cleanupTemporarySshKeys(): void { const dir = getTempKeysDir(); if (!existsSync(dir)) { return; } // Remove all .key files const files = readdirSync(dir); for (const file of files) { if (file.endsWith('.key')) { const filePath = join(dir, file); rmSync(filePath, { force: true }); } } } /** * Register cleanup handlers for process exit * Ensures temporary SSH keys are always cleaned up */ export function registerCleanupHandlers(): void { // Clean up on normal exit process.on('exit', () => { try { cleanupTemporarySshKeys(); } catch (error) { console.error('Failed to cleanup SSH keys on exit:', error); } }); // Clean up on SIGINT (Ctrl+C) process.on('SIGINT', () => { try { cleanupTemporarySshKeys(); } catch (error) { console.error('Failed to cleanup SSH keys on SIGINT:', error); } process.exit(130); // Standard exit code for SIGINT }); // Clean up on SIGTERM process.on('SIGTERM', () => { try { cleanupTemporarySshKeys(); } catch (error) { console.error('Failed to cleanup SSH keys on SIGTERM:', error); } process.exit(143); // Standard exit code for SIGTERM }); // Clean up on uncaught exception process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); try { cleanupTemporarySshKeys(); } catch (cleanupError) { console.error('Failed to cleanup SSH keys on uncaught exception:', cleanupError); } process.exit(1); }); } /** * Managed SSH key context for safe cleanup * Use this for automatic cleanup in try-finally patterns * * @example * const keyPath = await writeTemporarySshKey(machineId); * try { * await runAnsible(keyPath); * } finally { * deleteTemporarySshKey(machineId); * } */ export class ManagedSshKey { private machineId: string; private keyPath: string | null = null; constructor(machineId: string) { this.machineId = machineId; } /** * Write the SSH key and return the path */ async write(): Promise { this.keyPath = await writeTemporarySshKey(this.machineId); return this.keyPath; } /** * Get the key path (throws if not written yet) */ getPath(): string { if (!this.keyPath) { throw new Error('SSH key not written yet. Call write() first.'); } return this.keyPath; } /** * Clean up the temporary key file */ cleanup(): void { if (this.keyPath) { deleteTemporarySshKey(this.machineId); this.keyPath = null; } } /** * Use the key in a callback with automatic cleanup */ async use(callback: (keyPath: string) => Promise): Promise { await this.write(); try { return await callback(this.getPath()); } finally { this.cleanup(); } } }