Add cluster-managed certificate metadata support
This commit is contained in:
@@ -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-<id>
|
||||
|
||||
and is independent from any Certbot lineage under `/etc/letsencrypt`.
|
||||
|
||||
@@ -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")
|
||||
+8
-1
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user