/** * Database layer for Onebox using SQLite */ import * as plugins from './onebox.plugins.ts'; import type { IService, IRegistry, INginxConfig, ISslCertificate, IDnsRecord, IMetric, ILogEntry, IUser, ISetting, } from './onebox.types.ts'; import { logger } from './onebox.logging.ts'; export class OneboxDatabase { private db: plugins.sqlite.DB | null = null; private dbPath: string; constructor(dbPath = '/var/lib/onebox/onebox.db') { this.dbPath = dbPath; } /** * Initialize database connection and create tables */ async init(): Promise { try { // Ensure data directory exists const dbDir = plugins.path.dirname(this.dbPath); await Deno.mkdir(dbDir, { recursive: true }); // Open database this.db = new plugins.sqlite.DB(this.dbPath); logger.info(`Database initialized at ${this.dbPath}`); // Create tables await this.createTables(); // Run migrations if needed await this.runMigrations(); } catch (error) { logger.error(`Failed to initialize database: ${error.message}`); throw error; } } /** * Create all database tables */ private async createTables(): Promise { if (!this.db) throw new Error('Database not initialized'); // Services table this.db.query(` CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, image TEXT NOT NULL, registry TEXT, env_vars TEXT NOT NULL, port INTEGER NOT NULL, domain TEXT, container_id TEXT, status TEXT NOT NULL DEFAULT 'stopped', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Registries table this.db.query(` CREATE TABLE IF NOT EXISTS registries ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, username TEXT NOT NULL, password_encrypted TEXT NOT NULL, created_at INTEGER NOT NULL ) `); // Nginx configs table this.db.query(` CREATE TABLE IF NOT EXISTS nginx_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, domain TEXT NOT NULL, port INTEGER NOT NULL, ssl_enabled INTEGER NOT NULL DEFAULT 0, config_template TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // SSL certificates table this.db.query(` CREATE TABLE IF NOT EXISTS ssl_certificates ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, cert_path TEXT NOT NULL, key_path TEXT NOT NULL, full_chain_path TEXT NOT NULL, expiry_date INTEGER NOT NULL, issuer TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // DNS records table this.db.query(` CREATE TABLE IF NOT EXISTS dns_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, type TEXT NOT NULL, value TEXT NOT NULL, cloudflare_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Metrics table this.db.query(` CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, cpu_percent REAL NOT NULL, memory_used INTEGER NOT NULL, memory_limit INTEGER NOT NULL, network_rx_bytes INTEGER NOT NULL, network_tx_bytes INTEGER NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // Create index for metrics queries this.db.query(` CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC) `); // Logs table this.db.query(` CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, message TEXT NOT NULL, level TEXT NOT NULL, source TEXT NOT NULL, FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ) `); // Create index for logs queries this.db.query(` CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC) `); // Users table this.db.query(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Settings table this.db.query(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ) `); // Version table for migrations this.db.query(` CREATE TABLE IF NOT EXISTS migrations ( version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL ) `); logger.debug('Database tables created successfully'); } /** * Run database migrations */ private async runMigrations(): Promise { if (!this.db) throw new Error('Database not initialized'); const currentVersion = this.getMigrationVersion(); logger.debug(`Current database version: ${currentVersion}`); // Add migration logic here as needed // For now, just set version to 1 if (currentVersion === 0) { this.setMigrationVersion(1); } } /** * Get current migration version */ private getMigrationVersion(): number { if (!this.db) throw new Error('Database not initialized'); try { const result = this.db.query('SELECT MAX(version) as version FROM migrations'); return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0; } catch { return 0; } } /** * Set migration version */ private setMigrationVersion(version: number): void { if (!this.db) throw new Error('Database not initialized'); this.db.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [ version, Date.now(), ]); logger.debug(`Migration version set to ${version}`); } /** * Close database connection */ close(): void { if (this.db) { this.db.close(); this.db = null; logger.debug('Database connection closed'); } } /** * Execute a raw query */ query(sql: string, params: unknown[] = []): T[] { if (!this.db) throw new Error('Database not initialized'); return this.db.query(sql, params) as T[]; } // ============ Services CRUD ============ async createService(service: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.db.query( `INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ service.name, service.image, service.registry || null, JSON.stringify(service.envVars), service.port, service.domain || null, service.containerID || null, service.status, now, now, ] ); return this.getServiceByName(service.name)!; } getServiceByName(name: string): IService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM services WHERE name = ?', [name]); return rows.length > 0 ? this.rowToService(rows[0]) : null; } getServiceByID(id: number): IService | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM services WHERE id = ?', [id]); return rows.length > 0 ? this.rowToService(rows[0]) : null; } getAllServices(): IService[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM services ORDER BY created_at DESC'); return rows.map((row) => this.rowToService(row)); } updateService(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: unknown[] = []; if (updates.image !== undefined) { fields.push('image = ?'); values.push(updates.image); } if (updates.registry !== undefined) { fields.push('registry = ?'); values.push(updates.registry); } if (updates.envVars !== undefined) { fields.push('env_vars = ?'); values.push(JSON.stringify(updates.envVars)); } if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); } if (updates.domain !== undefined) { fields.push('domain = ?'); values.push(updates.domain); } if (updates.containerID !== undefined) { fields.push('container_id = ?'); values.push(updates.containerID); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(id); this.db.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values); } deleteService(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.db.query('DELETE FROM services WHERE id = ?', [id]); } private rowToService(row: unknown[]): IService { return { id: Number(row[0]), name: String(row[1]), image: String(row[2]), registry: row[3] ? String(row[3]) : undefined, envVars: JSON.parse(String(row[4])), port: Number(row[5]), domain: row[6] ? String(row[6]) : undefined, containerID: row[7] ? String(row[7]) : undefined, status: String(row[8]) as IService['status'], createdAt: Number(row[9]), updatedAt: Number(row[10]), }; } // ============ Registries CRUD ============ async createRegistry(registry: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.db.query( 'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)', [registry.url, registry.username, registry.passwordEncrypted, now] ); return this.getRegistryByURL(registry.url)!; } getRegistryByURL(url: string): IRegistry | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM registries WHERE url = ?', [url]); return rows.length > 0 ? this.rowToRegistry(rows[0]) : null; } getAllRegistries(): IRegistry[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM registries ORDER BY created_at DESC'); return rows.map((row) => this.rowToRegistry(row)); } deleteRegistry(url: string): void { if (!this.db) throw new Error('Database not initialized'); this.db.query('DELETE FROM registries WHERE url = ?', [url]); } private rowToRegistry(row: unknown[]): IRegistry { return { id: Number(row[0]), url: String(row[1]), username: String(row[2]), passwordEncrypted: String(row[3]), createdAt: Number(row[4]), }; } // ============ Settings CRUD ============ getSetting(key: string): string | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT value FROM settings WHERE key = ?', [key]); return rows.length > 0 ? String(rows[0][0]) : null; } setSetting(key: string, value: string): void { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.db.query( 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', [key, value, now] ); } getAllSettings(): Record { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT key, value FROM settings'); const settings: Record = {}; for (const row of rows) { settings[String(row[0])] = String(row[1]); } return settings; } // ============ Users CRUD ============ async createUser(user: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.db.query( 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [user.username, user.passwordHash, user.role, now, now] ); return this.getUserByUsername(user.username)!; } getUserByUsername(username: string): IUser | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM users WHERE username = ?', [username]); return rows.length > 0 ? this.rowToUser(rows[0]) : null; } getAllUsers(): IUser[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM users ORDER BY created_at DESC'); return rows.map((row) => this.rowToUser(row)); } updateUserPassword(username: string, passwordHash: string): void { if (!this.db) throw new Error('Database not initialized'); this.db.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [ passwordHash, Date.now(), username, ]); } deleteUser(username: string): void { if (!this.db) throw new Error('Database not initialized'); this.db.query('DELETE FROM users WHERE username = ?', [username]); } private rowToUser(row: unknown[]): IUser { return { id: Number(row[0]), username: String(row[1]), passwordHash: String(row[2]), role: String(row[3]) as IUser['role'], createdAt: Number(row[4]), updatedAt: Number(row[5]), }; } // ============ Metrics ============ addMetric(metric: Omit): void { if (!this.db) throw new Error('Database not initialized'); this.db.query( `INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ metric.serviceId, metric.timestamp, metric.cpuPercent, metric.memoryUsed, metric.memoryLimit, metric.networkRxBytes, metric.networkTxBytes, ] ); } getMetrics(serviceId: number, limit = 100): IMetric[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query( 'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', [serviceId, limit] ); return rows.map((row) => this.rowToMetric(row)); } private rowToMetric(row: unknown[]): IMetric { return { id: Number(row[0]), serviceId: Number(row[1]), timestamp: Number(row[2]), cpuPercent: Number(row[3]), memoryUsed: Number(row[4]), memoryLimit: Number(row[5]), networkRxBytes: Number(row[6]), networkTxBytes: Number(row[7]), }; } // ============ Logs ============ addLog(log: Omit): void { if (!this.db) throw new Error('Database not initialized'); this.db.query( 'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)', [log.serviceId, log.timestamp, log.message, log.level, log.source] ); } getLogs(serviceId: number, limit = 1000): ILogEntry[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query( 'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', [serviceId, limit] ); return rows.map((row) => this.rowToLog(row)); } private rowToLog(row: unknown[]): ILogEntry { return { id: Number(row[0]), serviceId: Number(row[1]), timestamp: Number(row[2]), message: String(row[3]), level: String(row[4]) as ILogEntry['level'], source: String(row[5]) as ILogEntry['source'], }; } // ============ SSL Certificates ============ async createSSLCertificate(cert: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.db.query( `INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ cert.domain, cert.certPath, cert.keyPath, cert.fullChainPath, cert.expiryDate, cert.issuer, now, now, ] ); return this.getSSLCertificate(cert.domain)!; } getSSLCertificate(domain: string): ISslCertificate | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]); return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null; } getAllSSLCertificates(): ISslCertificate[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC'); return rows.map((row) => this.rowToSSLCert(row)); } updateSSLCertificate(domain: string, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); const fields: string[] = []; const values: unknown[] = []; if (updates.certPath) { fields.push('cert_path = ?'); values.push(updates.certPath); } if (updates.keyPath) { fields.push('key_path = ?'); values.push(updates.keyPath); } if (updates.fullChainPath) { fields.push('full_chain_path = ?'); values.push(updates.fullChainPath); } if (updates.expiryDate) { fields.push('expiry_date = ?'); values.push(updates.expiryDate); } fields.push('updated_at = ?'); values.push(Date.now()); values.push(domain); this.db.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values); } deleteSSLCertificate(domain: string): void { if (!this.db) throw new Error('Database not initialized'); this.db.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]); } private rowToSSLCert(row: unknown[]): ISslCertificate { return { id: Number(row[0]), domain: String(row[1]), certPath: String(row[2]), keyPath: String(row[3]), fullChainPath: String(row[4]), expiryDate: Number(row[5]), issuer: String(row[6]), createdAt: Number(row[7]), updatedAt: Number(row[8]), }; } }