/** * System Initialization Service * * Handles first-time system configuration with sensible defaults. * Supports both interactive and non-interactive (--accept-defaults) modes. */ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { systemConfig } from '../db/schema'; /** * System configuration schema interface */ export interface SystemConfigSchema { properties: Record< string, { type: string; default?: string | number; description?: string; pattern?: string; minimum?: number; maximum?: number; format?: string; /** * A starting offer for an interview, deliberately NOT a `default`. * * `getDefaultConfiguration()` seeds every `default:` it finds at `system * init`, and network addressing is specifically not seeded * (openspec/specs/progressive-zone-disclosure/spec.md). A separate field * is structurally incapable of becoming a row nobody chose: it is only * ever read to pre-fill a question the operator still has to answer * ([[services/network-ensure.ts]]). */ suggested?: string; } >; } /** * Load system config schema from JSON file */ export function loadSchema(): SystemConfigSchema { // Try common locations (relative to this file's directory and cwd) const thisDir = dirname(new URL(import.meta.url).pathname); const candidates = [ join(thisDir, '..', '..', 'schemas', 'system_config.json'), // Relative to src/services/ → apps/celilo/schemas/ './schemas/system_config.json', // From cwd (apps/celilo) join(process.cwd(), 'schemas', 'system_config.json'), ]; for (const candidate of candidates) { if (existsSync(candidate)) { try { return JSON.parse(readFileSync(candidate, 'utf-8')); } catch (error) { throw new Error( `Failed to parse system_config.json schema: ${error instanceof Error ? error.message : 'Invalid JSON'}`, ); } } } throw new Error('Could not find system_config.json schema file'); } /** * Get default configuration values from schema */ export function getDefaultConfiguration(): Record { const schema = loadSchema(); const defaults: Record = {}; for (const [key, property] of Object.entries(schema.properties)) { if (property.default !== undefined) { defaults[key] = property.default; } } return defaults; } /** * Compute gateway IP from subnet CIDR * Returns the first usable IP address in the subnet * * @param subnet - CIDR notation (e.g., "10.0.10.0/24") * @returns Gateway IP (e.g., "10.0.10.1") */ export function computeGateway(subnet: string): string { const [network, _bits] = subnet.split('/'); const octets = network.split('.').map(Number); // First usable IP (network address + 1) octets[3] += 1; return octets.join('.'); } /** * Auto-detect SSH public key from common locations * * Checks ~/.ssh/ for common key files: * - id_ed25519.pub (preferred) * - id_rsa.pub * - id_ecdsa.pub * * @returns SSH public key content or null if none found */ export interface DetectedSSHKey { filename: string; keyType: string; content: string; } export function autoDetectSSHKeys(): DetectedSSHKey[] { const sshDir = join(homedir(), '.ssh'); const keyFiles = ['id_ed25519.pub', 'id_rsa.pub', 'id_ecdsa.pub']; const keys: DetectedSSHKey[] = []; for (const keyFile of keyFiles) { const keyPath = join(sshDir, keyFile); if (existsSync(keyPath)) { try { const keyContent = readFileSync(keyPath, 'utf-8').trim(); if (keyContent) { const keyType = keyContent.split(' ')[0] || keyFile; keys.push({ filename: keyFile, keyType, content: keyContent }); } } catch {} } } return keys; } /** * Initialize system configuration with defaults * * This function does all the "smart" work: * - Loads defaults from schema * - Computes gateway IPs from subnets * - Derives admin email from primary domain * - Auto-detects SSH keys * - Applies user overrides * - Stores everything in the database * * @param db - Database instance * @param overrides - Optional overrides for specific keys (used for interactive mode) * @returns Configuration that was applied (for summary display) */ export function initializeSystem( db: DbClient, overrides: Partial> = {}, ): Record { const defaults = getDefaultConfiguration(); // Start with defaults let config = { ...defaults }; // Apply user overrides (filter out undefined values) const definedOverrides = Object.fromEntries( Object.entries(overrides).filter(([_, v]) => v !== undefined), ) as Record; config = { ...config, ...definedOverrides }; // Auto-detect SSH key if not provided if (!config['ssh.public_key']) { const detectedKeys = autoDetectSSHKeys(); if (detectedKeys.length > 0) { config['ssh.public_key'] = detectedKeys[0].content; } } // Compute gateway IPs from subnets (if subnet was provided but not gateway) const zones = ['dmz', 'app', 'secure', 'internal']; for (const zone of zones) { const subnetKey = `network.${zone}.subnet`; const gatewayKey = `network.${zone}.gateway`; // If user provided a subnet, compute gateway from it if (config[subnetKey] && !overrides[gatewayKey]) { config[gatewayKey] = computeGateway(String(config[subnetKey])); } } // primary_domain and admin.email are no longer system config — they live // in the dns_registrar capability and in the authentik/caddy modules // respectively as of MANIFEST_V2 Phase 2 (D9). // Store all configuration in database for (const [key, value] of Object.entries(config)) { if (value === undefined || value === null) { continue; // Skip undefined/null values } const valueStr = String(value); db.insert(systemConfig) .values({ key, value: valueStr }) .onConflictDoUpdate({ target: systemConfig.key, set: { value: valueStr }, }) .run(); } // Explicit init marker. Network/DNS addressing is no longer seeded with // defaults (openspec/specs/progressive-zone-disclosure/spec.md), so we can't key // "is this system initialized?" off a network row anymore. Write a // dedicated sentinel instead. additionalProperties:true in the schema // permits this non-schema key. db.insert(systemConfig) .values({ key: 'system.initialized', value: 'true' }) .onConflictDoUpdate({ target: systemConfig.key, set: { value: 'true' }, }) .run(); return config; } /** * Load existing system configuration from database * * @param db - Database instance * @returns Map of existing configuration values */ export function loadExistingConfiguration(db: DbClient): Record { const existing: Record = {}; const rows = db.select().from(systemConfig).all(); for (const row of rows) { existing[row.key] = row.value; } return existing; } /** * Check if system is already initialized * * Keys off the explicit `system.initialized` sentinel written by * initializeSystem(). Earlier this checked for a seeded network zone * subnet, but network/DNS addressing is no longer defaulted * (openspec/specs/progressive-zone-disclosure/spec.md), so a fresh init leaves those * rows absent. * * @param db - Database instance * @returns true if initializeSystem has run against this database */ export function isSystemInitialized(db: DbClient): boolean { const result = db .select() .from(systemConfig) .where(eq(systemConfig.key, 'system.initialized')) .get(); return result?.value === 'true'; }