import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; export class CertificateHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); constructor(private opsServerRef: OpsServer) { this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } private registerHandlers(): void { // Get Certificate Overview this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getCertificateOverview', async (dataArg) => { const certificates = await this.buildCertificateOverview(); const summary = this.buildSummary(certificates); return { certificates, summary }; } ) ); // Reprovision Certificate this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'reprovisionCertificate', async (dataArg) => { return this.reprovisionCertificate(dataArg.routeName); } ) ); } private async buildCertificateOverview(): Promise { const dcRouter = this.opsServerRef.dcRouterRef; const smartProxy = dcRouter.smartProxy; if (!smartProxy) return []; const routes = smartProxy.routeManager.getRoutes(); const certificates: interfaces.requests.ICertificateInfo[] = []; for (const route of routes) { if (!route.name) continue; const tls = route.action?.tls; if (!tls) continue; // Skip passthrough routes - they don't manage certificates if (tls.mode === 'passthrough') continue; const routeDomains = route.match.domains ? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]) : []; // Determine source let source: interfaces.requests.TCertificateSource = 'none'; if (tls.certificate === 'auto') { // Check if a certProvisionFunction is configured if ((smartProxy.settings as any).certProvisionFunction) { source = 'provision-function'; } else { source = 'acme'; } } else if (tls.certificate && typeof tls.certificate === 'object') { source = 'static'; } // Start with unknown status let status: interfaces.requests.TCertificateStatus = 'unknown'; let expiryDate: string | undefined; let issuedAt: string | undefined; let issuer: string | undefined; let error: string | undefined; // Check event-based status from DcRouter's certificateStatusMap const eventStatus = dcRouter.certificateStatusMap.get(route.name); if (eventStatus) { status = eventStatus.status; expiryDate = eventStatus.expiryDate; issuedAt = eventStatus.issuedAt; error = eventStatus.error; if (eventStatus.source) { issuer = eventStatus.source; } } // Try Rust-side certificate status if no event data if (status === 'unknown') { try { const rustStatus = await smartProxy.getCertificateStatus(route.name); if (rustStatus) { if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate; if (rustStatus.issuer) issuer = rustStatus.issuer; if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt; if (rustStatus.status === 'valid' || rustStatus.status === 'expired') { status = rustStatus.status; } } } catch { // Rust bridge may not support this command yet — ignore } } // Check persisted cert data from StorageManager if (status === 'unknown' && routeDomains.length > 0) { for (const domain of routeDomains) { if (expiryDate) break; const cleanDomain = domain.replace(/^\*\.?/, ''); const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`); if (certData?.validUntil) { expiryDate = new Date(certData.validUntil).toISOString(); if (certData.created) { issuedAt = new Date(certData.created).toISOString(); } issuer = 'smartacme-dns-01'; } } } // Compute status from expiry date if we have one and status is still valid/unknown if (expiryDate && (status === 'valid' || status === 'unknown')) { const expiry = new Date(expiryDate); const now = new Date(); const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); if (daysUntilExpiry < 0) { status = 'expired'; } else if (daysUntilExpiry < 30) { status = 'expiring'; } else { status = 'valid'; } } // Static certs with no other info default to 'valid' if (source === 'static' && status === 'unknown') { status = 'valid'; } // ACME/provision-function routes with no cert data are still provisioning if (status === 'unknown' && (source === 'acme' || source === 'provision-function')) { status = 'provisioning'; } const canReprovision = source === 'acme' || source === 'provision-function'; certificates.push({ routeName: route.name, domains: routeDomains, status, source, tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough', expiryDate, issuer, issuedAt, error, canReprovision, }); } return certificates; } private buildSummary(certificates: interfaces.requests.ICertificateInfo[]): { total: number; valid: number; expiring: number; expired: number; failed: number; unknown: number; } { const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 }; summary.total = certificates.length; for (const cert of certificates) { switch (cert.status) { case 'valid': summary.valid++; break; case 'expiring': summary.expiring++; break; case 'expired': summary.expired++; break; case 'failed': summary.failed++; break; case 'provisioning': // count as unknown case 'unknown': summary.unknown++; break; } } return summary; } private async reprovisionCertificate(routeName: string): Promise<{ success: boolean; message?: string }> { const dcRouter = this.opsServerRef.dcRouterRef; const smartProxy = dcRouter.smartProxy; if (!smartProxy) { return { success: false, message: 'SmartProxy is not running' }; } try { await smartProxy.provisionCertificate(routeName); // Clear event-based status so it gets refreshed dcRouter.certificateStatusMap.delete(routeName); return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; } catch (err) { return { success: false, message: err.message || 'Failed to reprovision certificate' }; } } }