""" 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}); ''' CUSTOM_CERT_LOG_ANCHOR = ( '\t\tlogger.info("Writing Custom Certificate:", certificate);' ) CUSTOM_CERT_LOG_FRAGMENT = ( '\t\tlogger.info(`Writing Custom Certificate: Cert #${certificate.id}`);' ) 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 CUSTOM_CERT_LOG_FRAGMENT in source: log("cluster_cert_safe_logging=already-present") elif CUSTOM_CERT_LOG_ANCHOR in source: source = replace_exactly_once( source, CUSTOM_CERT_LOG_ANCHOR, CUSTOM_CERT_LOG_FRAGMENT, description="custom certificate safe logging", ) log("cluster_cert_safe_logging=added") else: raise RuntimeError( "Could not find native or patched custom certificate logging anchor" ) 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, CUSTOM_CERT_LOG_FRAGMENT, description="safe custom certificate logging", ) if CUSTOM_CERT_LOG_ANCHOR in source: raise RuntimeError( "Sensitive custom certificate object logging is still present" ) 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_safe_logging_verification=passed") 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")