/** * Tests for SSH key manager */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { resetTestDbPath } from '../test-utils/db-path'; import { addMachine } from './machine-pool'; import { LOCAL_MACHINE_IP, ManagedSshKey, cleanupTemporarySshKeys, deleteTemporarySshKey, writeTemporarySshKey, } from './ssh-key-manager'; describe('a machine with no stored SSH key fails loudly, not silently', () => { // Regression: the management box registers itself in the machine pool as // 127.0.0.1 with NO ssh key -- it does not need one, because Ansible reaches // it over the local connection. writeTemporarySshKey happily wrote a 0-byte // file, and the failure only surfaced much later inside Ansible as // Load key "/tmp/celilo-ansible-keys/machine-.key": error in libcrypto // root@127.0.0.1: Permission denied (publickey) // naming neither the machine nor the missing key. The host went UNREACHABLE // and celilo could not configure its own resolv.conf. let testDir2: string; beforeEach(async () => { testDir2 = mkdtempSync(join(tmpdir(), 'celilo-nokey-')); process.env.CELILO_DB_PATH = join(testDir2, 'test.db'); process.env.CELILO_DATA_DIR = join(testDir2, 'data'); await runMigrations(join(testDir2, 'test.db')); const masterKeyPath = join(testDir2, 'data', 'master.key'); process.env.CELILO_MASTER_KEY_PATH = masterKeyPath; const fs = await import('node:fs/promises'); await fs.mkdir(join(testDir2, 'data'), { recursive: true }); await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8'); }); afterEach(() => { closeDb(); rmSync(testDir2, { recursive: true, force: true }); }); it('throws instead of writing a 0-byte key file', async () => { const machine = await addMachine({ hostname: 'celilo-mgr', zone: 'internal', ipAddress: '127.0.0.1', sshUser: 'root', sshKey: '', hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 100 }, role: 'host', interfaces: [], }); await expect(writeTemporarySshKey(machine.id)).rejects.toThrow(/has no SSH key stored/); }); }); describe('ssh-key-manager', () => { let testDbPath: string; let testDir: string; let dataDir: string; beforeEach(async () => { // Create temp directory for test database and data testDir = mkdtempSync(join(tmpdir(), 'celilo-test-')); testDbPath = join(testDir, 'test.db'); dataDir = join(testDir, 'data'); // Set environment variables process.env.CELILO_DB_PATH = testDbPath; process.env.CELILO_DATA_DIR = dataDir; // Initialize database and run migrations await runMigrations(testDbPath); // Create a dummy master key for encryption const masterKeyPath = join(dataDir, 'master.key'); process.env.CELILO_MASTER_KEY_PATH = masterKeyPath; const fs = await import('node:fs/promises'); await fs.mkdir(dataDir, { recursive: true }); await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8'); }); afterEach(() => { // Close database connection closeDb(); // Clean up test directory if (testDir) { rmSync(testDir, { recursive: true, force: true }); } // Clear environment variables resetTestDbPath(); delete process.env.CELILO_DATA_DIR; delete process.env.CELILO_MASTER_KEY_PATH; }); describe('writeTemporarySshKey', () => { it('creates temp directory and writes key file', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-ssh-key-content', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const keyPath = await writeTemporarySshKey(machine.id); // Check file exists expect(existsSync(keyPath)).toBe(true); // Check file content is decrypted const content = readFileSync(keyPath, 'utf8'); expect(content).toBe('test-ssh-key-content'); // Check file permissions (should be 0o600) const stats = statSync(keyPath); const mode = stats.mode & 0o777; expect(mode).toBe(0o600); }); it('returns correct temp key path', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-ssh-key-content', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const keyPath = await writeTemporarySshKey(machine.id); expect(keyPath).toContain('celilo-ansible-keys'); expect(keyPath).toContain(`machine-${machine.id}.key`); }); }); describe('deleteTemporarySshKey', () => { it('removes temp key file', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-ssh-key-content', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const keyPath = await writeTemporarySshKey(machine.id); expect(existsSync(keyPath)).toBe(true); deleteTemporarySshKey(machine.id); expect(existsSync(keyPath)).toBe(false); }); it('does not throw if key file does not exist', () => { expect(() => deleteTemporarySshKey('non-existent-machine')).not.toThrow(); }); }); describe('cleanupTemporarySshKeys', () => { it('removes all temp key files', async () => { const machine1 = await addMachine({ hostname: 'machine1', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'key1', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const machine2 = await addMachine({ hostname: 'machine2', zone: 'internal', ipAddress: '192.168.1.101', sshUser: 'root', sshKey: 'key2', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const keyPath1 = await writeTemporarySshKey(machine1.id); const keyPath2 = await writeTemporarySshKey(machine2.id); expect(existsSync(keyPath1)).toBe(true); expect(existsSync(keyPath2)).toBe(true); cleanupTemporarySshKeys(); expect(existsSync(keyPath1)).toBe(false); expect(existsSync(keyPath2)).toBe(false); }); it('does not throw if temp directory does not exist', () => { expect(() => cleanupTemporarySshKeys()).not.toThrow(); }); }); describe('ManagedSshKey', () => { it('writes key and provides path', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const managedKey = new ManagedSshKey(machine.id); const keyPath = await managedKey.write(); expect(existsSync(keyPath)).toBe(true); expect(managedKey.getPath()).toBe(keyPath); }); it('cleans up key file', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const managedKey = new ManagedSshKey(machine.id); const keyPath = await managedKey.write(); expect(existsSync(keyPath)).toBe(true); managedKey.cleanup(); expect(existsSync(keyPath)).toBe(false); }); it('use() method handles automatic cleanup', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const managedKey = new ManagedSshKey(machine.id); let capturedPath: string | null = null; await managedKey.use(async (keyPath) => { capturedPath = keyPath; expect(existsSync(keyPath)).toBe(true); }); // Key should be cleaned up after callback expect(capturedPath).not.toBeNull(); // biome-ignore lint/style/noNonNullAssertion: checked with not.toBeNull() above expect(existsSync(capturedPath!)).toBe(false); }); it('use() method cleans up even if callback throws', async () => { const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, role: 'host', interfaces: [], }); const managedKey = new ManagedSshKey(machine.id); let capturedPath: string | null = null; await expect( managedKey.use(async (keyPath) => { capturedPath = keyPath; throw new Error('Test error'); }), ).rejects.toThrow('Test error'); // Key should still be cleaned up despite error expect(capturedPath).not.toBeNull(); // biome-ignore lint/style/noNonNullAssertion: checked with not.toBeNull() above expect(existsSync(capturedPath!)).toBe(false); }); it('throws if getPath() called before write()', () => { const managedKey = new ManagedSshKey('test-machine-id'); expect(() => managedKey.getPath()).toThrow('SSH key not written yet'); }); }); }); // The management box is reached over Ansible's local connection, never by // `ssh root@127.0.0.1`, so callers must skip key materialization for it. Three // call sites decide this — inventory.ts, aspect-runner.ts and module-deploy.ts — // and module-deploy.ts did NOT, so `celilo module deploy celilo-mgmt` threw // "has no SSH key stored" before Ansible ever ran and the control plane could // not self-deploy. The literal now lives in one place so a fourth caller finds // it instead of retyping it. describe('LOCAL_MACHINE_IP', () => { it('is the loopback address the management box registers itself under', () => { expect(LOCAL_MACHINE_IP).toBe('127.0.0.1'); }); });