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

@@ -36,6 +36,24 @@ Common mistakes to avoid:
- `ts/classes/` - All class implementations
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
### WebSocket Real-time Communication
- **Backend**: WebSocket endpoint at `/api/ws` (`ts/classes/httpserver.ts:96-174`)
- Connection management with client Set tracking
- Broadcast methods: `broadcast()`, `broadcastServiceUpdate()`, `broadcastServiceStatus()`
- Integrated with service lifecycle (start/stop/restart actions)
- Status monitoring loop broadcasts changes automatically
- **Frontend**: Angular WebSocket service (`ui/src/app/core/services/websocket.service.ts`)
- Auto-connects on app initialization
- Exponential backoff reconnection (max 5 attempts)
- RxJS Observable-based message streaming
- Components subscribe to real-time updates
- **Message Types**:
- `connected` - Initial connection confirmation
- `service_update` - Service lifecycle changes (action: created/updated/deleted/started/stopped)
- `service_status` - Real-time status changes from monitoring loop
- `system_status` - System-wide updates
- **Testing**: Use `.nogit/test-ws-updates.ts` to monitor WebSocket messages
### Docker Configuration
- **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless)
- **Swarm Mode**: Enabled for service orchestration

View File

@@ -17,7 +17,7 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.0.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.1.0",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
},

