533 lines
15 KiB
Python
533 lines
15 KiB
Python
"""
|
|
Cluster-managed custom certificate mutations.
|
|
|
|
This module does NOT implement ACME renewal.
|
|
|
|
It only:
|
|
1. Adds cluster lifecycle flags to the certificate meta schema.
|
|
2. Preserves logical domain_names when custom certificate material is uploaded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from functions.common import log
|
|
from functions.common import replace_exactly_once
|
|
from functions.common import require_fragment
|
|
|
|
|
|
SCHEMA_PROPERTIES = {
|
|
"cluster_managed": {
|
|
"type": "boolean",
|
|
"default": False,
|
|
},
|
|
"acme_renew": {
|
|
"type": "boolean",
|
|
"default": False,
|
|
},
|
|
}
|
|
|
|
|
|
UPLOAD_DOMAIN_ANCHOR = '''\t\tconst certificate = await internalCertificate.update(access, {
|
|
\t\t\tid: data.id,
|
|
\t\t\texpires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
|
|
\t\t\tdomain_names: validations.certificate.cn ? [validations.certificate.cn] : [],
|
|
\t\t\tmeta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later
|
|
\t\t});
|
|
'''
|
|
|
|
UPLOAD_DOMAIN_FRAGMENT = '''\t\tconst certificate = await internalCertificate.update(access, {
|
|
\t\t\tid: data.id,
|
|
\t\t\texpires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
|
|
\t\t\tdomain_names:
|
|
\t\t\t\tArray.isArray(row.domain_names) && row.domain_names.length
|
|
\t\t\t\t\t? row.domain_names
|
|
\t\t\t\t\t: validations.certificate.cn
|
|
\t\t\t\t\t\t? [validations.certificate.cn]
|
|
\t\t\t\t\t\t: [],
|
|
\t\t\tmeta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later
|
|
\t\t});
|
|
'''
|
|
|
|
|
|
SCHEDULER_PROVIDER_ANCHOR = '.andWhere("provider", "letsencrypt")\n'
|
|
|
|
SCHEDULER_FILTER_ANCHOR = "if (!certificates?.length) {"
|
|
|
|
SCHEDULER_FILTER_FRAGMENT = '''const renewableCertificates = (certificates || []).filter(
|
|
(certificate) =>
|
|
certificate.provider === "letsencrypt" ||
|
|
(
|
|
certificate.provider === "other" &&
|
|
certificate.meta?.cluster_managed === true &&
|
|
certificate.meta?.acme_renew === true
|
|
),
|
|
);
|
|
|
|
if (!renewableCertificates.length) {'''
|
|
|
|
SCHEDULER_FOREACH_ANCHOR = "certificates.forEach((certificate) => {"
|
|
|
|
SCHEDULER_FOREACH_FRAGMENT = (
|
|
"renewableCertificates.forEach((certificate) => {"
|
|
)
|
|
|
|
CLUSTER_RENEW_THROW_ANCHOR = (
|
|
"throw new error.ValidationError("
|
|
"\"Only Let'sEncrypt certificates can be renewed\");"
|
|
)
|
|
|
|
CLUSTER_RENEW_THROW_FRAGMENT = """if (
|
|
certificate.provider === "other" &&
|
|
certificate.meta?.cluster_managed === true &&
|
|
certificate.meta?.acme_renew === true
|
|
) {
|
|
return await internalCertificate.renewClusterManagedSsl(
|
|
access,
|
|
certificate,
|
|
);
|
|
}
|
|
|
|
throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");"""
|
|
|
|
CLUSTER_RENEW_HELPER_ANCHOR = (
|
|
"\trenewLetsEncryptSsl: async (certificate) => {"
|
|
)
|
|
|
|
CLUSTER_RENEW_HELPER_FRAGMENT = """\trenewClusterManagedSsl: async (access, certificate) => {
|
|
requireAcmeAuthority();
|
|
|
|
if (
|
|
certificate.provider !== "other" ||
|
|
certificate.meta?.cluster_managed !== true ||
|
|
certificate.meta?.acme_renew !== true
|
|
) {
|
|
throw new error.ValidationError(
|
|
"Certificate is not eligible for cluster-managed ACME renewal",
|
|
);
|
|
}
|
|
|
|
const livePath = internalCertificate.getLiveCertPath(certificate.id);
|
|
|
|
if (internalCertificate.hasLetsEncryptSslCerts(certificate)) {
|
|
const renewMethod = certificate.meta?.dns_challenge
|
|
? internalCertificate.renewLetsEncryptSslWithDnsChallenge
|
|
: internalCertificate.renewLetsEncryptSsl;
|
|
|
|
await renewMethod(certificate);
|
|
} else {
|
|
const user = await userModel
|
|
.query()
|
|
.where("is_deleted", 0)
|
|
.andWhere("id", certificate.owner_user_id)
|
|
.first();
|
|
|
|
if (!user?.email) {
|
|
throw new error.ValidationError(
|
|
"A valid email address must be set on the certificate owner account to use Let's Encrypt",
|
|
);
|
|
}
|
|
|
|
const inUseResult = await internalHost.getHostsWithDomains(
|
|
certificate.domain_names,
|
|
);
|
|
|
|
await internalCertificate.disableInUseHosts(inUseResult);
|
|
|
|
if (certificate.meta?.dns_challenge) {
|
|
try {
|
|
await internalNginx.reload();
|
|
|
|
await internalCertificate.requestLetsEncryptSslWithDnsChallenge(
|
|
certificate,
|
|
user.email,
|
|
);
|
|
|
|
await internalNginx.reload();
|
|
await internalCertificate.enableInUseHosts(inUseResult);
|
|
} catch (err) {
|
|
await internalCertificate.enableInUseHosts(inUseResult);
|
|
await internalNginx.reload();
|
|
throw err;
|
|
}
|
|
} else {
|
|
try {
|
|
await internalNginx.generateLetsEncryptRequestConfig(
|
|
certificate,
|
|
);
|
|
|
|
await internalNginx.reload();
|
|
|
|
await internalCertificate.requestLetsEncryptSsl(
|
|
certificate,
|
|
user.email,
|
|
);
|
|
|
|
await internalNginx.deleteLetsEncryptRequestConfig(
|
|
certificate,
|
|
);
|
|
|
|
await internalNginx.reload();
|
|
await internalCertificate.enableInUseHosts(inUseResult);
|
|
} catch (err) {
|
|
await internalNginx.deleteLetsEncryptRequestConfig(
|
|
certificate,
|
|
);
|
|
|
|
await internalCertificate.enableInUseHosts(inUseResult);
|
|
await internalNginx.reload();
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
const certPath = `${livePath}/cert.pem`;
|
|
const chainPath = `${livePath}/chain.pem`;
|
|
const keyPath = `${livePath}/privkey.pem`;
|
|
|
|
const certInfo =
|
|
await internalCertificate.getCertificateInfoFromFile(
|
|
certPath,
|
|
true,
|
|
);
|
|
|
|
const certificatePem = fs.readFileSync(certPath, "utf8");
|
|
const intermediateCertificate = fs.readFileSync(
|
|
chainPath,
|
|
"utf8",
|
|
);
|
|
const certificateKey = fs.readFileSync(keyPath, "utf8");
|
|
|
|
const updatedMeta = _.assign({}, certificate.meta, {
|
|
certificate: certificatePem,
|
|
intermediate_certificate: intermediateCertificate,
|
|
certificate_key: certificateKey,
|
|
});
|
|
|
|
const previousMeta = _.cloneDeep(certificate.meta);
|
|
const previousExpiresOn = certificate.expires_on;
|
|
|
|
const stagedCertificate = _.assign({}, certificate, {
|
|
meta: updatedMeta,
|
|
});
|
|
|
|
try {
|
|
await internalCertificate.writeCustomCert(stagedCertificate);
|
|
} catch (err) {
|
|
try {
|
|
await internalCertificate.writeCustomCert(certificate);
|
|
} catch (rollbackErr) {
|
|
logger.error(
|
|
`Failed restoring cluster certificate files after staging failure: ${rollbackErr.message}`,
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
let updatedCertificate;
|
|
|
|
try {
|
|
updatedCertificate =
|
|
await certificateModel.query().patchAndFetchById(
|
|
certificate.id,
|
|
{
|
|
expires_on: moment(
|
|
certInfo.dates.to,
|
|
"X",
|
|
).format("YYYY-MM-DD HH:mm:ss"),
|
|
meta: updatedMeta,
|
|
},
|
|
);
|
|
|
|
updatedCertificate.meta = updatedMeta;
|
|
} catch (err) {
|
|
try {
|
|
await internalCertificate.writeCustomCert(certificate);
|
|
} catch (rollbackErr) {
|
|
logger.error(
|
|
`Failed restoring cluster certificate files after database failure: ${rollbackErr.message}`,
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await internalNginx.reload();
|
|
} catch (err) {
|
|
try {
|
|
await certificateModel.query().patchAndFetchById(
|
|
certificate.id,
|
|
{
|
|
expires_on: previousExpiresOn,
|
|
meta: previousMeta,
|
|
},
|
|
);
|
|
|
|
certificate.meta = previousMeta;
|
|
await internalCertificate.writeCustomCert(certificate);
|
|
} catch (rollbackErr) {
|
|
logger.error(
|
|
`Failed rolling back cluster certificate after nginx reload failure: ${rollbackErr.message}`,
|
|
);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
|
|
const auditCertificate = _.cloneDeep(updatedCertificate);
|
|
|
|
auditCertificate.meta = internalCertificate.cleanMeta(
|
|
auditCertificate.meta,
|
|
);
|
|
|
|
await internalAuditLog.add(access, {
|
|
action: "renewed",
|
|
object_type: "certificate",
|
|
object_id: updatedCertificate.id,
|
|
meta: auditCertificate,
|
|
});
|
|
|
|
return updatedCertificate;
|
|
},
|
|
|
|
renewLetsEncryptSsl: async (certificate) => {"""
|
|
|
|
|
|
def apply_certificate_js(source: str) -> str:
|
|
if UPLOAD_DOMAIN_FRAGMENT in source:
|
|
log("cluster_cert_upload_domains=already-present")
|
|
else:
|
|
source = replace_exactly_once(
|
|
source,
|
|
UPLOAD_DOMAIN_ANCHOR,
|
|
UPLOAD_DOMAIN_FRAGMENT,
|
|
description="cluster certificate upload domain preservation",
|
|
)
|
|
log("cluster_cert_upload_domains=added")
|
|
|
|
if SCHEDULER_PROVIDER_ANCHOR in source:
|
|
source = replace_exactly_once(
|
|
source,
|
|
SCHEDULER_PROVIDER_ANCHOR,
|
|
"",
|
|
description="cluster certificate scheduler provider restriction",
|
|
)
|
|
log("cluster_cert_scheduler_query=added")
|
|
elif SCHEDULER_FILTER_FRAGMENT in source:
|
|
log("cluster_cert_scheduler_query=already-present")
|
|
else:
|
|
raise RuntimeError(
|
|
"cluster certificate scheduler query: "
|
|
"provider restriction missing but classifier is not present"
|
|
)
|
|
|
|
if SCHEDULER_FILTER_FRAGMENT in source:
|
|
log("cluster_cert_scheduler_filter=already-present")
|
|
else:
|
|
source = replace_exactly_once(
|
|
source,
|
|
SCHEDULER_FILTER_ANCHOR,
|
|
SCHEDULER_FILTER_FRAGMENT,
|
|
description="cluster certificate scheduler filter",
|
|
)
|
|
log("cluster_cert_scheduler_filter=added")
|
|
|
|
if SCHEDULER_FOREACH_FRAGMENT in source:
|
|
log("cluster_cert_scheduler_foreach=already-present")
|
|
else:
|
|
source = replace_exactly_once(
|
|
source,
|
|
SCHEDULER_FOREACH_ANCHOR,
|
|
SCHEDULER_FOREACH_FRAGMENT,
|
|
description="cluster certificate scheduler foreach",
|
|
)
|
|
log("cluster_cert_scheduler_foreach=added")
|
|
|
|
if CLUSTER_RENEW_HELPER_FRAGMENT in source:
|
|
log("cluster_cert_renew_helper=already-present")
|
|
else:
|
|
source = replace_exactly_once(
|
|
source,
|
|
CLUSTER_RENEW_HELPER_ANCHOR,
|
|
CLUSTER_RENEW_HELPER_FRAGMENT,
|
|
description="cluster certificate renewal helper",
|
|
)
|
|
log("cluster_cert_renew_helper=added")
|
|
|
|
if CLUSTER_RENEW_THROW_FRAGMENT in source:
|
|
log("cluster_cert_renew_dispatch=already-present")
|
|
else:
|
|
source = replace_exactly_once(
|
|
source,
|
|
CLUSTER_RENEW_THROW_ANCHOR,
|
|
CLUSTER_RENEW_THROW_FRAGMENT,
|
|
description="cluster certificate renewal dispatch",
|
|
)
|
|
log("cluster_cert_renew_dispatch=added")
|
|
|
|
return source
|
|
|
|
def verify_certificate_js(source: str) -> None:
|
|
require_fragment(
|
|
source,
|
|
UPLOAD_DOMAIN_FRAGMENT,
|
|
description="cluster certificate upload domain verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
"await internalCertificate.writeCustomCert(certificate);",
|
|
description="cluster certificate custom write verification",
|
|
)
|
|
|
|
if SCHEDULER_PROVIDER_ANCHOR in source:
|
|
raise RuntimeError(
|
|
"cluster certificate scheduler verification: "
|
|
"old letsencrypt-only provider restriction remains"
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
SCHEDULER_FILTER_FRAGMENT,
|
|
description="cluster certificate scheduler filter verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
SCHEDULER_FOREACH_FRAGMENT,
|
|
description="cluster certificate scheduler foreach verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
CLUSTER_RENEW_HELPER_FRAGMENT,
|
|
description="cluster certificate renewal helper verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
CLUSTER_RENEW_THROW_FRAGMENT,
|
|
description="cluster certificate renewal dispatch verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
'fs.readFileSync(certPath, "utf8")',
|
|
description="cluster certificate leaf import verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
'fs.readFileSync(keyPath, "utf8")',
|
|
description="cluster certificate private key import verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
"await internalCertificate.writeCustomCert(stagedCertificate);",
|
|
description="cluster certificate staged serving copy verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
"previousExpiresOn = certificate.expires_on",
|
|
description="cluster certificate rollback expiry verification",
|
|
)
|
|
|
|
require_fragment(
|
|
source,
|
|
"meta: previousMeta",
|
|
description="cluster certificate database rollback verification",
|
|
)
|
|
|
|
log("cluster_cert_upload_domains_verification=passed")
|
|
log("cluster_cert_scheduler_verification=passed")
|
|
log("cluster_cert_renew_helper_verification=passed")
|
|
log("cluster_cert_renew_dispatch_verification=passed")
|
|
|
|
def apply_schema(source: str) -> str:
|
|
try:
|
|
document = json.loads(source)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(
|
|
f"cluster certificate schema: invalid JSON: {exc}"
|
|
) from exc
|
|
|
|
try:
|
|
meta = document["properties"]["meta"]
|
|
meta_properties = meta["properties"]
|
|
except (KeyError, TypeError) as exc:
|
|
raise RuntimeError(
|
|
"cluster certificate schema: "
|
|
"properties.meta.properties not found"
|
|
) from exc
|
|
|
|
if meta.get("additionalProperties") is not False:
|
|
raise RuntimeError(
|
|
"cluster certificate schema: "
|
|
"meta.additionalProperties is not false"
|
|
)
|
|
|
|
changed = False
|
|
|
|
for name, definition in SCHEMA_PROPERTIES.items():
|
|
existing = meta_properties.get(name)
|
|
|
|
if existing is None:
|
|
meta_properties[name] = definition
|
|
changed = True
|
|
elif existing != definition:
|
|
raise RuntimeError(
|
|
"cluster certificate schema: "
|
|
f"{name} already exists with unexpected definition"
|
|
)
|
|
|
|
if not changed:
|
|
verify_schema(source)
|
|
log("cluster_cert_schema=already-present")
|
|
return source
|
|
|
|
result = json.dumps(
|
|
document,
|
|
indent="\t",
|
|
ensure_ascii=False,
|
|
) + "\n"
|
|
|
|
verify_schema(result)
|
|
log("cluster_cert_schema=added")
|
|
return result
|
|
|
|
|
|
def verify_schema(source: str) -> None:
|
|
try:
|
|
document = json.loads(source)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(
|
|
f"cluster certificate schema verification: invalid JSON: {exc}"
|
|
) from exc
|
|
|
|
try:
|
|
meta = document["properties"]["meta"]
|
|
meta_properties = meta["properties"]
|
|
except (KeyError, TypeError) as exc:
|
|
raise RuntimeError(
|
|
"cluster certificate schema verification: "
|
|
"properties.meta.properties not found"
|
|
) from exc
|
|
|
|
if meta.get("additionalProperties") is not False:
|
|
raise RuntimeError(
|
|
"cluster certificate schema verification: "
|
|
"meta.additionalProperties is not false"
|
|
)
|
|
|
|
for name, definition in SCHEMA_PROPERTIES.items():
|
|
if meta_properties.get(name) != definition:
|
|
raise RuntimeError(
|
|
"cluster certificate schema verification: "
|
|
f"{name} definition is missing or unexpected"
|
|
)
|
|
|
|
log("cluster_cert_schema_verification=passed")
|