/** * celilo's fleet SSH keypair — the key celilo authenticates to managed * machines with. The DB carries only the public half (`ssh.public_key`); * the private half lives on disk and never leaves the management box. * * It used to be minted by celilo-mgmt's `on_install`, which derived * `dirname(config.db_path)` and wrote into celilo's data directory from * inside a module hook. That is a write into the one directory the hook * jail exists to keep out of the mount set * (openspec/changes/hook-process-boundary, design D9b): staging covers * copies OUT of celilo's state, and nothing covers writes IN. * * So minting moved here. celilo owns the key's lifecycle, and "create it * if absent" wants to be idempotent and tested once rather than in each * module that reaches for it. */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getDbPath } from '../config/paths'; /** * Where the fleet keypair lives. * * Next to the DB, not under `getDataDir()`. Those are the same directory * on a deb install (`CELILO_DATA_DIR=/var/celilo`, db at * `/var/celilo/celilo.db`) and differ only when `CELILO_DB_PATH` points * somewhere custom. Following the DB is what `on_install` did, so it is * where every existing box's key already sits, and restore has followed * the same rule since it was written (`applyStagedSystemFiles`). * * One exported helper rather than two hand-derived joins is the point: * mint and restore can no longer drift to different directories. */ export function getFleetSshDir(): string { return join(dirname(getDbPath()), '.ssh'); } export interface FleetKey { /** The public half, as it goes into `ssh.public_key` and authorized_keys. */ publicKey: string; /** True when this call minted the key; false when it was already there. */ created: boolean; } /** * Ensure the fleet keypair exists and return its public half. * * Idempotent: an existing key is reused, never regenerated. Re-keying * would silently strand every machine whose authorized_keys holds the old * public half, and a redeploy must not do that. * * Permissions are left to `mkdirSync`'s mode and to ssh-keygen, which * `fchmod`s the private half to 0600 itself. An explicit chmod pass was * written here first and then removed: umask only ever REMOVES mode bits, so * neither the directory nor the key can come out wider than asked for, and no * test could be made to fail without it. */ export function ensureFleetKey(): FleetKey { const sshDir = getFleetSshDir(); const keyPath = join(sshDir, 'id_ed25519'); const publicKeyPath = `${keyPath}.pub`; if (existsSync(publicKeyPath)) { return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: false }; } mkdirSync(sshDir, { recursive: true, mode: 0o700 }); execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-C', 'celilo-fleet'], { stdio: 'pipe', }); return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true }; } /** * Every directory a fleet private key may sit in, in priority order. * * Exported so an error message names exactly what was searched. The message * used to say "~/.ssh/" while the search had already been widened, which is a * smaller version of the bug this whole helper exists to prevent. */ export function fleetKeySearchDirs(): string[] { return [getFleetSshDir(), join(process.env.HOME ?? '~', '.ssh')]; } /** * The private key whose public half is `publicKey`, or null. * * Searches celilo's own key directory FIRST and `$HOME/.ssh` second, and the * order is the point. Two different keys are normally present: the one * `ensureFleetKey` minted, which is what celilo authorizes on managed * machines, and whatever keypair the operator or the base image left in the * home directory. Only the first can open a machine celilo provisioned. * * This lives beside `getFleetSshDir` for the reason that helper exists. The * caller that searched for the key hand-derived its own `join(HOME, '.ssh')` * and therefore could not see a minted key at all (celilo#1240). * * Matching ignores the trailing comment, because ssh-keygen writes * `user@host` there and the recorded public half carries `celilo-fleet`. */ export function findFleetPrivateKey(publicKey: string): string | null { const [type, material] = publicKey.trim().split(/\s+/); if (!type || !material) return null; const byType: Record = { 'ssh-ed25519': ['id_ed25519'], 'ssh-rsa': ['id_rsa'], 'ecdsa-sha2-nistp256': ['id_ecdsa'], 'ecdsa-sha2-nistp384': ['id_ecdsa'], 'ecdsa-sha2-nistp521': ['id_ecdsa'], }; const candidates = byType[type] ?? ['id_ed25519', 'id_rsa', 'id_ecdsa']; for (const dir of fleetKeySearchDirs()) { if (!existsSync(dir)) continue; for (const name of candidates) { const keyPath = join(dir, name); const pubPath = `${keyPath}.pub`; if (!existsSync(keyPath) || !existsSync(pubPath)) continue; const [fileType, fileMaterial] = readFileSync(pubPath, 'utf-8').trim().split(/\s+/); if (fileType === type && fileMaterial === material) return keyPath; } } return null; }