/** * Database layer for Onebox using SQLite */ import * as plugins from '../plugins.ts'; import type { IService, IRegistry, INginxConfig, ISslCertificate, IDnsRecord, IMetric, ILogEntry, IUser, ISetting, } from '../types.ts'; import { logger } from '../logging.ts'; export class OneboxDatabase { private db: plugins.sqlite.DB | null = null; private dbPath: string; constructor(dbPath = './.nogit/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.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.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.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.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.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, zone_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Metrics table this.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.query(` CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC) `); // Logs table this.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.query(` CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC) `); // Users table this.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.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.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.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.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) { const error = new Error('Database not initialized'); console.error('Database access before initialization!'); console.error('Stack trace:', error.stack); throw error; } // For queries without parameters, use exec for better performance if (params.length === 0 && !sql.trim().toUpperCase().startsWith('SELECT')) { this.db.exec(sql); return [] as T[]; } // For SELECT queries or statements with parameters, use prepare().all() const stmt = this.db.prepare(sql); try { const result = stmt.all(...params); return result as T[]; } finally { stmt.finalize(); } } // ============ Services CRUD ============ async createService(service: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.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.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.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.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.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values); } deleteService(id: number): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM services WHERE id = ?', [id]); } private rowToService(row: any): IService { // Handle env_vars JSON parsing safely let envVars = {}; const envVarsRaw = row.env_vars || row[4]; if (envVarsRaw && envVarsRaw !== 'undefined' && envVarsRaw !== 'null') { try { envVars = JSON.parse(String(envVarsRaw)); } catch (e) { logger.warn(`Failed to parse env_vars for service: ${e.message}`); envVars = {}; } } return { id: Number(row.id || row[0]), name: String(row.name || row[1]), image: String(row.image || row[2]), registry: (row.registry || row[3]) ? String(row.registry || row[3]) : undefined, envVars, port: Number(row.port || row[5]), domain: (row.domain || row[6]) ? String(row.domain || row[6]) : undefined, containerID: (row.container_id || row[7]) ? String(row.container_id || row[7]) : undefined, status: String(row.status || row[8]) as IService['status'], createdAt: Number(row.created_at || row[9]), updatedAt: Number(row.updated_at || row[10]), }; } // ============ Registries CRUD ============ async createRegistry(registry: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.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.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.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.query('DELETE FROM registries WHERE url = ?', [url]); } private rowToRegistry(row: any): IRegistry { return { id: Number(row.id || row[0]), url: String(row.url || row[1]), username: String(row.username || row[2]), passwordEncrypted: String(row.password_encrypted || row[3]), createdAt: Number(row.created_at || row[4]), }; } // ============ Settings CRUD ============ getSetting(key: string): string | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.query('SELECT value FROM settings WHERE key = ?', [key]); if (rows.length === 0) return null; // @db/sqlite returns rows as objects with column names as keys const value = (rows[0] as any).value || rows[0][0]; return value ? String(value) : null; } setSetting(key: string, value: string): void { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.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.query('SELECT key, value FROM settings'); const settings: Record = {}; for (const row of rows) { // @db/sqlite returns rows as objects with column names as keys const key = (row as any).key || row[0]; const value = (row as any).value || row[1]; settings[String(key)] = String(value); } return settings; } // ============ Users CRUD ============ async createUser(user: Omit): Promise { if (!this.db) throw new Error('Database not initialized'); const now = Date.now(); this.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.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.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.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.query('DELETE FROM users WHERE username = ?', [username]); } private rowToUser(row: any): IUser { return { id: Number(row.id || row[0]), username: String(row.username || row[1]), passwordHash: String(row.password_hash || row[2]), role: String(row.role || row[3]) as IUser['role'], createdAt: Number(row.created_at || row[4]), updatedAt: Number(row.updated_at || row[5]), }; } // ============ Metrics ============ addMetric(metric: Omit): void { if (!this.db) throw new Error('Database not initialized'); this.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.query( 'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', [serviceId, limit] ); return rows.map((row) => this.rowToMetric(row)); } private rowToMetric(row: any): IMetric { return { id: Number(row.id || row[0]), serviceId: Number(row.service_id || row[1]), timestamp: Number(row.timestamp || row[2]), cpuPercent: Number(row.cpu_percent || row[3]), memoryUsed: Number(row.memory_used || row[4]), memoryLimit: Number(row.memory_limit || row[5]), networkRxBytes: Number(row.network_rx_bytes || row[6]), networkTxBytes: Number(row.network_tx_bytes || row[7]), }; } // ============ Logs ============ addLog(log: Omit): void { if (!this.db) throw new Error('Database not initialized'); this.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.query( 'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', [serviceId, limit] ); return rows.map((row) => this.rowToLog(row)); } private rowToLog(row: any): ILogEntry { return { id: Number(row.id || row[0]), serviceId: Number(row.service_id || row[1]), timestamp: Number(row.timestamp || row[2]), message: String(row.message || row[3]), level: String(row.level || row[4]) as ILogEntry['level'], source: String(row.source || 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.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.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.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.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values); } deleteSSLCertificate(domain: string): void { if (!this.db) throw new Error('Database not initialized'); this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]); } private rowToSSLCert(row: any): ISslCertificate { return { id: Number(row.id || row[0]), domain: String(row.domain || row[1]), certPath: String(row.cert_path || row[2]), keyPath: String(row.key_path || row[3]), fullChainPath: String(row.full_chain_path || row[4]), expiryDate: Number(row.expiry_date || row[5]), issuer: String(row.issuer || row[6]), createdAt: Number(row.created_at || row[7]), updatedAt: Number(row.updated_at || row[8]), }; } }