From 43826bebed98e15a527e33eaa0501360b5edc53e Mon Sep 17 00:00:00 2001 From: david Date: Sat, 19 Sep 2026 10:22:03 -0400 Subject: [PATCH] Add cluster-managed certificate metadata support --- README.md | 23 ++++++ functions/cluster_cert.py | 169 ++++++++++++++++++++++++++++++++++++++ functions/common.py | 9 +- patch_acme.py | 26 ++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 functions/cluster_cert.py diff --git a/README.md b/README.md index 7182794..7dff80b 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,26 @@ Requested mutations must fail closed. If an expected NPM source anchor cannot be identified or verification fails, the patcher must exit non-zero rather than start an ungated NPM backend. + +## Cluster-managed certificates + +Optional cluster-managed custom certificate support is enabled with: + + ACME_CLUSTER_CERT=true + +This does not grant ACME authority and does not currently perform renewal. + +When enabled it: + +- adds `meta.cluster_managed` as an explicit boolean certificate property; +- adds `meta.acme_renew` as an explicit boolean certificate property; +- preserves the logical `domain_names` already stored on a custom certificate + when new certificate material is uploaded. + +Cluster-managed certificates remain `provider=other`. + +The serving certificate therefore remains under: + + /data/custom_ssl/npm- + +and is independent from any Certbot lineage under `/etc/letsencrypt`. diff --git a/functions/cluster_cert.py b/functions/cluster_cert.py new file mode 100644 index 0000000..fc676b8 --- /dev/null +++ b/functions/cluster_cert.py @@ -0,0 +1,169 @@ +""" +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}); +''' + + +def apply_certificate_js(source: str) -> str: + if UPLOAD_DOMAIN_FRAGMENT in source: + log("cluster_cert_upload_domains=already-present") + return source + + source = replace_exactly_once( + source, + UPLOAD_DOMAIN_ANCHOR, + UPLOAD_DOMAIN_FRAGMENT, + description="cluster certificate upload domain preservation", + ) + + log("cluster_cert_upload_domains=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", + ) + + log("cluster_cert_upload_domains_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") diff --git a/functions/common.py b/functions/common.py index 6afb058..b1a529f 100644 --- a/functions/common.py +++ b/functions/common.py @@ -32,10 +32,16 @@ class GateConfig: request: bool renew: bool revoke: bool + cluster_cert: bool @property def any_enabled(self) -> bool: - return self.request or self.renew or self.revoke + return ( + self.request + or self.renew + or self.revoke + or self.cluster_cert + ) def log(message: str) -> None: @@ -66,6 +72,7 @@ def load_config() -> GateConfig: request=env_bool("ACME_GATE_REQUEST"), renew=env_bool("ACME_GATE_RENEW"), revoke=env_bool("ACME_GATE_REVOKE"), + cluster_cert=env_bool("ACME_CLUSTER_CERT"), ) diff --git a/patch_acme.py b/patch_acme.py index 90e732a..8c52ea7 100755 --- a/patch_acme.py +++ b/patch_acme.py @@ -23,9 +23,11 @@ from functions import common from functions import request from functions import renew from functions import revoke +from functions import cluster_cert DEFAULT_CERTIFICATE_JS = "/app/internal/certificate.js" +DEFAULT_CERTIFICATE_SCHEMA = "/app/schema/components/certificate-object.json" def main() -> None: @@ -36,15 +38,28 @@ def main() -> None: ) ) + certificate_schema = Path( + os.environ.get( + "NPM_CERTIFICATE_SCHEMA", + DEFAULT_CERTIFICATE_SCHEMA, + ) + ) + config = common.load_config() common.log(f"certificate.js={certificate_js}") + common.log(f"certificate_schema={certificate_schema}") common.log(f"request_gate={config.request}") common.log(f"renew_gate={config.renew}") common.log(f"revoke_gate={config.revoke}") + common.log(f"cluster_cert={config.cluster_cert}") source = common.load_source(certificate_js) + schema_source = None + if config.cluster_cert: + schema_source = common.load_source(certificate_schema) + if config.any_enabled: source = common.apply_authority_helper(source) @@ -57,6 +72,10 @@ def main() -> None: if config.revoke: source = revoke.apply(source) + if config.cluster_cert: + source = cluster_cert.apply_certificate_js(source) + schema_source = cluster_cert.apply_schema(schema_source) + common.verify(source, config) if config.request: @@ -68,8 +87,15 @@ def main() -> None: if config.revoke: revoke.verify(source) + if config.cluster_cert: + cluster_cert.verify_certificate_js(source) + cluster_cert.verify_schema(schema_source) + common.write_source(certificate_js, source) + if config.cluster_cert: + common.write_source(certificate_schema, schema_source) + common.log("All requested ACME authority gates applied successfully")