Files
dcrouter/ts/opsserver/handlers/certificate.handler.ts

675 lines
25 KiB
TypeScript
Raw Normal View History

import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
import { logger } from '../../logger.js';
/**
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
* @push.rocks/smartacme. Inlined here because the original is `private` on
* SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
* '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
* the same identity share the same underlying ACME cert.
*
* Returns undefined for domains with 4+ levels (matching smartacme's
* "deeper domains not supported" behavior) and for malformed inputs.
*
* Exported for unit testing.
*/
export function deriveCertDomainName(domain: string): string | undefined {
if (domain.startsWith('*.')) {
return domain.slice(2);
}
const parts = domain.split('.');
if (parts.length < 2 || parts.length > 3) return undefined;
return parts.slice(-2).join('.');
}
export class CertificateHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const viewRouter = this.opsServerRef.viewRouter;
const adminRouter = this.opsServerRef.adminRouter;
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
// Get Certificate Overview
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
'getCertificateOverview',
async (dataArg) => {
const certificates = await this.buildCertificateOverview();
const summary = this.buildSummary(certificates);
return { certificates, summary };
}
)
);
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
// Legacy route-based reprovision (backward compat)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
'reprovisionCertificate',
async (dataArg) => {
return this.reprovisionCertificateByRoute(dataArg.routeName);
}
)
);
// Domain-based reprovision (preferred)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
'reprovisionCertificateDomain',
async (dataArg) => {
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
}
)
);
// Delete certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
return this.importCertificate(dataArg.cert);
}
)
);
}
/**
* Build domain-centric certificate overview.
* Instead of one row per route, we produce one row per unique domain.
*/
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
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<string, {
routeNames: string[];
source: interfaces.requests.TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
canReprovision: boolean;
}>();
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 smartdata document classes
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) {
expiryDate = new Date(acmeDoc.validUntil).toISOString();
if (acmeDoc.created) {
issuedAt = new Date(acmeDoc.created).toISOString();
}
issuer = 'smartacme-dns-01';
} else if (proxyDoc?.publicKey) {
// certStore has the cert — parse PEM for expiry
try {
const x509 = new plugins.crypto.X509Certificate(proxyDoc.publicKey);
expiryDate = new Date(x509.validTo).toISOString();
issuedAt = new Date(x509.validFrom).toISOString();
} catch { /* PEM parsing failed */ }
status = 'valid';
issuer = 'cert-store';
} else if (acmeDoc || proxyDoc) {
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. Kept for backward compatibility with
* older clients that send `reprovisionCertificate` typed-requests.
*
* Like reprovisionCertificateDomain, this triggers the full route apply
* pipeline rather than smartProxy.provisionCertificate(routeName) which
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
*/
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' };
}
// Clear event-based status for domains in this route so the
// certificate-issued event can refresh them
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
try {
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || 'Failed to reprovision certificate' };
}
}
/**
* Domain-based reprovisioning clears backoff first, refreshes the smartacme
* cert (when forceRenew is set), then re-applies routes so the running Rust
* proxy actually picks up the new cert.
*
* Why applyRoutes (not smartProxy.provisionCertificate)?
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
* path, which is forcibly disabled whenever certProvisionFunction is set
* (smart-proxy.ts:168-171). The only path that re-invokes
* certProvisionFunction bridge.loadCertificate is updateRoutes(), which
* we trigger via routeConfigManager.applyRoutes().
*/
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): 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);
}
// Find routes matching this domain — fail early if none exist
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length === 0) {
return { success: false, message: `No routes found for domain '${domain}'` };
}
// If forceRenew, order a fresh cert from ACME now so it's already in
// AcmeCertDoc by the time certProvisionFunction is invoked below.
//
// includeWildcard: when forcing a non-wildcard subdomain renewal, we still
// want the wildcard SAN in the order so the new cert keeps covering every
// sibling. Without this, smartacme defaults to includeWildcard: false and
// the re-issued cert would have only the base domain as SAN, breaking every
// sibling subdomain that was previously covered by the same wildcard cert.
if (forceRenew && dcRouter.smartAcme) {
let newCert: plugins.smartacme.Cert;
try {
newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
forceRenew: true,
includeWildcard: !domain.startsWith('*.'),
});
} catch (err: unknown) {
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
}
// Propagate the freshly-issued cert PEM to every sibling route domain that
// shares the same cert identity. Without this, the rust hot-swap (keyed by
// exact domain in `loaded_certs`) only fires for the clicked route via the
// fire-and-forget cert provisioning path, leaving siblings serving the
// stale in-memory cert until the next background reload completes.
try {
await this.propagateCertToSiblings(domain, newCert);
} catch (err: unknown) {
// Best-effort: failure here doesn't undo the cert issuance, just log.
logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${(err as Error).message}`);
}
}
// Clear status map entry so it gets refreshed by the certificate-issued event
dcRouter.certificateStatusMap.delete(domain);
// Trigger the full route apply pipeline:
// applyRoutes → updateRoutes → provisionCertificatesViaCallback →
// certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
// certificate-issued event → certificateStatusMap updated
try {
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
// Fallback when DB is disabled and there is no RouteConfigManager
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
/**
* After a force-renew, walk every route in the smartproxy that resolves to
* the same cert identity as `forcedDomain` and write the freshly-issued cert
* PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
* provisionCertificatesViaCallback iteration will hot-swap every sibling's
* rust loaded_certs entry with the new (correct) PEM, rather than relying on
* the in-memory cert returned by smartacme's per-domain cache.
*
* Why this is necessary:
* Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
* bridge.loadCertificate(domain, ...) only swaps that one entry. The
* fire-and-forget cert provisioning path triggered by updateRoutes does
* eventually iterate every auto-cert route, but it returns the cached
* (broken pre-fix) cert from smartacme's per-domain mutex. With this
* helper, ProxyCertDoc is updated synchronously to the correct PEM before
* applyRoutes runs, so even the transient window stays consistent.
*/
private async propagateCertToSiblings(
forcedDomain: string,
newCert: plugins.smartacme.Cert,
): Promise<void> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return;
const certIdentity = deriveCertDomainName(forcedDomain);
if (!certIdentity) return;
// Collect every route domain whose cert identity matches.
const affected = new Set<string>();
for (const route of smartProxy.routeManager.getRoutes()) {
if (!route.match.domains) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const routeDomain of routeDomains) {
if (deriveCertDomainName(routeDomain) === certIdentity) {
affected.add(routeDomain);
}
}
}
if (affected.size === 0) return;
// Parse expiry from PEM (defense-in-depth — same pattern as
// ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
let validUntil = newCert.validUntil;
let validFrom: number | undefined;
if (newCert.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
validUntil = new Date(x509.validTo).getTime();
validFrom = new Date(x509.validFrom).getTime();
} catch { /* fall back to smartacme's value */ }
}
// Persist new cert PEM under each affected route domain
for (const routeDomain of affected) {
let doc = await ProxyCertDoc.findByDomain(routeDomain);
if (!doc) {
doc = new ProxyCertDoc();
doc.domain = routeDomain;
}
doc.publicKey = newCert.publicKey;
doc.privateKey = newCert.privateKey;
doc.ca = '';
doc.validUntil = validUntil || 0;
doc.validFrom = validFrom || 0;
await doc.save();
// Clear status so the next event refresh shows the new cert
dcRouter.certificateStatusMap.delete(routeDomain);
}
logger.log(
'info',
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
);
}
/**
* Delete certificate data for a domain from storage
*/
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
// Delete from smartdata document classes (try base domain first, then exact)
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (acmeDoc) {
await acmeDoc.delete();
}
// Try both original domain and clean domain for proxy certs
for (const d of [domain, cleanDomain]) {
const proxyDoc = await ProxyCertDoc.findByDomain(d);
if (proxyDoc) {
await proxyDoc.delete();
}
}
// Clear from in-memory status map
dcRouter.certificateStatusMap.delete(domain);
// Clear backoff info
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
return { success: true, message: `Certificate data deleted for '${domain}'` };
}
/**
* Export certificate data for a domain as ICert-shaped JSON
*/
private async exportCertificate(domain: string): Promise<{
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
}> {
const cleanDomain = domain.replace(/^\*\.?/, '');
// Try AcmeCertDoc first (has full ICert fields)
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
return {
success: true,
cert: {
id: acmeDoc.id || plugins.crypto.randomUUID(),
domainName: acmeDoc.domainName || domain,
created: acmeDoc.created || Date.now(),
validUntil: acmeDoc.validUntil || 0,
privateKey: acmeDoc.privateKey,
publicKey: acmeDoc.publicKey,
csr: acmeDoc.csr || '',
},
};
}
// Fallback: try ProxyCertDoc with original domain, then clean domain
let proxyDoc = await ProxyCertDoc.findByDomain(domain);
if (!proxyDoc || !proxyDoc.publicKey) {
proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
}
if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
return {
success: true,
cert: {
id: plugins.crypto.randomUUID(),
domainName: domain,
created: proxyDoc.validFrom || Date.now(),
validUntil: proxyDoc.validUntil || 0,
privateKey: proxyDoc.privateKey,
publicKey: proxyDoc.publicKey,
csr: '',
},
};
}
return { success: false, message: `No certificate data found for '${domain}'` };
}
/**
* Import a certificate from ICert-shaped JSON
*/
private async importCertificate(cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}): Promise<{ success: boolean; message?: string }> {
// Validate PEM content
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
}
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
}
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
// Save to AcmeCertDoc (SmartAcme-compatible)
let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
if (!acmeDoc) {
acmeDoc = new AcmeCertDoc();
acmeDoc.domainName = cleanDomain;
}
acmeDoc.id = cert.id;
acmeDoc.created = cert.created;
acmeDoc.validUntil = cert.validUntil;
acmeDoc.privateKey = cert.privateKey;
acmeDoc.publicKey = cert.publicKey;
acmeDoc.csr = cert.csr || '';
await acmeDoc.save();
// Also save to ProxyCertDoc (proxy-cert format)
let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
if (!proxyDoc) {
proxyDoc = new ProxyCertDoc();
proxyDoc.domain = cert.domainName;
}
proxyDoc.publicKey = cert.publicKey;
proxyDoc.privateKey = cert.privateKey;
proxyDoc.ca = '';
proxyDoc.validUntil = cert.validUntil;
proxyDoc.validFrom = cert.created;
await proxyDoc.save();
// Update in-memory status map
dcRouter.certificateStatusMap.set(cert.domainName, {
status: 'valid',
source: 'static',
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
routeNames: [],
});
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
}
}