Handle sans in the acme.json

This commit is contained in:
Owen
2026-04-29 10:59:49 -07:00
parent 8c645315f3
commit 5c31d35e28

View File

@@ -250,6 +250,30 @@ function extractFirstCert(pemBundle: string): string | null {
return match ? match[0] : null; return match ? match[0] : null;
} }
/**
* Determine whether an ACME cert entry represents a wildcard cert by checking
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
* Traefik) store the bare apex in `main` and only put the wildcard form in
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
*/
function detectWildcard(
main: string,
sans: string[] | undefined
): { wildcard: boolean; wildcardSan: string | null } {
if (main.startsWith("*.")) {
return { wildcard: true, wildcardSan: null };
}
if (Array.isArray(sans)) {
for (const san of sans) {
if (typeof san !== "string") continue;
if (san === `*.${main}` || san.startsWith("*.")) {
return { wildcard: true, wildcardSan: san };
}
}
}
return { wildcard: false, wildcardSan: null };
}
async function syncAcmeCerts( async function syncAcmeCerts(
acmeJsonPath: string, acmeJsonPath: string,
resolver: string resolver: string
@@ -279,14 +303,15 @@ async function syncAcmeCerts(
} }
for (const cert of resolverData.Certificates) { for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main; const domain = cert?.domain?.main;
const wildcard = domain.startsWith("*.");
if (!domain) { if (!domain || typeof domain !== "string") {
logger.debug(`acmeCertSync: skipping cert with missing domain`); logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue; continue;
} }
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) { if (!cert.certificate || !cert.key) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field` `acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
@@ -294,10 +319,17 @@ async function syncAcmeCerts(
continue; continue;
} }
const certPem = Buffer.from(cert.certificate, "base64").toString( let certPem: string;
"utf8" let keyPem: string;
); try {
const keyPem = Buffer.from(cert.key, "base64").toString("utf8"); certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
);
continue;
}
if (!certPem.trim() || !keyPem.trim()) { if (!certPem.trim() || !keyPem.trim()) {
logger.debug( logger.debug(
@@ -306,6 +338,39 @@ async function syncAcmeCerts(
continue; continue;
} }
// Validate that the decoded data actually parses as a real X.509 cert
// before we touch the database. This prevents importing partially-written
// or corrupted entries from acme.json.
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
// Sanity-check the private key parses too
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
);
continue;
}
// Check if cert already exists in DB // Check if cert already exists in DB
const existing = await db const existing = await db
.select() .select()
@@ -355,18 +420,16 @@ async function syncAcmeCerts(
} }
} }
// Parse cert expiry from the first cert in the PEM bundle // Parse cert expiry from the validated X.509 certificate
let expiresAt: number | null = null; let expiresAt: number | null = null;
const firstCertPem = extractFirstCert(certPem); try {
if (firstCertPem) { expiresAt = Math.floor(
try { new Date(validatedX509.validTo).getTime() / 1000
const x509 = new crypto.X509Certificate(firstCertPem); );
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000); } catch (err) {
} catch (err) { logger.debug(
logger.debug( `acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}` );
);
}
} }
const encryptedCert = encrypt( const encryptedCert = encrypt(