fix(certificate-handler): preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.1.2',
|
||||
version: '13.1.3',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user