- Added ToastService for managing toast notifications. - Replaced alert in settings component with toast notifications for success and error messages. - Included ToastComponent in layout for displaying notifications. - Created loading spinner component for better user experience. - Implemented domain detail component with detailed views for certificates, requirements, and services. - Added functionality to manage and display SSL certificates and their statuses. - Introduced a registry manager class for handling Docker registry operations.
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
/**
|
|
* 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 './ssl.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 };
|
|
}
|
|
}
|