feat(ssl): Add domain & certificate management, Cloudflare sync, SQLite cert manager, WebSocket realtime updates, and HTTP API SSL endpoints
This commit is contained in:
338
ts/classes/cert-requirement-manager.ts
Normal file
338
ts/classes/cert-requirement-manager.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user