mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-22 16:55:44 +00:00
Handle sans in the acme.json
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user