fix(certificate-handler): preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains

This commit is contained in:
2026-04-08 00:56:02 +00:00
parent d320590ce2
commit f04feec273
5 changed files with 331 additions and 3 deletions

View File

@@ -2,6 +2,28 @@ 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) {
@@ -363,12 +385,34 @@ export class CertificateHandler {
// 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 {
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
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
@@ -392,6 +436,86 @@ export class CertificateHandler {
}
}
/**
* 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
*/