From ad89f2cc1fe31e23bc2d07443fb2d3e7cca65c64 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 25 Nov 2025 23:27:27 +0000 Subject: [PATCH] feat: Implement repositories for authentication, certificates, metrics, and platform services - Added AuthRepository for user and settings management with CRUD operations. - Introduced CertificateRepository to handle domains, certificates, and requirements. - Created MetricsRepository for managing metrics and logs. - Developed PlatformRepository for platform services and resources management. - Established RegistryRepository for registry and token operations. - Implemented ServiceRepository for CRUD operations on services. - Defined types and interfaces in types.ts for database interactions. --- readme.hints.md | 45 + ts/classes/cert-requirement-manager.ts | 27 +- ts/classes/certmanager.ts | 75 +- ts/classes/database.ts | 1841 +---------------- ts/classes/reverseproxy.ts | 59 +- ts/classes/ssl.ts | 20 +- ts/database/base.repository.ts | 13 + ts/database/index.ts | 1081 ++++++++++ ts/database/repositories/auth.repository.ts | 83 + .../repositories/certificate.repository.ts | 381 ++++ ts/database/repositories/index.ts | 10 + .../repositories/metrics.repository.ts | 76 + .../repositories/platform.repository.ts | 171 ++ .../repositories/registry.repository.ts | 123 ++ .../repositories/service.repository.ts | 177 ++ ts/database/types.ts | 17 + ts/index.ts | 2 +- ts/types.ts | 14 +- 18 files changed, 2249 insertions(+), 1966 deletions(-) create mode 100644 ts/database/base.repository.ts create mode 100644 ts/database/index.ts create mode 100644 ts/database/repositories/auth.repository.ts create mode 100644 ts/database/repositories/certificate.repository.ts create mode 100644 ts/database/repositories/index.ts create mode 100644 ts/database/repositories/metrics.repository.ts create mode 100644 ts/database/repositories/platform.repository.ts create mode 100644 ts/database/repositories/registry.repository.ts create mode 100644 ts/database/repositories/service.repository.ts create mode 100644 ts/database/types.ts diff --git a/readme.hints.md b/readme.hints.md index e69de29..546c637 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -0,0 +1,45 @@ +# Onebox Project Hints + +## SSL Certificate Storage (November 2025) + +SSL certificates are now stored directly in the SQLite database as PEM content instead of file paths: +- `ISslCertificate` and `ICertificate` interfaces use `certPem`, `keyPem`, `fullchainPem` properties +- Database migration 8 converted the `certificates` table schema +- No filesystem storage for certificates - everything in DB +- `reverseproxy.ts` reads certificate PEM content from database +- `certmanager.ts` stores SmartACME certificates directly to database + +## Architecture Notes + +### Database Layer (November 2025 Refactoring) + +The database layer has been refactored into a repository pattern: + +**Directory Structure:** +``` +ts/database/ +├── index.ts # Main OneboxDatabase class (composes repositories, handles migrations) +├── types.ts # Shared types (TBindValue, TQueryFunction) +├── base.repository.ts # Base repository class +└── repositories/ + ├── index.ts # Repository exports + ├── service.repository.ts # Services CRUD + ├── registry.repository.ts # Registries + Registry Tokens + ├── certificate.repository.ts # Domains, Certificates, Cert Requirements, SSL Certificates (legacy) + ├── auth.repository.ts # Users, Settings + ├── metrics.repository.ts # Metrics, Logs + └── platform.repository.ts # Platform Services, Platform Resources +``` + +**Import paths:** +- Main: `import { OneboxDatabase } from './database/index.ts'` +- Legacy (deprecated): `import { OneboxDatabase } from './classes/database.ts'` (re-exports from new location) + +**API Compatibility:** +- The `OneboxDatabase` class maintains the same public API +- All methods delegate to the appropriate repository +- No breaking changes for existing code + +## Current Migration Version: 8 + +Migration 8 converted certificate storage from file paths to PEM content. diff --git a/ts/classes/cert-requirement-manager.ts b/ts/classes/cert-requirement-manager.ts index 68acd7c..927be82 100644 --- a/ts/classes/cert-requirement-manager.ts +++ b/ts/classes/cert-requirement-manager.ts @@ -152,9 +152,9 @@ export class CertRequirementManager { domainId: domain.id!, certDomain: domain.domain, isWildcard: useWildcard, - certPath: certData.certPath, - keyPath: certData.keyPath, - fullChainPath: certData.fullChainPath, + certPem: certData.certPem, + keyPem: certData.keyPem, + fullchainPem: certData.fullchainPem, expiryDate: certData.expiryDate, issuer: certData.issuer, isValid: true, @@ -239,9 +239,9 @@ export class CertRequirementManager { // Update certificate in database this.database.updateCertificate(cert.id!, { - certPath: certData.certPath, - keyPath: certData.keyPath, - fullChainPath: certData.fullChainPath, + certPem: certData.certPem, + keyPem: certData.keyPem, + fullchainPem: certData.fullchainPem, expiryDate: certData.expiryDate, issuer: certData.issuer, }); @@ -260,7 +260,7 @@ export class CertRequirementManager { /** * Clean up old invalid certificates (90+ days old) */ - async cleanupOldCertificates(): Promise { + cleanupOldCertificates(): void { try { const allCertificates = this.database.getAllCertificates(); const now = Date.now(); @@ -272,18 +272,7 @@ export class CertRequirementManager { // Check if certificate has been invalid for 90+ days const timeSinceExpiry = now - cert.expiryDate; if (timeSinceExpiry >= this.CLEANUP_DELAY_MS) { - // Delete certificate files - try { - await Deno.remove(cert.certPath); - await Deno.remove(cert.keyPath); - await Deno.remove(cert.fullChainPath); - } catch (error) { - logger.debug( - `Failed to delete certificate files for ${cert.certDomain}: ${getErrorMessage(error)}` - ); - } - - // Delete from database + // Delete from database (PEM content is stored in DB, no files to delete) this.database.deleteCertificate(cert.id!); deletedCount++; logger.info( diff --git a/ts/classes/certmanager.ts b/ts/classes/certmanager.ts index 72e66b6..c826f55 100644 --- a/ts/classes/certmanager.ts +++ b/ts/classes/certmanager.ts @@ -1,8 +1,8 @@ /** * SQLite-based Certificate Manager for SmartACME * - * Implements ICertManager interface to store SSL certificates in SQLite database - * and write PEM files to filesystem for use by the reverse proxy. + * Implements ICertManager interface to store SSL certificates directly in SQLite database. + * Certificate PEM content is stored in the database, not on the filesystem. */ import * as plugins from '../plugins.ts'; @@ -12,25 +12,16 @@ import { OneboxDatabase } from './database.ts'; export class SqliteCertManager implements plugins.smartacme.ICertManager { private database: OneboxDatabase; - private certBasePath: string; - constructor(database: OneboxDatabase, certBasePath = './.nogit/ssl/live') { + constructor(database: OneboxDatabase) { this.database = database; - this.certBasePath = certBasePath; } /** * Initialize the certificate manager */ async init(): Promise { - try { - // Ensure certificate directory exists - await Deno.mkdir(this.certBasePath, { recursive: true }); - logger.info(`Certificate manager initialized (path: ${this.certBasePath})`); - } catch (error) { - logger.error(`Failed to initialize certificate manager: ${getErrorMessage(error)}`); - throw error; - } + logger.info('Certificate manager initialized (database storage)'); } /** @@ -40,7 +31,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { try { const dbCert = this.database.getSSLCertificate(domainName); - if (!dbCert) { + if (!dbCert || !dbCert.keyPem || !dbCert.fullchainPem) { return null; } @@ -49,8 +40,8 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { id: dbCert.id?.toString() || domainName, domainName: dbCert.domain, created: dbCert.createdAt, - privateKey: await this.readPemFile(dbCert.keyPath), - publicKey: await this.readPemFile(dbCert.fullChainPath), // Full chain as public key + privateKey: dbCert.keyPem, + publicKey: dbCert.fullchainPem, // Full chain as public key csr: '', // CSR not stored separately validUntil: dbCert.expiryDate, }); @@ -68,42 +59,28 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { async storeCertificate(cert: plugins.smartacme.Cert): Promise { try { const domain = cert.domainName; - const domainPath = `${this.certBasePath}/${domain}`; - - // Create domain-specific directory - await Deno.mkdir(domainPath, { recursive: true }); - - // Write PEM files - const keyPath = `${domainPath}/privkey.pem`; - const certPath = `${domainPath}/cert.pem`; - const fullChainPath = `${domainPath}/fullchain.pem`; - - await Deno.writeTextFile(keyPath, cert.privateKey); - await Deno.writeTextFile(fullChainPath, cert.publicKey); // Extract certificate from full chain (first certificate in the chain) - const certOnly = this.extractCertFromChain(cert.publicKey); - await Deno.writeTextFile(certPath, certOnly); + const certPem = this.extractCertFromChain(cert.publicKey); - // Store/update in database + // Store/update in database with PEM content const existing = this.database.getSSLCertificate(domain); if (existing) { this.database.updateSSLCertificate(domain, { - certPath, - keyPath, - fullChainPath, + certPem: certPem, + keyPem: cert.privateKey, + fullchainPem: cert.publicKey, expiryDate: cert.validUntil, - updatedAt: Date.now(), }); } else { await this.database.createSSLCertificate({ domain, - certPath, - keyPath, - fullChainPath, + certPem: certPem, + keyPem: cert.privateKey, + fullchainPem: cert.publicKey, expiryDate: cert.validUntil, - issuer: 'Let\'s Encrypt', + issuer: "Let's Encrypt", createdAt: cert.created, updatedAt: Date.now(), }); @@ -124,17 +101,8 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { const dbCert = this.database.getSSLCertificate(domainName); if (dbCert) { - // Delete PEM files - const domainPath = `${this.certBasePath}/${domainName}`; - try { - await Deno.remove(domainPath, { recursive: true }); - } catch (error) { - logger.warn(`Failed to delete PEM files for ${domainName}: ${getErrorMessage(error)}`); - } - // Delete from database this.database.deleteSSLCertificate(domainName); - logger.info(`Certificate deleted for ${domainName}`); } } catch (error) { @@ -169,17 +137,6 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { } } - /** - * Read PEM file from filesystem - */ - private async readPemFile(path: string): Promise { - try { - return await Deno.readTextFile(path); - } catch (error) { - throw new Error(`Failed to read PEM file ${path}: ${getErrorMessage(error)}`); - } - } - /** * Extract the first certificate from a PEM chain */ diff --git a/ts/classes/database.ts b/ts/classes/database.ts index 5c1d1c7..6b6e42f 100644 --- a/ts/classes/database.ts +++ b/ts/classes/database.ts @@ -1,1841 +1,6 @@ /** - * Database layer for Onebox using SQLite + * Database layer re-export for backward compatibility + * @deprecated Import from '../database/index.ts' instead */ -import * as plugins from '../plugins.ts'; -import type { - IService, - IRegistry, - IRegistryToken, - INginxConfig, - ISslCertificate, - IDnsRecord, - IMetric, - ILogEntry, - IUser, - ISetting, - IPlatformService, - IPlatformResource, - IPlatformRequirements, - TPlatformServiceType, - IDomain, - ICertificate, - ICertRequirement, -} from '../types.ts'; - -// Type alias for sqlite bind parameters -type BindValue = string | number | bigint | boolean | null | undefined | Uint8Array; -import { logger } from '../logging.ts'; -import { getErrorMessage } from '../utils/error.ts'; - -export class OneboxDatabase { - private db: InstanceType | 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: ${getErrorMessage(error)}`); - 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'); - - try { - const currentVersion = this.getMigrationVersion(); - logger.info(`Current database migration version: ${currentVersion}`); - - // Migration 1: Initial schema - if (currentVersion === 0) { - logger.info('Setting initial migration version to 1'); - this.setMigrationVersion(1); - } - - // Migration 2: Convert timestamp columns from INTEGER to REAL - const updatedVersion = this.getMigrationVersion(); - if (updatedVersion < 2) { - logger.info('Running migration 2: Converting timestamps to REAL...'); - - // For each table, we need to: - // 1. Create new table with REAL timestamps - // 2. Copy data - // 3. Drop old table - // 4. Rename new table - - // SSL certificates - this.query(` - CREATE TABLE ssl_certificates_new ( - 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 REAL NOT NULL, - issuer TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`); - this.query(`DROP TABLE ssl_certificates`); - this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`); - - // Services - this.query(` - CREATE TABLE services_new ( - 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 REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO services_new SELECT * FROM services`); - this.query(`DROP TABLE services`); - this.query(`ALTER TABLE services_new RENAME TO services`); - - // Registries - this.query(` - CREATE TABLE registries_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - password_encrypted TEXT NOT NULL, - created_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO registries_new SELECT * FROM registries`); - this.query(`DROP TABLE registries`); - this.query(`ALTER TABLE registries_new RENAME TO registries`); - - // Nginx configs - this.query(` - CREATE TABLE nginx_configs_new ( - 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 REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`); - this.query(`DROP TABLE nginx_configs`); - this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`); - - // DNS records - this.query(` - CREATE TABLE dns_records_new ( - 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 REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`); - this.query(`DROP TABLE dns_records`); - this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`); - - // Metrics - this.query(` - CREATE TABLE metrics_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - timestamp REAL 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 - ) - `); - this.query(`INSERT INTO metrics_new SELECT * FROM metrics`); - this.query(`DROP TABLE metrics`); - this.query(`ALTER TABLE metrics_new RENAME TO metrics`); - this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`); - - // Logs - this.query(` - CREATE TABLE logs_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - timestamp REAL NOT NULL, - message TEXT NOT NULL, - level TEXT NOT NULL, - source TEXT NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - this.query(`INSERT INTO logs_new SELECT * FROM logs`); - this.query(`DROP TABLE logs`); - this.query(`ALTER TABLE logs_new RENAME TO logs`); - this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`); - - // Users - this.query(` - CREATE TABLE users_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user', - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO users_new SELECT * FROM users`); - this.query(`DROP TABLE users`); - this.query(`ALTER TABLE users_new RENAME TO users`); - - // Settings - this.query(` - CREATE TABLE settings_new ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO settings_new SELECT * FROM settings`); - this.query(`DROP TABLE settings`); - this.query(`ALTER TABLE settings_new RENAME TO settings`); - - // Migrations table itself - this.query(` - CREATE TABLE migrations_new ( - version INTEGER PRIMARY KEY, - applied_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO migrations_new SELECT * FROM migrations`); - this.query(`DROP TABLE migrations`); - this.query(`ALTER TABLE migrations_new RENAME TO migrations`); - - this.setMigrationVersion(2); - logger.success('Migration 2 completed: All timestamps converted to REAL'); - } - - // Migration 3: Domain management tables - const version3 = this.getMigrationVersion(); - if (version3 < 3) { - logger.info('Running migration 3: Creating domain management tables...'); - - // 1. Create domains table - this.query(` - CREATE TABLE domains ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL UNIQUE, - dns_provider TEXT, - cloudflare_zone_id TEXT, - is_obsolete INTEGER NOT NULL DEFAULT 0, - default_wildcard INTEGER NOT NULL DEFAULT 1, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - - // 2. Create certificates table (renamed from ssl_certificates) - this.query(` - CREATE TABLE certificates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain_id INTEGER NOT NULL, - cert_domain TEXT NOT NULL, - is_wildcard INTEGER NOT NULL DEFAULT 0, - cert_path TEXT NOT NULL, - key_path TEXT NOT NULL, - full_chain_path TEXT NOT NULL, - expiry_date REAL NOT NULL, - issuer TEXT NOT NULL, - is_valid INTEGER NOT NULL DEFAULT 1, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE - ) - `); - - // 3. Create cert_requirements table - this.query(` - CREATE TABLE cert_requirements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - domain_id INTEGER NOT NULL, - subdomain TEXT NOT NULL, - certificate_id INTEGER, - status TEXT NOT NULL DEFAULT 'pending', - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, - FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, - FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL - ) - `); - - // 4. Migrate existing ssl_certificates data - // Extract unique base domains from existing certificates - interface OldSslCert { - id?: number; - domain?: string; - cert_path?: string; - key_path?: string; - full_chain_path?: string; - expiry_date?: number; - issuer?: string; - created_at?: number; - updated_at?: number; - [key: number]: unknown; // Allow array-style access as fallback - } - const existingCerts = this.query('SELECT * FROM ssl_certificates'); - - const now = Date.now(); - const domainMap = new Map(); - - // Create domain entries for each unique base domain - for (const cert of existingCerts) { - const domain = String(cert.domain ?? (cert as Record)[1]); - if (!domainMap.has(domain)) { - this.query( - 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - [domain, null, 0, 1, now, now] - ); - const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id'); - const domainId = result[0].id ?? (result[0] as Record)[0]; - domainMap.set(domain, Number(domainId)); - } - } - - // Migrate certificates to new table - for (const cert of existingCerts) { - const domain = String(cert.domain ?? (cert as Record)[1]); - const domainId = domainMap.get(domain); - - this.query( - `INSERT INTO certificates ( - domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, - expiry_date, issuer, is_valid, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - domainId, - domain, - 0, // We don't know if it's wildcard, default to false - String(cert.cert_path ?? (cert as Record)[2]), - String(cert.key_path ?? (cert as Record)[3]), - String(cert.full_chain_path ?? (cert as Record)[4]), - Number(cert.expiry_date ?? (cert as Record)[5]), - String(cert.issuer ?? (cert as Record)[6]), - 1, // Assume valid - Number(cert.created_at ?? (cert as Record)[7]), - Number(cert.updated_at ?? (cert as Record)[8]) - ] - ); - } - - // 5. Drop old ssl_certificates table - this.query('DROP TABLE ssl_certificates'); - - // 6. Create indices for performance - this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); - this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)'); - - this.setMigrationVersion(3); - logger.success('Migration 3 completed: Domain management tables created'); - } - - // Migration 4: Add Onebox Registry support columns to services table - const version4 = this.getMigrationVersion(); - if (version4 < 4) { - logger.info('Running migration 4: Adding Onebox Registry columns to services table...'); - - // Add new columns for registry support - this.query(` - ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0 - `); - this.query(` - ALTER TABLE services ADD COLUMN registry_repository TEXT - `); - this.query(` - ALTER TABLE services ADD COLUMN registry_token TEXT - `); - this.query(` - ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest' - `); - this.query(` - ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0 - `); - this.query(` - ALTER TABLE services ADD COLUMN image_digest TEXT - `); - - this.setMigrationVersion(4); - logger.success('Migration 4 completed: Onebox Registry columns added to services table'); - } - - // Migration 5: Registry tokens table - const version5 = this.getMigrationVersion(); - if (version5 < 5) { - logger.info('Running migration 5: Creating registry_tokens table...'); - - this.query(` - CREATE TABLE registry_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, - token_type TEXT NOT NULL, - scope TEXT NOT NULL, - expires_at REAL, - created_at REAL NOT NULL, - last_used_at REAL, - created_by TEXT NOT NULL - ) - `); - - // Create indices for performance - this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)'); - this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)'); - - this.setMigrationVersion(5); - logger.success('Migration 5 completed: Registry tokens table created'); - } - - // Migration 6: Drop registry_token column from services table (replaced by registry_tokens table) - const version6 = this.getMigrationVersion(); - if (version6 < 6) { - logger.info('Running migration 6: Dropping registry_token column from services table...'); - - // SQLite doesn't support DROP COLUMN directly, so we need to recreate the table - // Create new table without registry_token - this.query(` - CREATE TABLE services_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - image TEXT NOT NULL, - registry TEXT, - env_vars TEXT, - port INTEGER NOT NULL, - domain TEXT, - container_id TEXT, - status TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - use_onebox_registry INTEGER DEFAULT 0, - registry_repository TEXT, - registry_image_tag TEXT DEFAULT 'latest', - auto_update_on_push INTEGER DEFAULT 0, - image_digest TEXT - ) - `); - - // Copy data (excluding registry_token) - this.query(` - INSERT INTO services_new ( - id, name, image, registry, env_vars, port, domain, container_id, status, - created_at, updated_at, use_onebox_registry, registry_repository, - registry_image_tag, auto_update_on_push, image_digest - ) - SELECT - id, name, image, registry, env_vars, port, domain, container_id, status, - created_at, updated_at, use_onebox_registry, registry_repository, - registry_image_tag, auto_update_on_push, image_digest - FROM services - `); - - // Drop old table - this.query('DROP TABLE services'); - - // Rename new table - this.query('ALTER TABLE services_new RENAME TO services'); - - // Recreate indices - this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)'); - this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)'); - - this.setMigrationVersion(6); - logger.success('Migration 6 completed: registry_token column dropped from services table'); - } - - // Migration 7: Platform services tables - const version7 = this.getMigrationVersion(); - if (version7 < 7) { - logger.info('Running migration 7: Creating platform services tables...'); - - // Create platform_services table - this.query(` - CREATE TABLE platform_services ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'stopped', - container_id TEXT, - config TEXT NOT NULL DEFAULT '{}', - admin_credentials_encrypted TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - - // Create platform_resources table - this.query(` - CREATE TABLE platform_resources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform_service_id INTEGER NOT NULL, - service_id INTEGER NOT NULL, - resource_type TEXT NOT NULL, - resource_name TEXT NOT NULL, - credentials_encrypted TEXT NOT NULL, - created_at REAL NOT NULL, - FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - // Add platform_requirements column to services table - this.query(` - ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}' - `); - - // Create indices - this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)'); - this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)'); - - this.setMigrationVersion(7); - logger.success('Migration 7 completed: Platform services tables created'); - } - } catch (error) { - logger.error(`Migration failed: ${getErrorMessage(error)}`); - if (error instanceof Error && error.stack) { - logger.error(`Stack: ${error.stack}`); - } - throw error; - } -} - - /** - * Get current migration version - */ - private getMigrationVersion(): number { - if (!this.db) throw new Error('Database not initialized'); - - try { - const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations'); - if (result.length === 0) return 0; - - // Handle both array and object access patterns - const versionValue = result[0].version ?? (result[0] as Record)[0]; - return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; - } catch (error) { - logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`); - 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: BindValue[] = []): 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, - use_onebox_registry, registry_repository, registry_image_tag, - auto_update_on_push, image_digest, platform_requirements - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - service.name, - service.image, - service.registry || null, - JSON.stringify(service.envVars), - service.port, - service.domain || null, - service.containerID || null, - service.status, - now, - now, - service.useOneboxRegistry ? 1 : 0, - service.registryRepository || null, - service.registryImageTag || 'latest', - service.autoUpdateOnPush ? 1 : 0, - service.imageDigest || null, - JSON.stringify(service.platformRequirements || {}), - ] - ); - - 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]); - if (rows.length > 0) { - logger.info(`getServiceByName: raw row data: ${JSON.stringify(rows[0])}`); - const service = this.rowToService(rows[0]); - logger.info(`getServiceByName: service object containerID: ${service.containerID}`); - return service; - } - return 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: BindValue[] = []; - - 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); - } - // Onebox Registry fields - if (updates.useOneboxRegistry !== undefined) { - fields.push('use_onebox_registry = ?'); - values.push(updates.useOneboxRegistry ? 1 : 0); - } - if (updates.registryRepository !== undefined) { - fields.push('registry_repository = ?'); - values.push(updates.registryRepository); - } - if (updates.registryImageTag !== undefined) { - fields.push('registry_image_tag = ?'); - values.push(updates.registryImageTag); - } - if (updates.autoUpdateOnPush !== undefined) { - fields.push('auto_update_on_push = ?'); - values.push(updates.autoUpdateOnPush ? 1 : 0); - } - if (updates.imageDigest !== undefined) { - fields.push('image_digest = ?'); - values.push(updates.imageDigest); - } - if (updates.platformRequirements !== undefined) { - fields.push('platform_requirements = ?'); - values.push(JSON.stringify(updates.platformRequirements)); - } - - 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: ${getErrorMessage(e)}`); - envVars = {}; - } - } - - // Handle platform_requirements JSON parsing safely - let platformRequirements: IPlatformRequirements | undefined; - const platformReqRaw = row.platform_requirements; - if (platformReqRaw && platformReqRaw !== 'undefined' && platformReqRaw !== 'null' && platformReqRaw !== '{}') { - try { - platformRequirements = JSON.parse(String(platformReqRaw)); - } catch (e) { - logger.warn(`Failed to parse platform_requirements for service: ${getErrorMessage(e)}`); - platformRequirements = undefined; - } - } - - 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]), - // Onebox Registry fields - useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined, - registryRepository: row.registry_repository ? String(row.registry_repository) : undefined, - registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined, - autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined, - imageDigest: row.image_digest ? String(row.image_digest) : undefined, - // Platform service requirements - platformRequirements, - }; - } - - // ============ 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: BindValue[] = []; - - 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]), - }; - } - - // ============ Domains ============ - - createDomain(domain: Omit): IDomain { - if (!this.db) throw new Error('Database not initialized'); - - this.query( - `INSERT INTO domains (domain, dns_provider, cloudflare_zone_id, is_obsolete, default_wildcard, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - domain.domain, - domain.dnsProvider, - domain.cloudflareZoneId, - domain.isObsolete ? 1 : 0, - domain.defaultWildcard ? 1 : 0, - domain.createdAt, - domain.updatedAt, - ] - ); - - return this.getDomainByName(domain.domain)!; - } - - getDomainByName(domain: string): IDomain | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM domains WHERE domain = ?', [domain]); - return rows.length > 0 ? this.rowToDomain(rows[0]) : null; - } - - getDomainById(id: number): IDomain | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM domains WHERE id = ?', [id]); - return rows.length > 0 ? this.rowToDomain(rows[0]) : null; - } - - getAllDomains(): IDomain[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM domains ORDER BY domain ASC'); - return rows.map((row) => this.rowToDomain(row)); - } - - getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [ - provider, - ]); - return rows.map((row) => this.rowToDomain(row)); - } - - updateDomain(id: number, updates: Partial): void { - if (!this.db) throw new Error('Database not initialized'); - - const fields: string[] = []; - const values: BindValue[] = []; - - if (updates.domain !== undefined) { - fields.push('domain = ?'); - values.push(updates.domain); - } - if (updates.dnsProvider !== undefined) { - fields.push('dns_provider = ?'); - values.push(updates.dnsProvider); - } - if (updates.cloudflareZoneId !== undefined) { - fields.push('cloudflare_zone_id = ?'); - values.push(updates.cloudflareZoneId); - } - if (updates.isObsolete !== undefined) { - fields.push('is_obsolete = ?'); - values.push(updates.isObsolete ? 1 : 0); - } - if (updates.defaultWildcard !== undefined) { - fields.push('default_wildcard = ?'); - values.push(updates.defaultWildcard ? 1 : 0); - } - - fields.push('updated_at = ?'); - values.push(Date.now()); - values.push(id); - - this.query(`UPDATE domains SET ${fields.join(', ')} WHERE id = ?`, values); - } - - deleteDomain(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM domains WHERE id = ?', [id]); - } - - private rowToDomain(row: any): IDomain { - return { - id: Number(row.id || row[0]), - domain: String(row.domain || row[1]), - dnsProvider: (row.dns_provider || row[2]) as IDomain['dnsProvider'], - cloudflareZoneId: row.cloudflare_zone_id || row[3] || undefined, - isObsolete: Boolean(row.is_obsolete || row[4]), - defaultWildcard: Boolean(row.default_wildcard || row[5]), - createdAt: Number(row.created_at || row[6]), - updatedAt: Number(row.updated_at || row[7]), - }; - } - - // ============ Certificates ============ - - createCertificate(cert: Omit): ICertificate { - if (!this.db) throw new Error('Database not initialized'); - - this.query( - `INSERT INTO certificates (domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, expiry_date, issuer, is_valid, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - cert.domainId, - cert.certDomain, - cert.isWildcard ? 1 : 0, - cert.certPath, - cert.keyPath, - cert.fullChainPath, - cert.expiryDate, - cert.issuer, - cert.isValid ? 1 : 0, - cert.createdAt, - cert.updatedAt, - ] - ); - - const rows = this.query('SELECT * FROM certificates WHERE id = last_insert_rowid()'); - return this.rowToCertificate(rows[0]); - } - - getCertificateById(id: number): ICertificate | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM certificates WHERE id = ?', [id]); - return rows.length > 0 ? this.rowToCertificate(rows[0]) : null; - } - - getCertificatesByDomain(domainId: number): ICertificate[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM certificates WHERE domain_id = ? ORDER BY expiry_date DESC', [ - domainId, - ]); - return rows.map((row) => this.rowToCertificate(row)); - } - - getAllCertificates(): ICertificate[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM certificates ORDER BY expiry_date DESC'); - return rows.map((row) => this.rowToCertificate(row)); - } - - updateCertificate(id: number, updates: Partial): void { - if (!this.db) throw new Error('Database not initialized'); - - const fields: string[] = []; - const values: BindValue[] = []; - - if (updates.certDomain !== undefined) { - fields.push('cert_domain = ?'); - values.push(updates.certDomain); - } - if (updates.isWildcard !== undefined) { - fields.push('is_wildcard = ?'); - values.push(updates.isWildcard ? 1 : 0); - } - if (updates.certPath !== undefined) { - fields.push('cert_path = ?'); - values.push(updates.certPath); - } - if (updates.keyPath !== undefined) { - fields.push('key_path = ?'); - values.push(updates.keyPath); - } - if (updates.fullChainPath !== undefined) { - fields.push('full_chain_path = ?'); - values.push(updates.fullChainPath); - } - if (updates.expiryDate !== undefined) { - fields.push('expiry_date = ?'); - values.push(updates.expiryDate); - } - if (updates.issuer !== undefined) { - fields.push('issuer = ?'); - values.push(updates.issuer); - } - if (updates.isValid !== undefined) { - fields.push('is_valid = ?'); - values.push(updates.isValid ? 1 : 0); - } - - fields.push('updated_at = ?'); - values.push(Date.now()); - values.push(id); - - this.query(`UPDATE certificates SET ${fields.join(', ')} WHERE id = ?`, values); - } - - deleteCertificate(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM certificates WHERE id = ?', [id]); - } - - private rowToCertificate(row: any): ICertificate { - return { - id: Number(row.id || row[0]), - domainId: Number(row.domain_id || row[1]), - certDomain: String(row.cert_domain || row[2]), - isWildcard: Boolean(row.is_wildcard || row[3]), - certPath: String(row.cert_path || row[4]), - keyPath: String(row.key_path || row[5]), - fullChainPath: String(row.full_chain_path || row[6]), - expiryDate: Number(row.expiry_date || row[7]), - issuer: String(row.issuer || row[8]), - isValid: Boolean(row.is_valid || row[9]), - createdAt: Number(row.created_at || row[10]), - updatedAt: Number(row.updated_at || row[11]), - }; - } - - // ============ Certificate Requirements ============ - - createCertRequirement(req: Omit): ICertRequirement { - if (!this.db) throw new Error('Database not initialized'); - - this.query( - `INSERT INTO cert_requirements (service_id, domain_id, subdomain, certificate_id, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - req.serviceId, - req.domainId, - req.subdomain, - req.certificateId, - req.status, - req.createdAt, - req.updatedAt, - ] - ); - - const rows = this.query('SELECT * FROM cert_requirements WHERE id = last_insert_rowid()'); - return this.rowToCertRequirement(rows[0]); - } - - getCertRequirementById(id: number): ICertRequirement | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM cert_requirements WHERE id = ?', [id]); - return rows.length > 0 ? this.rowToCertRequirement(rows[0]) : null; - } - - getCertRequirementsByService(serviceId: number): ICertRequirement[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM cert_requirements WHERE service_id = ?', [serviceId]); - return rows.map((row) => this.rowToCertRequirement(row)); - } - - getCertRequirementsByDomain(domainId: number): ICertRequirement[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM cert_requirements WHERE domain_id = ?', [domainId]); - return rows.map((row) => this.rowToCertRequirement(row)); - } - - getAllCertRequirements(): ICertRequirement[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM cert_requirements ORDER BY created_at DESC'); - return rows.map((row) => this.rowToCertRequirement(row)); - } - - updateCertRequirement(id: number, updates: Partial): void { - if (!this.db) throw new Error('Database not initialized'); - - const fields: string[] = []; - const values: BindValue[] = []; - - if (updates.subdomain !== undefined) { - fields.push('subdomain = ?'); - values.push(updates.subdomain); - } - if (updates.certificateId !== undefined) { - fields.push('certificate_id = ?'); - values.push(updates.certificateId); - } - 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 cert_requirements SET ${fields.join(', ')} WHERE id = ?`, values); - } - - deleteCertRequirement(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM cert_requirements WHERE id = ?', [id]); - } - - private rowToCertRequirement(row: any): ICertRequirement { - return { - id: Number(row.id || row[0]), - serviceId: Number(row.service_id || row[1]), - domainId: Number(row.domain_id || row[2]), - subdomain: String(row.subdomain || row[3]), - certificateId: row.certificate_id || row[4] || undefined, - status: String(row.status || row[5]) as ICertRequirement['status'], - createdAt: Number(row.created_at || row[6]), - updatedAt: Number(row.updated_at || row[7]), - }; - } - - // ============ Registry Tokens ============ - - createRegistryToken(token: Omit): IRegistryToken { - if (!this.db) throw new Error('Database not initialized'); - - const scopeJson = Array.isArray(token.scope) ? JSON.stringify(token.scope) : token.scope; - - this.query( - `INSERT INTO registry_tokens (name, token_hash, token_type, scope, expires_at, created_at, last_used_at, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [ - token.name, - token.tokenHash, - token.type, - scopeJson, - token.expiresAt, - token.createdAt, - token.lastUsedAt, - token.createdBy, - ] - ); - - const rows = this.query('SELECT * FROM registry_tokens WHERE id = last_insert_rowid()'); - return this.rowToRegistryToken(rows[0]); - } - - getRegistryTokenById(id: number): IRegistryToken | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM registry_tokens WHERE id = ?', [id]); - return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null; - } - - getRegistryTokenByHash(tokenHash: string): IRegistryToken | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM registry_tokens WHERE token_hash = ?', [tokenHash]); - return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null; - } - - getAllRegistryTokens(): IRegistryToken[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM registry_tokens ORDER BY created_at DESC'); - return rows.map((row) => this.rowToRegistryToken(row)); - } - - getRegistryTokensByType(type: 'global' | 'ci'): IRegistryToken[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM registry_tokens WHERE token_type = ? ORDER BY created_at DESC', [type]); - return rows.map((row) => this.rowToRegistryToken(row)); - } - - updateRegistryTokenLastUsed(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('UPDATE registry_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), id]); - } - - deleteRegistryToken(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM registry_tokens WHERE id = ?', [id]); - } - - private rowToRegistryToken(row: any): IRegistryToken { - // Parse scope - it's either 'all' or a JSON array - let scope: 'all' | string[]; - const scopeRaw = row.scope || row[4]; - if (scopeRaw === 'all') { - scope = 'all'; - } else { - try { - scope = JSON.parse(String(scopeRaw)); - } catch { - scope = 'all'; - } - } - - return { - id: Number(row.id || row[0]), - name: String(row.name || row[1]), - tokenHash: String(row.token_hash || row[2]), - type: String(row.token_type || row[3]) as IRegistryToken['type'], - scope, - expiresAt: row.expires_at || row[5] ? Number(row.expires_at || row[5]) : null, - createdAt: Number(row.created_at || row[6]), - lastUsedAt: row.last_used_at || row[7] ? Number(row.last_used_at || row[7]) : null, - createdBy: String(row.created_by || row[8]), - }; - } - - // ============ Platform Services CRUD ============ - - createPlatformService(service: Omit): IPlatformService { - if (!this.db) throw new Error('Database not initialized'); - - const now = Date.now(); - this.query( - `INSERT INTO platform_services (name, type, status, container_id, config, admin_credentials_encrypted, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [ - service.name, - service.type, - service.status, - service.containerId || null, - JSON.stringify(service.config), - service.adminCredentialsEncrypted || null, - now, - now, - ] - ); - - return this.getPlatformServiceByName(service.name)!; - } - - getPlatformServiceByName(name: string): IPlatformService | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_services WHERE name = ?', [name]); - return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; - } - - getPlatformServiceById(id: number): IPlatformService | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_services WHERE id = ?', [id]); - return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; - } - - getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_services WHERE type = ?', [type]); - return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; - } - - getAllPlatformServices(): IPlatformService[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_services ORDER BY created_at DESC'); - return rows.map((row) => this.rowToPlatformService(row)); - } - - updatePlatformService(id: number, updates: Partial): void { - if (!this.db) throw new Error('Database not initialized'); - - const fields: string[] = []; - const values: BindValue[] = []; - - if (updates.status !== undefined) { - fields.push('status = ?'); - values.push(updates.status); - } - if (updates.containerId !== undefined) { - fields.push('container_id = ?'); - values.push(updates.containerId); - } - if (updates.config !== undefined) { - fields.push('config = ?'); - values.push(JSON.stringify(updates.config)); - } - if (updates.adminCredentialsEncrypted !== undefined) { - fields.push('admin_credentials_encrypted = ?'); - values.push(updates.adminCredentialsEncrypted); - } - - fields.push('updated_at = ?'); - values.push(Date.now()); - values.push(id); - - this.query(`UPDATE platform_services SET ${fields.join(', ')} WHERE id = ?`, values); - } - - deletePlatformService(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM platform_services WHERE id = ?', [id]); - } - - private rowToPlatformService(row: any): IPlatformService { - let config = { image: '', port: 0 }; - const configRaw = row.config; - if (configRaw) { - try { - config = JSON.parse(String(configRaw)); - } catch (e) { - logger.warn(`Failed to parse platform service config: ${getErrorMessage(e)}`); - } - } - - return { - id: Number(row.id), - name: String(row.name), - type: String(row.type) as TPlatformServiceType, - status: String(row.status) as IPlatformService['status'], - containerId: row.container_id ? String(row.container_id) : undefined, - config, - adminCredentialsEncrypted: row.admin_credentials_encrypted ? String(row.admin_credentials_encrypted) : undefined, - createdAt: Number(row.created_at), - updatedAt: Number(row.updated_at), - }; - } - - // ============ Platform Resources CRUD ============ - - createPlatformResource(resource: Omit): IPlatformResource { - if (!this.db) throw new Error('Database not initialized'); - - const now = Date.now(); - this.query( - `INSERT INTO platform_resources (platform_service_id, service_id, resource_type, resource_name, credentials_encrypted, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - resource.platformServiceId, - resource.serviceId, - resource.resourceType, - resource.resourceName, - resource.credentialsEncrypted, - now, - ] - ); - - const rows = this.query('SELECT * FROM platform_resources WHERE id = last_insert_rowid()'); - return this.rowToPlatformResource(rows[0]); - } - - getPlatformResourceById(id: number): IPlatformResource | null { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_resources WHERE id = ?', [id]); - return rows.length > 0 ? this.rowToPlatformResource(rows[0]) : null; - } - - getPlatformResourcesByService(serviceId: number): IPlatformResource[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_resources WHERE service_id = ?', [serviceId]); - return rows.map((row) => this.rowToPlatformResource(row)); - } - - getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_resources WHERE platform_service_id = ?', [platformServiceId]); - return rows.map((row) => this.rowToPlatformResource(row)); - } - - getAllPlatformResources(): IPlatformResource[] { - if (!this.db) throw new Error('Database not initialized'); - - const rows = this.query('SELECT * FROM platform_resources ORDER BY created_at DESC'); - return rows.map((row) => this.rowToPlatformResource(row)); - } - - deletePlatformResource(id: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM platform_resources WHERE id = ?', [id]); - } - - deletePlatformResourcesByService(serviceId: number): void { - if (!this.db) throw new Error('Database not initialized'); - this.query('DELETE FROM platform_resources WHERE service_id = ?', [serviceId]); - } - - private rowToPlatformResource(row: any): IPlatformResource { - return { - id: Number(row.id), - platformServiceId: Number(row.platform_service_id), - serviceId: Number(row.service_id), - resourceType: String(row.resource_type) as IPlatformResource['resourceType'], - resourceName: String(row.resource_name), - credentialsEncrypted: String(row.credentials_encrypted), - createdAt: Number(row.created_at), - }; - } -} +export { OneboxDatabase } from '../database/index.ts'; diff --git a/ts/classes/reverseproxy.ts b/ts/classes/reverseproxy.ts index 01387e3..335ed0a 100644 --- a/ts/classes/reverseproxy.ts +++ b/ts/classes/reverseproxy.ts @@ -18,8 +18,8 @@ interface IProxyRoute { interface ITlsConfig { domain: string; - certPath: string; - keyPath: string; + certPem: string; // Certificate PEM content + keyPem: string; // Private key PEM content } export class OneboxReverseProxy { @@ -112,8 +112,8 @@ export class OneboxReverseProxy { { port: this.httpsPort, hostname: '0.0.0.0', - cert: await Deno.readTextFile(defaultConfig.certPath), - key: await Deno.readTextFile(defaultConfig.keyPath), + cert: defaultConfig.certPem, + key: defaultConfig.keyPem, onListen: ({ hostname, port }) => { logger.success(`HTTPS reverse proxy listening on https://${hostname}:${port}`); }, @@ -402,30 +402,26 @@ export class OneboxReverseProxy { } /** - * Add TLS certificate for a domain + * Add TLS certificate for a domain (using PEM content) */ - async addCertificate(domain: string, certPath: string, keyPath: string): Promise { - try { - // Verify certificate files exist - await Deno.stat(certPath); - await Deno.stat(keyPath); + addCertificate(domain: string, certPem: string, keyPem: string): void { + if (!certPem || !keyPem) { + logger.warn(`Cannot add certificate for ${domain}: missing PEM content`); + return; + } - this.tlsConfigs.set(domain, { - domain, - certPath, - keyPath, - }); + this.tlsConfigs.set(domain, { + domain, + certPem, + keyPem, + }); - logger.success(`Added TLS certificate for ${domain}`); + logger.success(`Added TLS certificate for ${domain}`); - // If HTTPS server is already running, we need to restart it - // TODO: Implement hot reload for certificates - if (this.httpsServer) { - logger.warn('HTTPS server restart required for new certificate to take effect'); - } - } catch (error) { - logger.error(`Failed to add certificate for ${domain}: ${getErrorMessage(error)}`); - throw error; + // If HTTPS server is already running, we need to restart it + // TODO: Implement hot reload for certificates + if (this.httpsServer) { + logger.warn('HTTPS server restart required for new certificate to take effect'); } } @@ -441,23 +437,22 @@ export class OneboxReverseProxy { } /** - * Reload TLS certificates from SSL manager + * Reload TLS certificates from database */ async reloadCertificates(): Promise { try { - logger.info('Reloading TLS certificates...'); + logger.info('Reloading TLS certificates from database...'); this.tlsConfigs.clear(); const certificates = this.database.getAllSSLCertificates(); for (const cert of certificates) { - if (cert.domain && cert.certPath && cert.keyPath) { - try { - await this.addCertificate(cert.domain, cert.fullChainPath, cert.keyPath); - } catch (error) { - logger.warn(`Failed to load certificate for ${cert.domain}: ${getErrorMessage(error)}`); - } + // Use fullchainPem for the cert (includes intermediates) and keyPem for the key + if (cert.domain && cert.fullchainPem && cert.keyPem) { + this.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem); + } else { + logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`); } } diff --git a/ts/classes/ssl.ts b/ts/classes/ssl.ts index 3465192..5e78656 100644 --- a/ts/classes/ssl.ts +++ b/ts/classes/ssl.ts @@ -92,15 +92,15 @@ export class OneboxSslManager { /** * Acquire certificate and return certificate data (for CertRequirementManager) - * Returns certificate paths and expiry information + * Returns certificate PEM content and expiry information */ async acquireCertificate( domain: string, includeWildcard = false ): Promise<{ - certPath: string; - keyPath: string; - fullChainPath: string; + certPem: string; + keyPem: string; + fullchainPem: string; expiryDate: number; issuer: string; }> { @@ -122,8 +122,8 @@ export class OneboxSslManager { // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); - // The certManager stores the cert to disk and database during getCertificateForDomain - // Look up the paths from the database + // The certManager stores the cert to database during getCertificateForDomain + // Look up the PEM content from the database const dbCert = this.database.getSSLCertificate(domain); if (!dbCert) { throw new Error(`Certificate stored but not found in database for ${domain}`); @@ -131,11 +131,11 @@ export class OneboxSslManager { // Return certificate data from database return { - certPath: dbCert.certPath, - keyPath: dbCert.keyPath, - fullChainPath: dbCert.fullChainPath, + certPem: dbCert.certPem, + keyPem: dbCert.keyPem, + fullchainPem: dbCert.fullchainPem, expiryDate: cert.validUntil, - issuer: dbCert.issuer || 'Let\'s Encrypt', + issuer: dbCert.issuer || "Let's Encrypt", }; } catch (error) { logger.error(`Failed to acquire certificate for ${domain}: ${getErrorMessage(error)}`); diff --git a/ts/database/base.repository.ts b/ts/database/base.repository.ts new file mode 100644 index 0000000..de0204f --- /dev/null +++ b/ts/database/base.repository.ts @@ -0,0 +1,13 @@ +/** + * Base repository class with common query methods + */ + +import type { TBindValue, TQueryFunction } from './types.ts'; + +export abstract class BaseRepository { + protected query: TQueryFunction; + + constructor(queryFn: TQueryFunction) { + this.query = queryFn; + } +} diff --git a/ts/database/index.ts b/ts/database/index.ts new file mode 100644 index 0000000..d458fa7 --- /dev/null +++ b/ts/database/index.ts @@ -0,0 +1,1081 @@ +/** + * Database layer for Onebox using SQLite + * Refactored into repository pattern + */ + +import * as plugins from '../plugins.ts'; +import type { + IService, + IRegistry, + IRegistryToken, + ISslCertificate, + IMetric, + ILogEntry, + IUser, + IPlatformService, + IPlatformResource, + TPlatformServiceType, + IDomain, + ICertificate, + ICertRequirement, +} from '../types.ts'; +import type { TBindValue } from './types.ts'; +import { logger } from '../logging.ts'; +import { getErrorMessage } from '../utils/error.ts'; + +// Import repositories +import { + ServiceRepository, + RegistryRepository, + CertificateRepository, + AuthRepository, + MetricsRepository, + PlatformRepository, +} from './repositories/index.ts'; + +export class OneboxDatabase { + private db: InstanceType | null = null; + private dbPath: string; + + // Repositories + private serviceRepo!: ServiceRepository; + private registryRepo!: RegistryRepository; + private certificateRepo!: CertificateRepository; + private authRepo!: AuthRepository; + private metricsRepo!: MetricsRepository; + private platformRepo!: PlatformRepository; + + 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(); + + // Initialize repositories with bound query function + const queryFn = this.query.bind(this); + this.serviceRepo = new ServiceRepository(queryFn); + this.registryRepo = new RegistryRepository(queryFn); + this.certificateRepo = new CertificateRepository(queryFn); + this.authRepo = new AuthRepository(queryFn); + this.metricsRepo = new MetricsRepository(queryFn); + this.platformRepo = new PlatformRepository(queryFn); + } catch (error) { + logger.error(`Failed to initialize database: ${getErrorMessage(error)}`); + 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'); + + try { + const currentVersion = this.getMigrationVersion(); + logger.info(`Current database migration version: ${currentVersion}`); + + // Migration 1: Initial schema + if (currentVersion === 0) { + logger.info('Setting initial migration version to 1'); + this.setMigrationVersion(1); + } + + // Migration 2: Convert timestamp columns from INTEGER to REAL + const updatedVersion = this.getMigrationVersion(); + if (updatedVersion < 2) { + logger.info('Running migration 2: Converting timestamps to REAL...'); + + // SSL certificates + this.query(` + CREATE TABLE ssl_certificates_new ( + 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 REAL NOT NULL, + issuer TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`); + this.query(`DROP TABLE ssl_certificates`); + this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`); + + // Services + this.query(` + CREATE TABLE services_new ( + 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 REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO services_new SELECT * FROM services`); + this.query(`DROP TABLE services`); + this.query(`ALTER TABLE services_new RENAME TO services`); + + // Registries + this.query(` + CREATE TABLE registries_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + created_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO registries_new SELECT * FROM registries`); + this.query(`DROP TABLE registries`); + this.query(`ALTER TABLE registries_new RENAME TO registries`); + + // Nginx configs + this.query(` + CREATE TABLE nginx_configs_new ( + 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 REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`); + this.query(`DROP TABLE nginx_configs`); + this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`); + + // DNS records + this.query(` + CREATE TABLE dns_records_new ( + 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 REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`); + this.query(`DROP TABLE dns_records`); + this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`); + + // Metrics + this.query(` + CREATE TABLE metrics_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp REAL 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 + ) + `); + this.query(`INSERT INTO metrics_new SELECT * FROM metrics`); + this.query(`DROP TABLE metrics`); + this.query(`ALTER TABLE metrics_new RENAME TO metrics`); + this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`); + + // Logs + this.query(` + CREATE TABLE logs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp REAL NOT NULL, + message TEXT NOT NULL, + level TEXT NOT NULL, + source TEXT NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + this.query(`INSERT INTO logs_new SELECT * FROM logs`); + this.query(`DROP TABLE logs`); + this.query(`ALTER TABLE logs_new RENAME TO logs`); + this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`); + + // Users + this.query(` + CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO users_new SELECT * FROM users`); + this.query(`DROP TABLE users`); + this.query(`ALTER TABLE users_new RENAME TO users`); + + // Settings + this.query(` + CREATE TABLE settings_new ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO settings_new SELECT * FROM settings`); + this.query(`DROP TABLE settings`); + this.query(`ALTER TABLE settings_new RENAME TO settings`); + + // Migrations table itself + this.query(` + CREATE TABLE migrations_new ( + version INTEGER PRIMARY KEY, + applied_at REAL NOT NULL + ) + `); + this.query(`INSERT INTO migrations_new SELECT * FROM migrations`); + this.query(`DROP TABLE migrations`); + this.query(`ALTER TABLE migrations_new RENAME TO migrations`); + + this.setMigrationVersion(2); + logger.success('Migration 2 completed: All timestamps converted to REAL'); + } + + // Migration 3: Domain management tables + const version3 = this.getMigrationVersion(); + if (version3 < 3) { + logger.info('Running migration 3: Creating domain management tables...'); + + this.query(` + CREATE TABLE domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + dns_provider TEXT, + cloudflare_zone_id TEXT, + is_obsolete INTEGER NOT NULL DEFAULT 0, + default_wildcard INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + + this.query(` + CREATE TABLE certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL, + cert_domain TEXT NOT NULL, + is_wildcard INTEGER NOT NULL DEFAULT 0, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + full_chain_path TEXT NOT NULL, + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE + ) + `); + + this.query(` + CREATE TABLE cert_requirements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + domain_id INTEGER NOT NULL, + subdomain TEXT NOT NULL, + certificate_id INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, + FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL + ) + `); + + interface OldSslCert { + id?: number; + domain?: string; + cert_path?: string; + key_path?: string; + full_chain_path?: string; + expiry_date?: number; + issuer?: string; + created_at?: number; + updated_at?: number; + [key: number]: unknown; + } + const existingCerts = this.query('SELECT * FROM ssl_certificates'); + + const now = Date.now(); + const domainMap = new Map(); + + for (const cert of existingCerts) { + const domain = String(cert.domain ?? (cert as Record)[1]); + if (!domainMap.has(domain)) { + this.query( + 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [domain, null, 0, 1, now, now] + ); + const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id'); + const domainId = result[0].id ?? (result[0] as Record)[0]; + domainMap.set(domain, Number(domainId)); + } + } + + for (const cert of existingCerts) { + const domain = String(cert.domain ?? (cert as Record)[1]); + const domainId = domainMap.get(domain); + + this.query( + `INSERT INTO certificates ( + domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, + expiry_date, issuer, is_valid, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + domainId, + domain, + 0, + String(cert.cert_path ?? (cert as Record)[2]), + String(cert.key_path ?? (cert as Record)[3]), + String(cert.full_chain_path ?? (cert as Record)[4]), + Number(cert.expiry_date ?? (cert as Record)[5]), + String(cert.issuer ?? (cert as Record)[6]), + 1, + Number(cert.created_at ?? (cert as Record)[7]), + Number(cert.updated_at ?? (cert as Record)[8]) + ] + ); + } + + this.query('DROP TABLE ssl_certificates'); + this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); + this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)'); + + this.setMigrationVersion(3); + logger.success('Migration 3 completed: Domain management tables created'); + } + + // Migration 4: Add Onebox Registry support columns + const version4 = this.getMigrationVersion(); + if (version4 < 4) { + logger.info('Running migration 4: Adding Onebox Registry columns to services table...'); + + this.query(`ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0`); + this.query(`ALTER TABLE services ADD COLUMN registry_repository TEXT`); + this.query(`ALTER TABLE services ADD COLUMN registry_token TEXT`); + this.query(`ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest'`); + this.query(`ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0`); + this.query(`ALTER TABLE services ADD COLUMN image_digest TEXT`); + + this.setMigrationVersion(4); + logger.success('Migration 4 completed: Onebox Registry columns added to services table'); + } + + // Migration 5: Registry tokens table + const version5 = this.getMigrationVersion(); + if (version5 < 5) { + logger.info('Running migration 5: Creating registry_tokens table...'); + + this.query(` + CREATE TABLE registry_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_type TEXT NOT NULL, + scope TEXT NOT NULL, + expires_at REAL, + created_at REAL NOT NULL, + last_used_at REAL, + created_by TEXT NOT NULL + ) + `); + + this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)'); + this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)'); + + this.setMigrationVersion(5); + logger.success('Migration 5 completed: Registry tokens table created'); + } + + // Migration 6: Drop registry_token column from services table + const version6 = this.getMigrationVersion(); + if (version6 < 6) { + logger.info('Running migration 6: Dropping registry_token column from services table...'); + + this.query(` + CREATE TABLE services_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + image TEXT NOT NULL, + registry TEXT, + env_vars TEXT, + port INTEGER NOT NULL, + domain TEXT, + container_id TEXT, + status TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + use_onebox_registry INTEGER DEFAULT 0, + registry_repository TEXT, + registry_image_tag TEXT DEFAULT 'latest', + auto_update_on_push INTEGER DEFAULT 0, + image_digest TEXT + ) + `); + + this.query(` + INSERT INTO services_new ( + id, name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, use_onebox_registry, registry_repository, + registry_image_tag, auto_update_on_push, image_digest + ) + SELECT + id, name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, use_onebox_registry, registry_repository, + registry_image_tag, auto_update_on_push, image_digest + FROM services + `); + + this.query('DROP TABLE services'); + this.query('ALTER TABLE services_new RENAME TO services'); + this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)'); + this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)'); + + this.setMigrationVersion(6); + logger.success('Migration 6 completed: registry_token column dropped from services table'); + } + + // Migration 7: Platform services tables + const version7 = this.getMigrationVersion(); + if (version7 < 7) { + logger.info('Running migration 7: Creating platform services tables...'); + + this.query(` + CREATE TABLE platform_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'stopped', + container_id TEXT, + config TEXT NOT NULL DEFAULT '{}', + admin_credentials_encrypted TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + + this.query(` + CREATE TABLE platform_resources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_service_id INTEGER NOT NULL, + service_id INTEGER NOT NULL, + resource_type TEXT NOT NULL, + resource_name TEXT NOT NULL, + credentials_encrypted TEXT NOT NULL, + created_at REAL NOT NULL, + FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + this.query(`ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'`); + + this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)'); + this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)'); + + this.setMigrationVersion(7); + logger.success('Migration 7 completed: Platform services tables created'); + } + + // Migration 8: Convert certificates table to store PEM content + const version8 = this.getMigrationVersion(); + if (version8 < 8) { + logger.info('Running migration 8: Converting certificates table to store PEM content...'); + + this.query(` + CREATE TABLE certificates_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL, + cert_domain TEXT NOT NULL, + is_wildcard INTEGER NOT NULL DEFAULT 0, + cert_pem TEXT NOT NULL DEFAULT '', + key_pem TEXT NOT NULL DEFAULT '', + fullchain_pem TEXT NOT NULL DEFAULT '', + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE + ) + `); + + this.query(` + INSERT INTO certificates_new (id, domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at) + SELECT id, domain_id, cert_domain, is_wildcard, '', '', '', expiry_date, issuer, 0, created_at, updated_at FROM certificates + `); + + this.query('DROP TABLE certificates'); + this.query('ALTER TABLE certificates_new RENAME TO certificates'); + this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); + + this.setMigrationVersion(8); + logger.success('Migration 8 completed: Certificates table now stores PEM content'); + } + } catch (error) { + logger.error(`Migration failed: ${getErrorMessage(error)}`); + if (error instanceof Error && error.stack) { + logger.error(`Stack: ${error.stack}`); + } + throw error; + } + } + + /** + * Get current migration version + */ + private getMigrationVersion(): number { + if (!this.db) throw new Error('Database not initialized'); + + try { + const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations'); + if (result.length === 0) return 0; + + const versionValue = result[0].version ?? (result[0] as Record)[0]; + return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; + } catch (error) { + logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`); + 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: TBindValue[] = []): 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; + } + + if (params.length === 0 && !sql.trim().toUpperCase().startsWith('SELECT')) { + this.db.exec(sql); + return [] as T[]; + } + + const stmt = this.db.prepare(sql); + try { + const result = stmt.all(...params); + return result as T[]; + } finally { + stmt.finalize(); + } + } + + // ============ Services CRUD (delegated to repository) ============ + + async createService(service: Omit): Promise { + return this.serviceRepo.create(service); + } + + getServiceByName(name: string): IService | null { + return this.serviceRepo.getByName(name); + } + + getServiceByID(id: number): IService | null { + return this.serviceRepo.getById(id); + } + + getAllServices(): IService[] { + return this.serviceRepo.getAll(); + } + + updateService(id: number, updates: Partial): void { + this.serviceRepo.update(id, updates); + } + + deleteService(id: number): void { + this.serviceRepo.delete(id); + } + + // ============ Registries CRUD (delegated to repository) ============ + + async createRegistry(registry: Omit): Promise { + return this.registryRepo.createRegistry(registry); + } + + getRegistryByURL(url: string): IRegistry | null { + return this.registryRepo.getRegistryByURL(url); + } + + getAllRegistries(): IRegistry[] { + return this.registryRepo.getAllRegistries(); + } + + deleteRegistry(url: string): void { + this.registryRepo.deleteRegistry(url); + } + + // ============ Settings CRUD (delegated to repository) ============ + + getSetting(key: string): string | null { + return this.authRepo.getSetting(key); + } + + setSetting(key: string, value: string): void { + this.authRepo.setSetting(key, value); + } + + getAllSettings(): Record { + return this.authRepo.getAllSettings(); + } + + // ============ Users CRUD (delegated to repository) ============ + + async createUser(user: Omit): Promise { + return this.authRepo.createUser(user); + } + + getUserByUsername(username: string): IUser | null { + return this.authRepo.getUserByUsername(username); + } + + getAllUsers(): IUser[] { + return this.authRepo.getAllUsers(); + } + + updateUserPassword(username: string, passwordHash: string): void { + this.authRepo.updateUserPassword(username, passwordHash); + } + + deleteUser(username: string): void { + this.authRepo.deleteUser(username); + } + + // ============ Metrics (delegated to repository) ============ + + addMetric(metric: Omit): void { + this.metricsRepo.addMetric(metric); + } + + getMetrics(serviceId: number, limit = 100): IMetric[] { + return this.metricsRepo.getMetrics(serviceId, limit); + } + + // ============ Logs (delegated to repository) ============ + + addLog(log: Omit): void { + this.metricsRepo.addLog(log); + } + + getLogs(serviceId: number, limit = 1000): ILogEntry[] { + return this.metricsRepo.getLogs(serviceId, limit); + } + + // ============ SSL Certificates Legacy API (delegated to repository) ============ + + async createSSLCertificate(cert: Omit): Promise { + return this.certificateRepo.createSSLCertificate(cert); + } + + getSSLCertificate(domain: string): ISslCertificate | null { + return this.certificateRepo.getSSLCertificate(domain); + } + + getAllSSLCertificates(): ISslCertificate[] { + return this.certificateRepo.getAllSSLCertificates(); + } + + updateSSLCertificate(domain: string, updates: Partial): void { + this.certificateRepo.updateSSLCertificate(domain, updates); + } + + deleteSSLCertificate(domain: string): void { + this.certificateRepo.deleteSSLCertificate(domain); + } + + // ============ Domains (delegated to repository) ============ + + createDomain(domain: Omit): IDomain { + return this.certificateRepo.createDomain(domain); + } + + getDomainByName(domain: string): IDomain | null { + return this.certificateRepo.getDomainByName(domain); + } + + getDomainById(id: number): IDomain | null { + return this.certificateRepo.getDomainById(id); + } + + getAllDomains(): IDomain[] { + return this.certificateRepo.getAllDomains(); + } + + getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] { + return this.certificateRepo.getDomainsByProvider(provider); + } + + updateDomain(id: number, updates: Partial): void { + this.certificateRepo.updateDomain(id, updates); + } + + deleteDomain(id: number): void { + this.certificateRepo.deleteDomain(id); + } + + // ============ Certificates (delegated to repository) ============ + + createCertificate(cert: Omit): ICertificate { + return this.certificateRepo.createCertificate(cert); + } + + getCertificateById(id: number): ICertificate | null { + return this.certificateRepo.getCertificateById(id); + } + + getCertificatesByDomain(domainId: number): ICertificate[] { + return this.certificateRepo.getCertificatesByDomain(domainId); + } + + getAllCertificates(): ICertificate[] { + return this.certificateRepo.getAllCertificates(); + } + + updateCertificate(id: number, updates: Partial): void { + this.certificateRepo.updateCertificate(id, updates); + } + + deleteCertificate(id: number): void { + this.certificateRepo.deleteCertificate(id); + } + + // ============ Certificate Requirements (delegated to repository) ============ + + createCertRequirement(req: Omit): ICertRequirement { + return this.certificateRepo.createCertRequirement(req); + } + + getCertRequirementById(id: number): ICertRequirement | null { + return this.certificateRepo.getCertRequirementById(id); + } + + getCertRequirementsByService(serviceId: number): ICertRequirement[] { + return this.certificateRepo.getCertRequirementsByService(serviceId); + } + + getCertRequirementsByDomain(domainId: number): ICertRequirement[] { + return this.certificateRepo.getCertRequirementsByDomain(domainId); + } + + getAllCertRequirements(): ICertRequirement[] { + return this.certificateRepo.getAllCertRequirements(); + } + + updateCertRequirement(id: number, updates: Partial): void { + this.certificateRepo.updateCertRequirement(id, updates); + } + + deleteCertRequirement(id: number): void { + this.certificateRepo.deleteCertRequirement(id); + } + + // ============ Registry Tokens (delegated to repository) ============ + + createRegistryToken(token: Omit): IRegistryToken { + return this.registryRepo.createToken(token); + } + + getRegistryTokenById(id: number): IRegistryToken | null { + return this.registryRepo.getTokenById(id); + } + + getRegistryTokenByHash(tokenHash: string): IRegistryToken | null { + return this.registryRepo.getTokenByHash(tokenHash); + } + + getAllRegistryTokens(): IRegistryToken[] { + return this.registryRepo.getAllTokens(); + } + + getRegistryTokensByType(type: 'global' | 'ci'): IRegistryToken[] { + return this.registryRepo.getTokensByType(type); + } + + updateRegistryTokenLastUsed(id: number): void { + this.registryRepo.updateTokenLastUsed(id); + } + + deleteRegistryToken(id: number): void { + this.registryRepo.deleteToken(id); + } + + // ============ Platform Services (delegated to repository) ============ + + createPlatformService(service: Omit): IPlatformService { + return this.platformRepo.createPlatformService(service); + } + + getPlatformServiceByName(name: string): IPlatformService | null { + return this.platformRepo.getPlatformServiceByName(name); + } + + getPlatformServiceById(id: number): IPlatformService | null { + return this.platformRepo.getPlatformServiceById(id); + } + + getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null { + return this.platformRepo.getPlatformServiceByType(type); + } + + getAllPlatformServices(): IPlatformService[] { + return this.platformRepo.getAllPlatformServices(); + } + + updatePlatformService(id: number, updates: Partial): void { + this.platformRepo.updatePlatformService(id, updates); + } + + deletePlatformService(id: number): void { + this.platformRepo.deletePlatformService(id); + } + + // ============ Platform Resources (delegated to repository) ============ + + createPlatformResource(resource: Omit): IPlatformResource { + return this.platformRepo.createPlatformResource(resource); + } + + getPlatformResourceById(id: number): IPlatformResource | null { + return this.platformRepo.getPlatformResourceById(id); + } + + getPlatformResourcesByService(serviceId: number): IPlatformResource[] { + return this.platformRepo.getPlatformResourcesByService(serviceId); + } + + getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] { + return this.platformRepo.getPlatformResourcesByPlatformService(platformServiceId); + } + + getAllPlatformResources(): IPlatformResource[] { + return this.platformRepo.getAllPlatformResources(); + } + + deletePlatformResource(id: number): void { + this.platformRepo.deletePlatformResource(id); + } + + deletePlatformResourcesByService(serviceId: number): void { + this.platformRepo.deletePlatformResourcesByService(serviceId); + } +} diff --git a/ts/database/repositories/auth.repository.ts b/ts/database/repositories/auth.repository.ts new file mode 100644 index 0000000..2b48e0f --- /dev/null +++ b/ts/database/repositories/auth.repository.ts @@ -0,0 +1,83 @@ +/** + * Auth Repository + * Handles CRUD operations for users and settings tables + */ + +import { BaseRepository } from '../base.repository.ts'; +import type { IUser } from '../../types.ts'; + +export class AuthRepository extends BaseRepository { + // ============ Users ============ + + async createUser(user: Omit): Promise { + 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 { + const rows = this.query('SELECT * FROM users WHERE username = ?', [username]); + return rows.length > 0 ? this.rowToUser(rows[0]) : null; + } + + getAllUsers(): IUser[] { + 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 { + this.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [ + passwordHash, + Date.now(), + username, + ]); + } + + deleteUser(username: string): void { + 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]), + }; + } + + // ============ Settings ============ + + getSetting(key: string): string | null { + const rows = this.query('SELECT value FROM settings WHERE key = ?', [key]); + if (rows.length === 0) return null; + + const value = (rows[0] as any).value || rows[0][0]; + return value ? String(value) : null; + } + + setSetting(key: string, value: string): void { + const now = Date.now(); + this.query( + 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', + [key, value, now] + ); + } + + getAllSettings(): Record { + const rows = this.query('SELECT key, value FROM settings'); + const settings: Record = {}; + for (const row of rows) { + const key = (row as any).key || row[0]; + const value = (row as any).value || row[1]; + settings[String(key)] = String(value); + } + return settings; + } +} diff --git a/ts/database/repositories/certificate.repository.ts b/ts/database/repositories/certificate.repository.ts new file mode 100644 index 0000000..b1892ea --- /dev/null +++ b/ts/database/repositories/certificate.repository.ts @@ -0,0 +1,381 @@ +/** + * Certificate Repository + * Handles CRUD operations for domains, certificates, cert_requirements, and legacy ssl_certificates + */ + +import { BaseRepository } from '../base.repository.ts'; +import type { TBindValue } from '../types.ts'; +import type { IDomain, ICertificate, ICertRequirement, ISslCertificate } from '../../types.ts'; + +export class CertificateRepository extends BaseRepository { + // ============ Domains ============ + + createDomain(domain: Omit): IDomain { + this.query( + `INSERT INTO domains (domain, dns_provider, cloudflare_zone_id, is_obsolete, default_wildcard, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + domain.domain, + domain.dnsProvider, + domain.cloudflareZoneId, + domain.isObsolete ? 1 : 0, + domain.defaultWildcard ? 1 : 0, + domain.createdAt, + domain.updatedAt, + ] + ); + + return this.getDomainByName(domain.domain)!; + } + + getDomainByName(domain: string): IDomain | null { + const rows = this.query('SELECT * FROM domains WHERE domain = ?', [domain]); + return rows.length > 0 ? this.rowToDomain(rows[0]) : null; + } + + getDomainById(id: number): IDomain | null { + const rows = this.query('SELECT * FROM domains WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToDomain(rows[0]) : null; + } + + getAllDomains(): IDomain[] { + const rows = this.query('SELECT * FROM domains ORDER BY domain ASC'); + return rows.map((row) => this.rowToDomain(row)); + } + + getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] { + const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [provider]); + return rows.map((row) => this.rowToDomain(row)); + } + + updateDomain(id: number, updates: Partial): void { + const fields: string[] = []; + const values: TBindValue[] = []; + + if (updates.domain !== undefined) { + fields.push('domain = ?'); + values.push(updates.domain); + } + if (updates.dnsProvider !== undefined) { + fields.push('dns_provider = ?'); + values.push(updates.dnsProvider); + } + if (updates.cloudflareZoneId !== undefined) { + fields.push('cloudflare_zone_id = ?'); + values.push(updates.cloudflareZoneId); + } + if (updates.isObsolete !== undefined) { + fields.push('is_obsolete = ?'); + values.push(updates.isObsolete ? 1 : 0); + } + if (updates.defaultWildcard !== undefined) { + fields.push('default_wildcard = ?'); + values.push(updates.defaultWildcard ? 1 : 0); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE domains SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deleteDomain(id: number): void { + this.query('DELETE FROM domains WHERE id = ?', [id]); + } + + private rowToDomain(row: any): IDomain { + return { + id: Number(row.id || row[0]), + domain: String(row.domain || row[1]), + dnsProvider: (row.dns_provider || row[2]) as IDomain['dnsProvider'], + cloudflareZoneId: row.cloudflare_zone_id || row[3] || undefined, + isObsolete: Boolean(row.is_obsolete || row[4]), + defaultWildcard: Boolean(row.default_wildcard || row[5]), + createdAt: Number(row.created_at || row[6]), + updatedAt: Number(row.updated_at || row[7]), + }; + } + + // ============ Certificates ============ + + createCertificate(cert: Omit): ICertificate { + this.query( + `INSERT INTO certificates (domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + cert.domainId, + cert.certDomain, + cert.isWildcard ? 1 : 0, + cert.certPem, + cert.keyPem, + cert.fullchainPem, + cert.expiryDate, + cert.issuer, + cert.isValid ? 1 : 0, + cert.createdAt, + cert.updatedAt, + ] + ); + + const rows = this.query('SELECT * FROM certificates WHERE id = last_insert_rowid()'); + return this.rowToCertificate(rows[0]); + } + + getCertificateById(id: number): ICertificate | null { + const rows = this.query('SELECT * FROM certificates WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToCertificate(rows[0]) : null; + } + + getCertificatesByDomain(domainId: number): ICertificate[] { + const rows = this.query('SELECT * FROM certificates WHERE domain_id = ? ORDER BY expiry_date DESC', [domainId]); + return rows.map((row) => this.rowToCertificate(row)); + } + + getAllCertificates(): ICertificate[] { + const rows = this.query('SELECT * FROM certificates ORDER BY expiry_date DESC'); + return rows.map((row) => this.rowToCertificate(row)); + } + + updateCertificate(id: number, updates: Partial): void { + const fields: string[] = []; + const values: TBindValue[] = []; + + if (updates.certDomain !== undefined) { + fields.push('cert_domain = ?'); + values.push(updates.certDomain); + } + if (updates.isWildcard !== undefined) { + fields.push('is_wildcard = ?'); + values.push(updates.isWildcard ? 1 : 0); + } + if (updates.certPem !== undefined) { + fields.push('cert_pem = ?'); + values.push(updates.certPem); + } + if (updates.keyPem !== undefined) { + fields.push('key_pem = ?'); + values.push(updates.keyPem); + } + if (updates.fullchainPem !== undefined) { + fields.push('fullchain_pem = ?'); + values.push(updates.fullchainPem); + } + if (updates.expiryDate !== undefined) { + fields.push('expiry_date = ?'); + values.push(updates.expiryDate); + } + if (updates.issuer !== undefined) { + fields.push('issuer = ?'); + values.push(updates.issuer); + } + if (updates.isValid !== undefined) { + fields.push('is_valid = ?'); + values.push(updates.isValid ? 1 : 0); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE certificates SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deleteCertificate(id: number): void { + this.query('DELETE FROM certificates WHERE id = ?', [id]); + } + + private rowToCertificate(row: any): ICertificate { + return { + id: Number(row.id || row[0]), + domainId: Number(row.domain_id || row[1]), + certDomain: String(row.cert_domain || row[2]), + isWildcard: Boolean(row.is_wildcard || row[3]), + certPem: String(row.cert_pem || row[4] || ''), + keyPem: String(row.key_pem || row[5] || ''), + fullchainPem: String(row.fullchain_pem || row[6] || ''), + expiryDate: Number(row.expiry_date || row[7]), + issuer: String(row.issuer || row[8]), + isValid: Boolean(row.is_valid || row[9]), + createdAt: Number(row.created_at || row[10]), + updatedAt: Number(row.updated_at || row[11]), + }; + } + + // ============ Certificate Requirements ============ + + createCertRequirement(req: Omit): ICertRequirement { + this.query( + `INSERT INTO cert_requirements (service_id, domain_id, subdomain, certificate_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + req.serviceId, + req.domainId, + req.subdomain, + req.certificateId, + req.status, + req.createdAt, + req.updatedAt, + ] + ); + + const rows = this.query('SELECT * FROM cert_requirements WHERE id = last_insert_rowid()'); + return this.rowToCertRequirement(rows[0]); + } + + getCertRequirementById(id: number): ICertRequirement | null { + const rows = this.query('SELECT * FROM cert_requirements WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToCertRequirement(rows[0]) : null; + } + + getCertRequirementsByService(serviceId: number): ICertRequirement[] { + const rows = this.query('SELECT * FROM cert_requirements WHERE service_id = ?', [serviceId]); + return rows.map((row) => this.rowToCertRequirement(row)); + } + + getCertRequirementsByDomain(domainId: number): ICertRequirement[] { + const rows = this.query('SELECT * FROM cert_requirements WHERE domain_id = ?', [domainId]); + return rows.map((row) => this.rowToCertRequirement(row)); + } + + getAllCertRequirements(): ICertRequirement[] { + const rows = this.query('SELECT * FROM cert_requirements ORDER BY created_at DESC'); + return rows.map((row) => this.rowToCertRequirement(row)); + } + + updateCertRequirement(id: number, updates: Partial): void { + const fields: string[] = []; + const values: TBindValue[] = []; + + if (updates.subdomain !== undefined) { + fields.push('subdomain = ?'); + values.push(updates.subdomain); + } + if (updates.certificateId !== undefined) { + fields.push('certificate_id = ?'); + values.push(updates.certificateId); + } + 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 cert_requirements SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deleteCertRequirement(id: number): void { + this.query('DELETE FROM cert_requirements WHERE id = ?', [id]); + } + + private rowToCertRequirement(row: any): ICertRequirement { + return { + id: Number(row.id || row[0]), + serviceId: Number(row.service_id || row[1]), + domainId: Number(row.domain_id || row[2]), + subdomain: String(row.subdomain || row[3]), + certificateId: row.certificate_id || row[4] || undefined, + status: String(row.status || row[5]) as ICertRequirement['status'], + createdAt: Number(row.created_at || row[6]), + updatedAt: Number(row.updated_at || row[7]), + }; + } + + // ============ SSL Certificates (Legacy API) ============ + + async createSSLCertificate(cert: Omit): Promise { + // First, ensure domain exists in domains table + let domainRecord = this.getDomainByName(cert.domain); + if (!domainRecord) { + const now = Date.now(); + domainRecord = this.createDomain({ + domain: cert.domain, + dnsProvider: null, + isObsolete: false, + defaultWildcard: true, + createdAt: now, + updatedAt: now, + }); + } + + const now = Date.now(); + this.query( + `INSERT INTO certificates (domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + domainRecord.id, + cert.domain, + 0, + cert.certPem, + cert.keyPem, + cert.fullchainPem, + cert.expiryDate, + cert.issuer, + 1, + now, + now, + ] + ); + + return this.getSSLCertificate(cert.domain)!; + } + + getSSLCertificate(domain: string): ISslCertificate | null { + const rows = this.query('SELECT * FROM certificates WHERE cert_domain = ?', [domain]); + return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null; + } + + getAllSSLCertificates(): ISslCertificate[] { + const rows = this.query('SELECT * FROM certificates ORDER BY expiry_date ASC'); + return rows.map((row) => this.rowToSSLCert(row)); + } + + updateSSLCertificate(domain: string, updates: Partial): void { + const fields: string[] = []; + const values: TBindValue[] = []; + + if (updates.certPem) { + fields.push('cert_pem = ?'); + values.push(updates.certPem); + } + if (updates.keyPem) { + fields.push('key_pem = ?'); + values.push(updates.keyPem); + } + if (updates.fullchainPem) { + fields.push('fullchain_pem = ?'); + values.push(updates.fullchainPem); + } + 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 certificates SET ${fields.join(', ')} WHERE cert_domain = ?`, values); + } + + deleteSSLCertificate(domain: string): void { + this.query('DELETE FROM certificates WHERE cert_domain = ?', [domain]); + } + + private rowToSSLCert(row: any): ISslCertificate { + return { + id: Number(row.id || row[0]), + domain: String(row.cert_domain || row[2] || ''), + certPem: String(row.cert_pem || row[4] || ''), + keyPem: String(row.key_pem || row[5] || ''), + fullchainPem: String(row.fullchain_pem || row[6] || ''), + expiryDate: Number(row.expiry_date || row[7]), + issuer: String(row.issuer || row[8]), + createdAt: Number(row.created_at || row[10]), + updatedAt: Number(row.updated_at || row[11]), + }; + } +} diff --git a/ts/database/repositories/index.ts b/ts/database/repositories/index.ts new file mode 100644 index 0000000..8974827 --- /dev/null +++ b/ts/database/repositories/index.ts @@ -0,0 +1,10 @@ +/** + * Repository exports + */ + +export { ServiceRepository } from './service.repository.ts'; +export { RegistryRepository } from './registry.repository.ts'; +export { CertificateRepository } from './certificate.repository.ts'; +export { AuthRepository } from './auth.repository.ts'; +export { MetricsRepository } from './metrics.repository.ts'; +export { PlatformRepository } from './platform.repository.ts'; diff --git a/ts/database/repositories/metrics.repository.ts b/ts/database/repositories/metrics.repository.ts new file mode 100644 index 0000000..25e89c3 --- /dev/null +++ b/ts/database/repositories/metrics.repository.ts @@ -0,0 +1,76 @@ +/** + * Metrics Repository + * Handles CRUD operations for metrics and logs tables + */ + +import { BaseRepository } from '../base.repository.ts'; +import type { IMetric, ILogEntry } from '../../types.ts'; + +export class MetricsRepository extends BaseRepository { + // ============ Metrics ============ + + addMetric(metric: Omit): void { + 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[] { + 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 { + 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[] { + 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'], + }; + } +} diff --git a/ts/database/repositories/platform.repository.ts b/ts/database/repositories/platform.repository.ts new file mode 100644 index 0000000..4b8ba86 --- /dev/null +++ b/ts/database/repositories/platform.repository.ts @@ -0,0 +1,171 @@ +/** + * Platform Repository + * Handles CRUD operations for platform_services and platform_resources tables + */ + +import { BaseRepository } from '../base.repository.ts'; +import type { TBindValue } from '../types.ts'; +import type { IPlatformService, IPlatformResource, TPlatformServiceType } from '../../types.ts'; +import { logger } from '../../logging.ts'; +import { getErrorMessage } from '../../utils/error.ts'; + +export class PlatformRepository extends BaseRepository { + // ============ Platform Services ============ + + createPlatformService(service: Omit): IPlatformService { + const now = Date.now(); + this.query( + `INSERT INTO platform_services (name, type, status, container_id, config, admin_credentials_encrypted, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + service.name, + service.type, + service.status, + service.containerId || null, + JSON.stringify(service.config), + service.adminCredentialsEncrypted || null, + now, + now, + ] + ); + + return this.getPlatformServiceByName(service.name)!; + } + + getPlatformServiceByName(name: string): IPlatformService | null { + const rows = this.query('SELECT * FROM platform_services WHERE name = ?', [name]); + return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; + } + + getPlatformServiceById(id: number): IPlatformService | null { + const rows = this.query('SELECT * FROM platform_services WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; + } + + getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null { + const rows = this.query('SELECT * FROM platform_services WHERE type = ?', [type]); + return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; + } + + getAllPlatformServices(): IPlatformService[] { + const rows = this.query('SELECT * FROM platform_services ORDER BY created_at DESC'); + return rows.map((row) => this.rowToPlatformService(row)); + } + + updatePlatformService(id: number, updates: Partial): void { + const fields: string[] = []; + const values: TBindValue[] = []; + + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + if (updates.containerId !== undefined) { + fields.push('container_id = ?'); + values.push(updates.containerId); + } + if (updates.config !== undefined) { + fields.push('config = ?'); + values.push(JSON.stringify(updates.config)); + } + if (updates.adminCredentialsEncrypted !== undefined) { + fields.push('admin_credentials_encrypted = ?'); + values.push(updates.adminCredentialsEncrypted); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE platform_services SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deletePlatformService(id: number): void { + this.query('DELETE FROM platform_services WHERE id = ?', [id]); + } + + private rowToPlatformService(row: any): IPlatformService { + let config = { image: '', port: 0 }; + const configRaw = row.config; + if (configRaw) { + try { + config = JSON.parse(String(configRaw)); + } catch (e) { + logger.warn(`Failed to parse platform service config: ${getErrorMessage(e)}`); + } + } + + return { + id: Number(row.id), + name: String(row.name), + type: String(row.type) as TPlatformServiceType, + status: String(row.status) as IPlatformService['status'], + containerId: row.container_id ? String(row.container_id) : undefined, + config, + adminCredentialsEncrypted: row.admin_credentials_encrypted ? String(row.admin_credentials_encrypted) : undefined, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; + } + + // ============ Platform Resources ============ + + createPlatformResource(resource: Omit): IPlatformResource { + const now = Date.now(); + this.query( + `INSERT INTO platform_resources (platform_service_id, service_id, resource_type, resource_name, credentials_encrypted, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + resource.platformServiceId, + resource.serviceId, + resource.resourceType, + resource.resourceName, + resource.credentialsEncrypted, + now, + ] + ); + + const rows = this.query('SELECT * FROM platform_resources WHERE id = last_insert_rowid()'); + return this.rowToPlatformResource(rows[0]); + } + + getPlatformResourceById(id: number): IPlatformResource | null { + const rows = this.query('SELECT * FROM platform_resources WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToPlatformResource(rows[0]) : null; + } + + getPlatformResourcesByService(serviceId: number): IPlatformResource[] { + const rows = this.query('SELECT * FROM platform_resources WHERE service_id = ?', [serviceId]); + return rows.map((row) => this.rowToPlatformResource(row)); + } + + getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] { + const rows = this.query('SELECT * FROM platform_resources WHERE platform_service_id = ?', [platformServiceId]); + return rows.map((row) => this.rowToPlatformResource(row)); + } + + getAllPlatformResources(): IPlatformResource[] { + const rows = this.query('SELECT * FROM platform_resources ORDER BY created_at DESC'); + return rows.map((row) => this.rowToPlatformResource(row)); + } + + deletePlatformResource(id: number): void { + this.query('DELETE FROM platform_resources WHERE id = ?', [id]); + } + + deletePlatformResourcesByService(serviceId: number): void { + this.query('DELETE FROM platform_resources WHERE service_id = ?', [serviceId]); + } + + private rowToPlatformResource(row: any): IPlatformResource { + return { + id: Number(row.id), + platformServiceId: Number(row.platform_service_id), + serviceId: Number(row.service_id), + resourceType: String(row.resource_type) as IPlatformResource['resourceType'], + resourceName: String(row.resource_name), + credentialsEncrypted: String(row.credentials_encrypted), + createdAt: Number(row.created_at), + }; + } +} diff --git a/ts/database/repositories/registry.repository.ts b/ts/database/repositories/registry.repository.ts new file mode 100644 index 0000000..3bf5066 --- /dev/null +++ b/ts/database/repositories/registry.repository.ts @@ -0,0 +1,123 @@ +/** + * Registry Repository + * Handles CRUD operations for registries and registry_tokens tables + */ + +import { BaseRepository } from '../base.repository.ts'; +import type { IRegistry, IRegistryToken } from '../../types.ts'; + +export class RegistryRepository extends BaseRepository { + // ============ Registries ============ + + async createRegistry(registry: Omit): Promise { + 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 { + const rows = this.query('SELECT * FROM registries WHERE url = ?', [url]); + return rows.length > 0 ? this.rowToRegistry(rows[0]) : null; + } + + getAllRegistries(): IRegistry[] { + const rows = this.query('SELECT * FROM registries ORDER BY created_at DESC'); + return rows.map((row) => this.rowToRegistry(row)); + } + + deleteRegistry(url: string): void { + 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]), + }; + } + + // ============ Registry Tokens ============ + + createToken(token: Omit): IRegistryToken { + const scopeJson = Array.isArray(token.scope) ? JSON.stringify(token.scope) : token.scope; + + this.query( + `INSERT INTO registry_tokens (name, token_hash, token_type, scope, expires_at, created_at, last_used_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + token.name, + token.tokenHash, + token.type, + scopeJson, + token.expiresAt, + token.createdAt, + token.lastUsedAt, + token.createdBy, + ] + ); + + const rows = this.query('SELECT * FROM registry_tokens WHERE id = last_insert_rowid()'); + return this.rowToToken(rows[0]); + } + + getTokenById(id: number): IRegistryToken | null { + const rows = this.query('SELECT * FROM registry_tokens WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToToken(rows[0]) : null; + } + + getTokenByHash(tokenHash: string): IRegistryToken | null { + const rows = this.query('SELECT * FROM registry_tokens WHERE token_hash = ?', [tokenHash]); + return rows.length > 0 ? this.rowToToken(rows[0]) : null; + } + + getAllTokens(): IRegistryToken[] { + const rows = this.query('SELECT * FROM registry_tokens ORDER BY created_at DESC'); + return rows.map((row) => this.rowToToken(row)); + } + + getTokensByType(type: 'global' | 'ci'): IRegistryToken[] { + const rows = this.query('SELECT * FROM registry_tokens WHERE token_type = ? ORDER BY created_at DESC', [type]); + return rows.map((row) => this.rowToToken(row)); + } + + updateTokenLastUsed(id: number): void { + this.query('UPDATE registry_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), id]); + } + + deleteToken(id: number): void { + this.query('DELETE FROM registry_tokens WHERE id = ?', [id]); + } + + private rowToToken(row: any): IRegistryToken { + let scope: 'all' | string[]; + const scopeRaw = row.scope || row[4]; + if (scopeRaw === 'all') { + scope = 'all'; + } else { + try { + scope = JSON.parse(String(scopeRaw)); + } catch { + scope = 'all'; + } + } + + return { + id: Number(row.id || row[0]), + name: String(row.name || row[1]), + tokenHash: String(row.token_hash || row[2]), + type: String(row.token_type || row[3]) as IRegistryToken['type'], + scope, + expiresAt: row.expires_at || row[5] ? Number(row.expires_at || row[5]) : null, + createdAt: Number(row.created_at || row[6]), + lastUsedAt: row.last_used_at || row[7] ? Number(row.last_used_at || row[7]) : null, + createdBy: String(row.created_by || row[8]), + }; + } +} diff --git a/ts/database/repositories/service.repository.ts b/ts/database/repositories/service.repository.ts new file mode 100644 index 0000000..700a22b --- /dev/null +++ b/ts/database/repositories/service.repository.ts @@ -0,0 +1,177 @@ +/** + * Service Repository + * Handles CRUD operations for services table + */ + +import { BaseRepository } from '../base.repository.ts'; +import type { TBindValue } from '../types.ts'; +import type { IService, IPlatformRequirements } from '../../types.ts'; +import { logger } from '../../logging.ts'; +import { getErrorMessage } from '../../utils/error.ts'; + +export class ServiceRepository extends BaseRepository { + async create(service: Omit): Promise { + const now = Date.now(); + this.query( + `INSERT INTO services ( + name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, + use_onebox_registry, registry_repository, registry_image_tag, + auto_update_on_push, image_digest, platform_requirements + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + service.name, + service.image, + service.registry || null, + JSON.stringify(service.envVars), + service.port, + service.domain || null, + service.containerID || null, + service.status, + now, + now, + service.useOneboxRegistry ? 1 : 0, + service.registryRepository || null, + service.registryImageTag || 'latest', + service.autoUpdateOnPush ? 1 : 0, + service.imageDigest || null, + JSON.stringify(service.platformRequirements || {}), + ] + ); + + return this.getByName(service.name)!; + } + + getByName(name: string): IService | null { + const rows = this.query('SELECT * FROM services WHERE name = ?', [name]); + if (rows.length > 0) { + logger.info(`getServiceByName: raw row data: ${JSON.stringify(rows[0])}`); + const service = this.rowToService(rows[0]); + logger.info(`getServiceByName: service object containerID: ${service.containerID}`); + return service; + } + return null; + } + + getById(id: number): IService | null { + const rows = this.query('SELECT * FROM services WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToService(rows[0]) : null; + } + + getAll(): IService[] { + const rows = this.query('SELECT * FROM services ORDER BY created_at DESC'); + return rows.map((row) => this.rowToService(row)); + } + + update(id: number, updates: Partial): void { + const fields: string[] = []; + const values: TBindValue[] = []; + + 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); + } + if (updates.useOneboxRegistry !== undefined) { + fields.push('use_onebox_registry = ?'); + values.push(updates.useOneboxRegistry ? 1 : 0); + } + if (updates.registryRepository !== undefined) { + fields.push('registry_repository = ?'); + values.push(updates.registryRepository); + } + if (updates.registryImageTag !== undefined) { + fields.push('registry_image_tag = ?'); + values.push(updates.registryImageTag); + } + if (updates.autoUpdateOnPush !== undefined) { + fields.push('auto_update_on_push = ?'); + values.push(updates.autoUpdateOnPush ? 1 : 0); + } + if (updates.imageDigest !== undefined) { + fields.push('image_digest = ?'); + values.push(updates.imageDigest); + } + if (updates.platformRequirements !== undefined) { + fields.push('platform_requirements = ?'); + values.push(JSON.stringify(updates.platformRequirements)); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values); + } + + delete(id: number): void { + this.query('DELETE FROM services WHERE id = ?', [id]); + } + + private rowToService(row: any): IService { + 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: ${getErrorMessage(e)}`); + envVars = {}; + } + } + + let platformRequirements: IPlatformRequirements | undefined; + const platformReqRaw = row.platform_requirements; + if (platformReqRaw && platformReqRaw !== 'undefined' && platformReqRaw !== 'null' && platformReqRaw !== '{}') { + try { + platformRequirements = JSON.parse(String(platformReqRaw)); + } catch (e) { + logger.warn(`Failed to parse platform_requirements for service: ${getErrorMessage(e)}`); + platformRequirements = undefined; + } + } + + 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]), + useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined, + registryRepository: row.registry_repository ? String(row.registry_repository) : undefined, + registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined, + autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined, + imageDigest: row.image_digest ? String(row.image_digest) : undefined, + platformRequirements, + }; + } +} diff --git a/ts/database/types.ts b/ts/database/types.ts new file mode 100644 index 0000000..9778443 --- /dev/null +++ b/ts/database/types.ts @@ -0,0 +1,17 @@ +/** + * Database types and interfaces + */ + +import * as plugins from '../plugins.ts'; + +// Type alias for sqlite bind parameters +export type TBindValue = string | number | bigint | boolean | null | undefined | Uint8Array; + +// Database connection type +export type TDatabaseConnection = InstanceType; + +// Query function type +export type TQueryFunction = >( + sql: string, + params?: TBindValue[] +) => T[]; diff --git a/ts/index.ts b/ts/index.ts index 7a205c8..03f69e8 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -4,7 +4,7 @@ export { Onebox } from './classes/onebox.ts'; export { runCli } from './cli.ts'; -export { OneboxDatabase } from './classes/database.ts'; +export { OneboxDatabase } from './database/index.ts'; export { OneboxDockerManager } from './classes/docker.ts'; export { OneboxServicesManager } from './classes/services.ts'; export { OneboxRegistriesManager } from './classes/registries.ts'; diff --git a/ts/types.ts b/ts/types.ts index 31dc221..64a23be 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -153,9 +153,9 @@ export interface ICertificate { domainId: number; certDomain: string; isWildcard: boolean; - certPath: string; - keyPath: string; - fullChainPath: string; + certPem: string; // Certificate PEM content + keyPem: string; // Private key PEM content + fullchainPem: string; // Full chain PEM content (cert + intermediates) expiryDate: number; issuer: string; isValid: boolean; @@ -183,13 +183,13 @@ export interface IDomainView { daysRemaining: number | null; } -// Legacy SSL certificate type (for backward compatibility) +// SSL certificate type - stores certificate content directly in database export interface ISslCertificate { id?: number; domain: string; - certPath: string; - keyPath: string; - fullChainPath: string; + certPem: string; // Certificate PEM content + keyPem: string; // Private key PEM content + fullchainPem: string; // Full chain PEM content (cert + intermediates) expiryDate: number; issuer: string; createdAt: number;