1242
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
/**
* Certificate Requirement Manager
*
* Manages the lifecycle of SSL certificates based on service requirements.
* Automatically acquires, renews, and cleans up certificates.
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxSslManager } from './sslmanager.ts';
import type { ICertRequirement, ICertificate, IDomain } from '../types.ts';
export class CertRequirementManager {
private database: OneboxDatabase;
private sslManager: OneboxSslManager;
// Certificate renewal threshold (30 days before expiry)
private readonly RENEWAL_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
// Certificate cleanup delay (90 days after becoming invalid)
private readonly CLEANUP_DELAY_MS = 90 * 24 * 60 * 60 * 1000;
constructor(database: OneboxDatabase, sslManager: OneboxSslManager) {
this.database = database;
this.sslManager = sslManager;
}
/**
* Process all pending certificate requirements
* Matches requirements to existing certificates or schedules acquisition
*/
async processPendingRequirements(): Promise<void> {
try {
const allRequirements = this.database.getAllCertRequirements();
const pendingRequirements = allRequirements.filter((req) => req.status === 'pending');
logger.info(`Processing ${pendingRequirements.length} pending certificate requirement(s)`);
for (const requirement of pendingRequirements) {
try {
await this.processRequirement(requirement);
} catch (error) {
logger.error(
`Failed to process requirement ${requirement.id}: ${error.message}`
);
}
}
} catch (error) {
logger.error(`Failed to process pending requirements: ${error.message}`);
}
}
/**
* Process a single certificate requirement
*/
private async processRequirement(requirement: ICertRequirement): Promise<void> {
const domain = this.database.getDomainById(requirement.domainId);
if (!domain) {
logger.error(`Domain ${requirement.domainId} not found for requirement ${requirement.id}`);
return;
}
// Construct the full domain name
const fullDomain = requirement.subdomain
? `${requirement.subdomain}.${domain.domain}`
: domain.domain;
logger.debug(`Processing requirement for domain: ${fullDomain}`);
// Check if a valid certificate already exists
const existingCert = this.findValidCertificate(domain, requirement.subdomain);
if (existingCert) {
// Link existing certificate to requirement
this.database.updateCertRequirement(requirement.id!, {
certificateId: existingCert.id,
status: 'active',
});
logger.info(`Linked existing certificate to requirement for ${fullDomain}`);
} else {
// Schedule certificate acquisition
await this.acquireCertificate(requirement, domain, fullDomain);
}
}
/**
* Find a valid certificate for the given domain and subdomain
*/
private findValidCertificate(
domain: IDomain,
subdomain: string
): ICertificate | null {
const certificates = this.database.getCertificatesByDomain(domain.id!);
const now = Date.now();
for (const cert of certificates) {
// Skip invalid or expired certificates
if (!cert.isValid || cert.expiryDate <= now) {
continue;
}
// Check if certificate covers the required domain
if (cert.isWildcard && !subdomain) {
// Wildcard cert covers base domain
return cert;
} else if (cert.isWildcard && subdomain) {
// Wildcard cert covers first-level subdomains
const levels = subdomain.split('.').length;
if (levels === 1) {
return cert;
}
} else if (!cert.isWildcard && cert.certDomain === domain.domain && !subdomain) {
// Exact match for base domain
return cert;
} else if (!cert.isWildcard && subdomain) {
// Exact match for specific subdomain
const fullDomain = `${subdomain}.${domain.domain}`;
if (cert.certDomain === fullDomain) {
return cert;
}
}
}
return null;
}
/**
* Acquire a new certificate for the requirement
*/
private async acquireCertificate(
requirement: ICertRequirement,
domain: IDomain,
fullDomain: string
): Promise<void> {
try {
logger.info(`Acquiring certificate for ${fullDomain}...`);
// Determine if we should use wildcard
const useWildcard = domain.defaultWildcard && !requirement.subdomain;
// Acquire certificate using SSL manager
const certData = await this.sslManager.acquireCertificate(
domain.domain,
useWildcard
);
// Store certificate in database
const now = Date.now();
const certificate = this.database.createCertificate({
domainId: domain.id!,
certDomain: domain.domain,
isWildcard: useWildcard,
certPath: certData.certPath,
keyPath: certData.keyPath,
fullChainPath: certData.fullChainPath,
expiryDate: certData.expiryDate,
issuer: certData.issuer,
isValid: true,
createdAt: now,
updatedAt: now,
});
// Link certificate to requirement
this.database.updateCertRequirement(requirement.id!, {
certificateId: certificate.id,
status: 'active',
});
logger.success(`Certificate acquired for ${fullDomain}`);
} catch (error) {
logger.error(`Failed to acquire certificate for ${fullDomain}: ${error.message}`);
throw error;
}
}
/**
* Check all certificates for renewal
*/
async checkCertificateRenewal(): Promise<void> {
try {
const allCertificates = this.database.getAllCertificates();
const now = Date.now();
for (const cert of allCertificates) {
// Skip invalid certificates
if (!cert.isValid) {
continue;
}
// Check if certificate is expired
if (cert.expiryDate <= now) {
// Mark as invalid
this.database.updateCertificate(cert.id!, { isValid: false });
logger.warn(`Certificate ${cert.id} for ${cert.certDomain} has expired`);
continue;
}
// Check if certificate needs renewal
const timeUntilExpiry = cert.expiryDate - now;
if (timeUntilExpiry <= this.RENEWAL_THRESHOLD_MS) {
await this.renewCertificate(cert);
}
}
} catch (error) {
logger.error(`Failed to check certificate renewal: ${error.message}`);
}
}
/**
* Renew a certificate
*/
private async renewCertificate(cert: ICertificate): Promise<void> {
try {
logger.info(`Renewing certificate for ${cert.certDomain}...`);
const domain = this.database.getDomainById(cert.domainId);
if (!domain) {
logger.error(`Domain ${cert.domainId} not found for certificate ${cert.id}`);
return;
}
// Mark all requirements using this certificate as renewing
const requirements = this.database.getAllCertRequirements();
const relatedRequirements = requirements.filter(
(req) => req.certificateId === cert.id
);
for (const req of relatedRequirements) {
this.database.updateCertRequirement(req.id!, { status: 'renewing' });
}
// Acquire new certificate
const certData = await this.sslManager.acquireCertificate(
domain.domain,
cert.isWildcard
);
// Update certificate in database
this.database.updateCertificate(cert.id!, {
certPath: certData.certPath,
keyPath: certData.keyPath,
fullChainPath: certData.fullChainPath,
expiryDate: certData.expiryDate,
issuer: certData.issuer,
});
// Mark requirements as active again
for (const req of relatedRequirements) {
this.database.updateCertRequirement(req.id!, { status: 'active' });
}
logger.success(`Certificate renewed for ${cert.certDomain}`);
} catch (error) {
logger.error(`Failed to renew certificate for ${cert.certDomain}: ${error.message}`);
}
}
/**
* Clean up old invalid certificates (90+ days old)
*/
async cleanupOldCertificates(): Promise<void> {
try {
const allCertificates = this.database.getAllCertificates();
const now = Date.now();
let deletedCount = 0;
for (const cert of allCertificates) {
// Only clean up invalid certificates
if (!cert.isValid) {
// 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}: ${error.message}`
);
}
// Delete from database
this.database.deleteCertificate(cert.id!);
deletedCount++;
logger.info(
`Deleted old certificate ${cert.id} for ${cert.certDomain} (expired ${new Date(
cert.expiryDate
).toISOString()})`
);
}
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} old certificate(s)`);
}
} catch (error) {
logger.error(`Failed to cleanup old certificates: ${error.message}`);
}
}
/**
* Get certificate status for a domain
*/
getCertificateStatus(domainId: number): {
valid: number;
expiringSoon: number;
expired: number;
pending: number;
} {
const certificates = this.database.getCertificatesByDomain(domainId);
const requirements = this.database.getCertRequirementsByDomain(domainId);
const now = Date.now();
let valid = 0;
let expiringSoon = 0;
let expired = 0;
for (const cert of certificates) {
if (!cert.isValid) {
expired++;
} else if (cert.expiryDate <= now) {
expired++;
} else if (cert.expiryDate - now <= this.RENEWAL_THRESHOLD_MS) {
expiringSoon++;
} else {
valid++;
}
}
const pending = requirements.filter((req) => req.status === 'pending').length;
return { valid, expiringSoon, expired, pending };
}
}

189
ts/classes/certmanager.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* 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.
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
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') {
this.database = database;
this.certBasePath = certBasePath;
}
/**
* Initialize the certificate manager
*/
async init(): Promise<void> {
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: ${error.message}`);
throw error;
}
}
/**
* Retrieve a certificate by domain name
*/
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
try {
const dbCert = this.database.getSSLCertificate(domainName);
if (!dbCert) {
return null;
}
// Convert database format to SmartacmeCert format
const cert = new plugins.smartacme.Cert({
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
csr: '', // CSR not stored separately
validUntil: dbCert.expiryDate,
});
return cert;
} catch (error) {
logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`);
return null;
}
}
/**
* Store a certificate
*/
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
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);
// Store/update in database
const existing = this.database.getSSLCertificate(domain);
if (existing) {
this.database.updateSSLCertificate(domain, {
certPath,
keyPath,
fullChainPath,
expiryDate: cert.validUntil,
updatedAt: Date.now(),
});
} else {
await this.database.createSSLCertificate({
domain,
certPath,
keyPath,
fullChainPath,
expiryDate: cert.validUntil,
issuer: 'Let\'s Encrypt',
createdAt: cert.created,
updatedAt: Date.now(),
});
}
logger.success(`Certificate stored for ${domain}`);
} catch (error) {
logger.error(`Failed to store certificate for ${cert.domainName}: ${error.message}`);
throw error;
}
}
/**
* Delete a certificate
*/
async deleteCertificate(domainName: string): Promise<void> {
try {
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}: ${error.message}`);
}
// Delete from database
this.database.deleteSSLCertificate(domainName);
logger.info(`Certificate deleted for ${domainName}`);
}
} catch (error) {
logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`);
throw error;
}
}
/**
* Close the certificate manager
*/
async close(): Promise<void> {
// SQLite database is managed by OneboxDatabase, nothing to close here
logger.info('Certificate manager closed');
}
/**
* Wipe all certificates (for testing)
*/
async wipe(): Promise<void> {
try {
const certs = this.database.getAllSSLCertificates();
for (const cert of certs) {
await this.deleteCertificate(cert.domain);
}
logger.warn('All certificates wiped');
} catch (error) {
logger.error(`Failed to wipe certificates: ${error.message}`);
throw error;
}
}
/**
* Read PEM file from filesystem
*/
private async readPemFile(path: string): Promise<string> {
try {
return await Deno.readTextFile(path);
} catch (error) {
throw new Error(`Failed to read PEM file ${path}: ${error.message}`);
}
}
/**
* Extract the first certificate from a PEM chain
*/
private extractCertFromChain(fullChain: string): string {
const certMatch = fullChain.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/);
return certMatch ? certMatch[0] : fullChain;
}
}

View File

@@ -0,0 +1,165 @@
/**
* Cloudflare Domain Sync Manager
*
* Synchronizes Cloudflare DNS zones with the local Domain table.
* Automatically imports zones and marks obsolete domains when zones are removed.
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import type { IDomain } from '../types.ts';
export class CloudflareDomainSync {
private database: OneboxDatabase;
private cloudflareAccount: plugins.cloudflare.CloudflareAccount | null = null;
constructor(database: OneboxDatabase) {
this.database = database;
}
/**
* Initialize Cloudflare connection
*/
async init(): Promise<void> {
try {
const apiKey = this.database.getSetting('cloudflareAPIKey');
if (!apiKey) {
logger.warn('Cloudflare API key not configured. Domain sync will be limited.');
return;
}
this.cloudflareAccount = new plugins.cloudflare.CloudflareAccount(apiKey);
logger.info('Cloudflare domain sync initialized');
} catch (error) {
logger.error(`Failed to initialize Cloudflare sync: ${error.message}`);
throw error;
}
}
/**
* Check if Cloudflare is configured
*/
isConfigured(): boolean {
return this.cloudflareAccount !== null;
}
/**
* Sync all Cloudflare zones with Domain table
*/
async syncZones(): Promise<void> {
if (!this.isConfigured()) {
logger.warn('Cloudflare not configured, skipping zone sync');
return;
}
try {
logger.info('Starting Cloudflare zone synchronization...');
// Fetch all zones from Cloudflare
const zones = await this.cloudflareAccount!.getZones();
logger.info(`Found ${zones.length} Cloudflare zone(s)`);
const now = Date.now();
const syncedZoneIds = new Set<string>();
// Sync each zone to the Domain table
for (const zone of zones) {
try {
const domain = zone.name;
const zoneId = zone.id;
syncedZoneIds.add(zoneId);
// Check if domain already exists
const existingDomain = this.database.getDomainByName(domain);
if (existingDomain) {
// Update existing domain
this.database.updateDomain(existingDomain.id!, {
dnsProvider: 'cloudflare',
cloudflareZoneId: zoneId,
isObsolete: false, // Re-activate if it was marked obsolete
updatedAt: now,
});
logger.debug(`Updated domain: ${domain}`);
} else {
// Create new domain
this.database.createDomain({
domain,
dnsProvider: 'cloudflare',
cloudflareZoneId: zoneId,
isObsolete: false,
defaultWildcard: true, // Default to wildcard certificates
createdAt: now,
updatedAt: now,
});
logger.info(`Added new domain from Cloudflare: ${domain}`);
}
} catch (error) {
logger.error(`Failed to sync zone ${zone.name}: ${error.message}`);
}
}
// Mark domains as obsolete if their Cloudflare zones no longer exist
await this.markObsoleteDomains(syncedZoneIds);
logger.success(`Cloudflare zone sync completed: ${zones.length} zone(s) synced`);
} catch (error) {
logger.error(`Cloudflare zone sync failed: ${error.message}`);
throw error;
}
}
/**
* Mark domains as obsolete if their Cloudflare zones have been removed
*/
private async markObsoleteDomains(activeZoneIds: Set<string>): Promise<void> {
try {
// Get all domains managed by Cloudflare
const cloudflareDomains = this.database.getDomainsByProvider('cloudflare');
let obsoleteCount = 0;
for (const domain of cloudflareDomains) {
// If domain has a Cloudflare zone ID but it's not in the active set, mark obsolete
if (domain.cloudflareZoneId && !activeZoneIds.has(domain.cloudflareZoneId)) {
this.database.updateDomain(domain.id!, {
isObsolete: true,
updatedAt: Date.now(),
});
logger.warn(`Marked domain as obsolete (zone removed): ${domain.domain}`);
obsoleteCount++;
}
}
if (obsoleteCount > 0) {
logger.info(`Marked ${obsoleteCount} domain(s) as obsolete`);
}
} catch (error) {
logger.error(`Failed to mark obsolete domains: ${error.message}`);
}
}
/**
* Get sync status information
*/
getSyncStatus(): {
configured: boolean;
totalDomains: number;
cloudflareDomains: number;
obsoleteDomains: number;
} {
const allDomains = this.database.getAllDomains();
const cloudflareDomains = allDomains.filter(d => d.dnsProvider === 'cloudflare');
const obsoleteDomains = allDomains.filter(d => d.isObsolete);
return {
configured: this.isConfigured(),
totalDomains: allDomains.length,
cloudflareDomains: cloudflareDomains.length,
obsoleteDomains: obsoleteDomains.length,
};
}
}

View File

@@ -204,14 +204,312 @@ export class OneboxDatabase {
private async runMigrations(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
try {
const currentVersion = this.getMigrationVersion();
logger.debug(`Current database version: ${currentVersion}`);
logger.info(`Current database migration version: ${currentVersion}`);
// Add migration logic here as needed
// For now, just set version to 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;
}
}
/**
@@ -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]),
};
}
}

View File

@@ -214,6 +214,16 @@ export class OneboxHttpServer {
} else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') {
const name = path.split('/')[3];
return await this.handleGetLogsRequest(name);
} else if (path === '/api/ssl/obtain' && method === 'POST') {
return await this.handleObtainCertificateRequest(req);
} else if (path === '/api/ssl/list' && method === 'GET') {
return await this.handleListCertificatesRequest();
} else if (path.match(/^\/api\/ssl\/[^/]+$/) && method === 'GET') {
const domain = path.split('/').pop()!;
return await this.handleGetCertificateRequest(domain);
} else if (path.match(/^\/api\/ssl\/[^/]+\/renew$/) && method === 'POST') {
const domain = path.split('/')[3];
return await this.handleRenewCertificateRequest(domain);
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -442,6 +452,66 @@ export class OneboxHttpServer {
}
}
private async handleObtainCertificateRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { domain, includeWildcard } = body;
if (!domain) {
return this.jsonResponse(
{ success: false, error: 'Domain is required' },
400
);
}
await this.oneboxRef.ssl.obtainCertificate(domain, includeWildcard || false);
return this.jsonResponse({
success: true,
message: `Certificate obtained for ${domain}`,
});
} catch (error) {
logger.error(`Failed to obtain certificate: ${error.message}`);
return this.jsonResponse({ success: false, error: error.message || 'Failed to obtain certificate' }, 500);
}
}
private async handleListCertificatesRequest(): Promise<Response> {
try {
const certificates = this.oneboxRef.ssl.listCertificates();
return this.jsonResponse({ success: true, data: certificates });
} catch (error) {
logger.error(`Failed to list certificates: ${error.message}`);
return this.jsonResponse({ success: false, error: error.message || 'Failed to list certificates' }, 500);
}
}
private async handleGetCertificateRequest(domain: string): Promise<Response> {
try {
const certificate = this.oneboxRef.ssl.getCertificate(domain);
if (!certificate) {
return this.jsonResponse({ success: false, error: 'Certificate not found' }, 404);
}
return this.jsonResponse({ success: true, data: certificate });
} catch (error) {
logger.error(`Failed to get certificate for ${domain}: ${error.message}`);
return this.jsonResponse({ success: false, error: error.message || 'Failed to get certificate' }, 500);
}
}
private async handleRenewCertificateRequest(domain: string): Promise<Response> {
try {
await this.oneboxRef.ssl.renewCertificate(domain);
return this.jsonResponse({
success: true,
message: `Certificate renewed for ${domain}`,
});
} catch (error) {
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
return this.jsonResponse({ success: false, error: error.message || 'Failed to renew certificate' }, 500);
}
}
/**
* Handle WebSocket upgrade
*/

View File

@@ -7,11 +7,13 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import { SqliteCertManager } from './certmanager.ts';
export class OneboxSslManager {
private oneboxRef: any;
private database: OneboxDatabase;
private smartacme: plugins.smartacme.SmartAcme | null = null;
private certManager: SqliteCertManager | null = null;
private acmeEmail: string | null = null;
constructor(oneboxRef: any) {
@@ -35,14 +37,45 @@ export class OneboxSslManager {
this.acmeEmail = acmeEmail;
// Initialize SmartACME
// Get Cloudflare API key (reuse from DNS manager)
const cfApiKey = this.database.getSetting('cloudflareAPIKey');
if (!cfApiKey) {
logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.');
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
return;
}
// Get ACME environment (default: production)
const acmeEnvironment = this.database.getSetting('acmeEnvironment') || 'production';
if (!['production', 'integration'].includes(acmeEnvironment)) {
throw new Error('acmeEnvironment must be "production" or "integration"');
}
// Initialize certificate manager
this.certManager = new SqliteCertManager(this.database);
await this.certManager.init();
// Initialize Cloudflare DNS provider for DNS-01 challenge
const cfAccount = new plugins.cloudflare.CloudflareAccount(cfApiKey);
// Create DNS-01 challenge handler
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cfAccount);
// Initialize SmartACME with proper configuration
this.smartacme = new plugins.smartacme.SmartAcme({
email: acmeEmail,
environment: 'production', // or 'staging' for testing
dns: 'cloudflare', // Use Cloudflare DNS challenge
accountEmail: acmeEmail,
certManager: this.certManager,
environment: acmeEnvironment as 'production' | 'integration',
challengeHandlers: [dns01Handler],
challengePriority: ['dns-01'], // Prefer DNS-01 challenges
});
logger.info('SSL manager initialized with SmartACME');
// Start SmartACME
await this.smartacme.start();
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
} catch (error) {
logger.error(`Failed to initialize SSL manager: ${error.message}`);
throw error;
@@ -57,59 +90,33 @@ export class OneboxSslManager {
}
/**
* Obtain SSL certificate for a domain
* Obtain SSL certificate for a domain using SmartACME
*/
async obtainCertificate(domain: string): Promise<void> {
async obtainCertificate(domain: string, includeWildcard = false): Promise<void> {
try {
if (!this.isConfigured()) {
throw new Error('SSL manager not configured');
}
logger.info(`Obtaining SSL certificate for ${domain}...`);
logger.info(`Obtaining SSL certificate for ${domain} via SmartACME DNS-01...`);
// Check if certificate already exists and is valid
const existing = this.database.getSSLCertificate(domain);
if (existing && existing.expiryDate > Date.now()) {
const existingCert = await this.certManager!.retrieveCertificate(domain);
if (existingCert && existingCert.isStillValid()) {
logger.info(`Valid certificate already exists for ${domain}`);
return;
}
// Use certbot for now (smartacme integration would be more complex)
// This is a simplified version - in production, use proper ACME client
await this.obtainCertificateWithCertbot(domain);
// Store in database
const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`;
const keyPath = `/etc/letsencrypt/live/${domain}/privkey.pem`;
const fullChainPath = `/etc/letsencrypt/live/${domain}/fullchain.pem`;
// Get expiry date (90 days from now for Let's Encrypt)
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
if (existing) {
this.database.updateSSLCertificate(domain, {
certPath,
keyPath,
fullChainPath,
expiryDate,
// Use SmartACME to obtain certificate via DNS-01 challenge
const cert = await this.smartacme!.getCertificateForDomain(domain, {
includeWildcard,
});
} else {
await this.database.createSSLCertificate({
domain,
certPath,
keyPath,
fullChainPath,
expiryDate,
issuer: 'Let\'s Encrypt',
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
logger.success(`SSL certificate obtained for ${domain}`);
logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`);
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
logger.success(`SSL certificate obtained for ${domain}`);
} catch (error) {
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
throw error;
@@ -155,32 +162,21 @@ export class OneboxSslManager {
}
/**
* Renew certificate for a domain
* Renew certificate for a domain using SmartACME
*/
async renewCertificate(domain: string): Promise<void> {
try {
logger.info(`Renewing SSL certificate for ${domain}...`);
const command = new Deno.Command('certbot', {
args: ['renew', '--cert-name', domain, '--non-interactive'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
throw new Error(`Certbot renewal failed: ${errorMsg}`);
if (!this.isConfigured()) {
throw new Error('SSL manager not configured');
}
// Update database
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
this.database.updateSSLCertificate(domain, {
expiryDate,
});
logger.info(`Renewing SSL certificate for ${domain} via SmartACME...`);
// SmartACME will check if renewal is needed and obtain a new certificate
const cert = await this.smartacme!.getCertificateForDomain(domain);
logger.success(`Certificate renewed for ${domain}`);
logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`);
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
@@ -209,21 +205,27 @@ export class OneboxSslManager {
*/
async renewExpiring(): Promise<void> {
try {
if (!this.isConfigured()) {
logger.warn('SSL manager not configured, skipping renewal check');
return;
}
logger.info('Checking for expiring certificates...');
const certificates = this.listCertificates();
const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000;
for (const cert of certificates) {
if (cert.expiryDate < thirtyDaysFromNow) {
logger.info(`Certificate for ${cert.domain} expires soon, renewing...`);
for (const dbCert of certificates) {
try {
await this.renewCertificate(cert.domain);
} catch (error) {
logger.error(`Failed to renew ${cert.domain}: ${error.message}`);
// Continue with other certificates
// Retrieve certificate from cert manager to check if it should be renewed
const cert = await this.certManager!.retrieveCertificate(dbCert.domain);
if (cert && cert.shouldBeRenewed()) {
logger.info(`Certificate for ${dbCert.domain} needs renewal, renewing...`);
await this.renewCertificate(dbCert.domain);
}
} catch (error) {
logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`);
// Continue with other certificates
}
}

View File

@@ -25,9 +25,9 @@ export { smartdaemon };
import { DockerHost } from '@apiclient.xyz/docker';
export const docker = { Docker: DockerHost };
// Cloudflare DNS Management
import Cloudflare from 'npm:cloudflare@5.2.0';
export const cloudflare = { Cloudflare };
// Cloudflare DNS Management (API Client)
import * as cloudflare from '@apiclient.xyz/cloudflare';
export { cloudflare };
// Let's Encrypt / ACME
import * as smartacme from '@push.rocks/smartacme';

View File

@@ -38,7 +38,54 @@ export interface INginxConfig {
updatedAt: number;
}
// SSL certificate types
// Domain management types
export interface IDomain {
id?: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
cloudflareZoneId?: string;
isObsolete: boolean;
defaultWildcard: boolean;
createdAt: number;
updatedAt: number;
}
export interface ICertificate {
id?: number;
domainId: number;
certDomain: string;
isWildcard: boolean;
certPath: string;
keyPath: string;
fullChainPath: string;
expiryDate: number;
issuer: string;
isValid: boolean;
createdAt: number;
updatedAt: number;
}
export interface ICertRequirement {
id?: number;
serviceId: number;
domainId: number;
subdomain: string;
certificateId?: number;
status: 'pending' | 'active' | 'renewing';
createdAt: number;
updatedAt: number;
}
export interface IDomainView {
domain: IDomain;
certificates: ICertificate[];
requirements: ICertRequirement[];
serviceCount: number;
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
daysRemaining: number | null;
}
// Legacy SSL certificate type (for backward compatibility)
export interface ISslCertificate {
id?: number;
domain: string;

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { WebSocketService } from './core/services/websocket.service';
@Component({
selector: 'app-root',
@@ -7,4 +8,16 @@ import { RouterOutlet } from '@angular/router';
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
})
export class AppComponent {}
export class AppComponent implements OnInit, OnDestroy {
private wsService = inject(WebSocketService);
ngOnInit(): void {
// Connect to WebSocket when app starts
this.wsService.connect();
}
ngOnDestroy(): void {
// Disconnect when app is destroyed
this.wsService.disconnect();
}
}

View File

@@ -35,9 +35,17 @@ export interface SystemStatus {
running: boolean;
version: any;
};
nginx: {
status: string;
installed: boolean;
reverseProxy: {
http: {
running: boolean;
port: number;
};
https: {
running: boolean;
port: number;
certificates: number;
};
routes: number;
};
dns: {
configured: boolean;

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';
export interface WebSocketMessage {
type: string;
action?: string;
serviceName?: string;
data?: any;
status?: string;
timestamp: number;
message?: string;
}
@Injectable({
providedIn: 'root'
})
export class WebSocketService {
private ws: WebSocket | null = null;
private messageSubject = new Subject<WebSocketMessage>();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000;
private reconnectTimer: any = null;
constructor() {}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
console.log('WebSocket already connected');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
console.log('Connecting to WebSocket:', wsUrl);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('✓ WebSocket connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
console.log('📨 WebSocket message:', message);
this.messageSubject.next(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
this.ws.onerror = (error) => {
console.error('✖ WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('⚠ WebSocket closed');
this.ws = null;
this.attemptReconnect();
};
}
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
getMessages(): Observable<WebSocketMessage> {
return this.messageSubject.asObservable();
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
}

View File

@@ -110,22 +110,26 @@ import { ApiService, SystemStatus } from '../../core/services/api.service';
</div>
</div>
<!-- Nginx -->
<!-- Reverse Proxy -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Nginx</h3>
<h3 class="text-lg font-medium text-gray-900 mb-4">Reverse Proxy</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span [ngClass]="status()!.nginx.status === 'running' ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.nginx.status }}
<span class="text-sm text-gray-600">HTTP (Port {{ status()!.reverseProxy.http.port }})</span>
<span [ngClass]="status()!.reverseProxy.http.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Installed</span>
<span [ngClass]="status()!.nginx.installed ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.nginx.installed ? 'Yes' : 'No' }}
<span class="text-sm text-gray-600">HTTPS (Port {{ status()!.reverseProxy.https.port }})</span>
<span [ngClass]="status()!.reverseProxy.https.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.reverseProxy.https.running ? 'Running' : 'Stopped' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">SSL Certificates</span>
<span class="badge badge-info">{{ status()!.reverseProxy.https.certificates }}</span>
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ApiService, Service } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-services-list',
@@ -89,14 +91,42 @@ import { ApiService, Service } from '../../core/services/api.service';
</div>
`,
})
export class ServicesListComponent implements OnInit {
export class ServicesListComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
private wsSubscription?: Subscription;
services = signal<Service[]>([]);
loading = signal(true);
ngOnInit(): void {
// Initial load
this.loadServices();
// Subscribe to WebSocket updates
this.wsSubscription = this.wsService.getMessages().subscribe((message) => {
this.handleWebSocketMessage(message);
});
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
}
private handleWebSocketMessage(message: any): void {
if (message.type === 'service_update') {
// Reload the full service list on any service update
this.loadServices();
} else if (message.type === 'service_status') {
// Update individual service status
const currentServices = this.services();
const updatedServices = currentServices.map(s =>
s.name === message.serviceName
? { ...s, status: message.status }
: s
);
this.services.set(updatedServices);
}
}
loadServices(): void {
@@ -117,7 +147,7 @@ export class ServicesListComponent implements OnInit {
startService(service: Service): void {
this.apiService.startService(service.name).subscribe({
next: () => {
this.loadServices();
// WebSocket will handle the update
},
});
}
@@ -125,7 +155,7 @@ export class ServicesListComponent implements OnInit {
stopService(service: Service): void {
this.apiService.stopService(service.name).subscribe({
next: () => {
this.loadServices();
// WebSocket will handle the update
},
});
}
@@ -133,7 +163,7 @@ export class ServicesListComponent implements OnInit {
restartService(service: Service): void {
this.apiService.restartService(service.name).subscribe({
next: () => {
this.loadServices();
// WebSocket will handle the update
},
});
}
@@ -142,7 +172,7 @@ export class ServicesListComponent implements OnInit {
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
this.apiService.deleteService(service.name).subscribe({
next: () => {
this.loadServices();
// WebSocket will handle the update
},
});
}