feat(ssl): Add domain & certificate management, Cloudflare sync, SQLite cert manager, WebSocket realtime updates, and HTTP API SSL endpoints

This commit is contained in:
2025-11-18 19:34:26 +00:00
parent 44267bbb27
commit b94aa17eee
16 changed files with 1707 additions and 1344 deletions

View File

@@ -204,15 +204,313 @@ export class OneboxDatabase {
private async runMigrations(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const currentVersion = this.getMigrationVersion();
logger.debug(`Current database version: ${currentVersion}`);
try {
const currentVersion = this.getMigrationVersion();
logger.info(`Current database migration version: ${currentVersion}`);
// Add migration logic here as needed
// For now, just set version to 1
if (currentVersion === 0) {
this.setMigrationVersion(1);
// 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
const existingCerts = this.query('SELECT * FROM ssl_certificates');
const now = Date.now();
const domainMap = new Map<string, number>();
// Create domain entries for each unique base domain
for (const cert of existingCerts) {
const domain = String(cert.domain ?? cert[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('SELECT last_insert_rowid() as id');
const domainId = result[0].id ?? result[0][0];
domainMap.set(domain, Number(domainId));
}
}
// Migrate certificates to new table
for (const cert of existingCerts) {
const domain = String(cert.domain ?? cert[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[2]),
String(cert.key_path ?? cert[3]),
String(cert.full_chain_path ?? cert[4]),
Number(cert.expiry_date ?? cert[5]),
String(cert.issuer ?? cert[6]),
1, // Assume valid
Number(cert.created_at ?? cert[7]),
Number(cert.updated_at ?? cert[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');
}
} catch (error) {
logger.error(`Migration failed: ${error.message}`);
logger.error(`Stack: ${error.stack}`);
throw error;
}
}
/**
* Get current migration version
@@ -222,8 +520,13 @@ export class OneboxDatabase {
try {
const result = this.query('SELECT MAX(version) as version FROM migrations');
return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0;
} catch {
if (result.length === 0) return 0;
// Handle both array and object access patterns
const versionValue = result[0].version ?? result[0][0];
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
} catch (error) {
logger.warn(`Error getting migration version: ${error.message}, defaulting to 0`);
return 0;
}
}
@@ -695,4 +998,321 @@ export class OneboxDatabase {
updatedAt: Number(row.updated_at || row[8]),
};
}
// ============ Domains ============
createDomain(domain: Omit<IDomain, 'id'>): 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<IDomain>): void {
if (!this.db) throw new Error('Database not initialized');
const fields: string[] = [];
const values: unknown[] = [];
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, 'id'>): 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<ICertificate>): void {
if (!this.db) throw new Error('Database not initialized');
const fields: string[] = [];
const values: unknown[] = [];
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, 'id'>): 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<ICertRequirement>): void {
if (!this.db) throw new Error('Database not initialized');
const fields: string[] = [];
const values: unknown[] = [];
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]),
};
}
}