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 }; } ) ); // Legacy route-based reprovision (backward compat) this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'reprovisionCertificate', async (dataArg) => { return this.reprovisionCertificateByRoute(dataArg.routeName); } ) ); // Domain-based reprovision (preferred) this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'reprovisionCertificateDomain', async (dataArg) => { return this.reprovisionCertificateDomain(dataArg.domain); } ) ); } /** * Build domain-centric certificate overview. * Instead of one row per route, we produce one row per unique domain. */ private async buildCertificateOverview(): Promise { const dcRouter = this.opsServerRef.dcRouterRef; const smartProxy = dcRouter.smartProxy; if (!smartProxy) return []; const routes = smartProxy.routeManager.getRoutes(); // Phase 1: Collect unique domains with their associated route info const domainMap = new Map(); 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') { if ((smartProxy.settings as any).certProvisionFunction) { source = 'provision-function'; } else { source = 'acme'; } } else if (tls.certificate && typeof tls.certificate === 'object') { source = 'static'; } const canReprovision = source === 'acme' || source === 'provision-function'; const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough'; for (const domain of routeDomains) { const existing = domainMap.get(domain); if (existing) { // Add this route name to the existing domain entry if (!existing.routeNames.includes(route.name)) { existing.routeNames.push(route.name); } // Upgrade source if more specific if (existing.source === 'none' && source !== 'none') { existing.source = source; existing.canReprovision = canReprovision; } } else { domainMap.set(domain, { routeNames: [route.name], source, tlsMode, canReprovision, }); } } } // Phase 2: Resolve status for each unique domain const certificates: interfaces.requests.ICertificateInfo[] = []; for (const [domain, info] of domainMap) { 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 certificateStatusMap (now keyed by domain) const eventStatus = dcRouter.certificateStatusMap.get(domain); if (eventStatus) { status = eventStatus.status; expiryDate = eventStatus.expiryDate; issuedAt = eventStatus.issuedAt; error = eventStatus.error; if (eventStatus.source) { issuer = eventStatus.source; } } // Try SmartProxy certificate status if no event data if (status === 'unknown' && info.routeNames.length > 0) { try { const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]); 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') { const cleanDomain = domain.replace(/^\*\.?/, ''); let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`); if (!certData) { // Also check certStore path (proxy-certs) certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`); } if (certData?.validUntil) { expiryDate = new Date(certData.validUntil).toISOString(); if (certData.created) { issuedAt = new Date(certData.created).toISOString(); } issuer = 'smartacme-dns-01'; } else if (certData?.publicKey) { // certStore has the cert — parse PEM for expiry try { const x509 = new plugins.crypto.X509Certificate(certData.publicKey); expiryDate = new Date(x509.validTo).toISOString(); issuedAt = new Date(x509.validFrom).toISOString(); } catch { /* PEM parsing failed */ } status = 'valid'; issuer = 'cert-store'; } else if (certData) { status = 'valid'; issuer = 'cert-store'; } } // Compute status from expiry date 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 (info.source === 'static' && status === 'unknown') { status = 'valid'; } // ACME/provision-function routes with no cert data are still provisioning if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) { status = 'provisioning'; } // Phase 3: Attach backoff info let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo']; if (dcRouter.certProvisionScheduler) { const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain); if (bi) { backoffInfo = bi; } } certificates.push({ domain, routeNames: info.routeNames, status, source: info.source, tlsMode: info.tlsMode, expiryDate, issuer, issuedAt, error, canReprovision: info.canReprovision, backoffInfo, }); } 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; } /** * Legacy route-based reprovisioning */ private async reprovisionCertificateByRoute(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 for domains in this route for (const [domain, entry] of dcRouter.certificateStatusMap) { if (entry.routeNames.includes(routeName)) { dcRouter.certificateStatusMap.delete(domain); } } return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; } catch (err) { return { success: false, message: err.message || 'Failed to reprovision certificate' }; } } /** * Domain-based reprovisioning — clears backoff first, then triggers provision */ private async reprovisionCertificateDomain(domain: 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' }; } // Clear backoff for this domain (user override) if (dcRouter.certProvisionScheduler) { await dcRouter.certProvisionScheduler.clearBackoff(domain); } // Clear status map entry so it gets refreshed dcRouter.certificateStatusMap.delete(domain); // Try to provision via SmartAcme directly if (dcRouter.smartAcme) { try { await dcRouter.smartAcme.getCertificateForDomain(domain); return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` }; } catch (err) { return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` }; } } // Fallback: try provisioning via the first matching route const routeNames = dcRouter.findRouteNamesForDomain(domain); if (routeNames.length > 0) { try { await smartProxy.provisionCertificate(routeNames[0]); return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` }; } catch (err) { return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` }; } } return { success: false, message: `No routes found for domain '${domain}'` }; } }