From 096665fcc20a2a4952846f3514ae54fc101218ed Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 9 Aug 2026 18:13:46 +0000 Subject: pki: T9135: derive ACME certificate chains from disk An ACME-issued certificate's intermediate CA was previously imported into the running configuration as a synthetic object, purely so consumers building a full certificate chain (HAProxy, HTTPS, IPsec, stunnel, EAPOL, ...) could find it. This leaked certbot's internal state into the CLI as a real, deletable object that never needed to exist there: the intermediate is available on disk the moment the certificate is issued, same as the leaf certificate and its key. Read it live from disk instead, purely in memory, wherever a full chain is resolved or displayed - never as a settable or deletable configuration object. An already-configured CA that completes the chain on its own takes precedence and nothing synthetic is added. Adding, changing, or removing a CA now reloads only the services whose resolved chain is actually affected, with no side effect on certificates whose own content did not change. --- python/vyos/config.py | 30 +++++++++++++++++++++++++++++ python/vyos/pki.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++ python/vyos/utils/file.py | 9 ++++++--- 3 files changed, 85 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/config.py b/python/vyos/config.py index 7c9771ae3..9d7801758 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -359,10 +359,40 @@ class Config(object): get_first_key=True) if pki_dict: if 'certificate' in pki_dict: + from vyos.defaults import directories + from vyos.pki import AUTOCHAIN_PREFIX + from vyos.pki import acme_chain_ca_entry + from vyos.pki import acme_chain_redundant + vyos_certbot_dir = directories['certbot'] + # Snapshot of explicitly/manually configured CAs, taken + # before any synthetic entries are added below + real_ca_certs = dict(pki_dict.get('ca', {})) + for certificate in pki_dict['certificate']: pki_dict['certificate'][certificate] = config_dict_mangle_acme( certificate, pki_dict['certificate'][certificate]) + # If this is an ACME certificate, also make its + # intermediate CA chain (read live from certbot's + # own chain.pem, never persisted to the CLI) + # available under pki.ca, the same way consumers + # already look up any manually-configured CA, so + # find_chain() can build the full chain for them - + # unless an explicit, manually-configured CA + # already completes the chain, making this + # redundant. + cert_conf = pki_dict['certificate'][certificate] + if 'acme' in cert_conf and not acme_chain_redundant( + cert_conf['certificate'], real_ca_certs): + chain_entry = acme_chain_ca_entry(vyos_certbot_dir, certificate) + if chain_entry: + ca_dict = pki_dict.setdefault('ca', {}) + autochain_name = f'{AUTOCHAIN_PREFIX}{certificate}' + # A real, manually-configured CLI CA object + # with this name wins over the synthetic one + if autochain_name not in ca_dict: + ca_dict[autochain_name] = chain_entry + conf_dict['pki'] = pki_dict interfaces_root = root_dict.get('interfaces', {}) diff --git a/python/vyos/pki.py b/python/vyos/pki.py index 17fa97223..787b3fdbf 100644 --- a/python/vyos/pki.py +++ b/python/vyos/pki.py @@ -470,6 +470,55 @@ def find_chain(cert, ca_certs): return chain +# ACME certificates are managed entirely by certbot under +# {vyos_certbot_dir}/live// - the intermediate CA certbot +# obtained alongside the leaf cert (chain.pem) is derived data, not +# configuration, so it is never written to the CLI. AUTOCHAIN_ +# is only ever used as an in-memory dict key (see +# Config.get_config_dict()'s with_pki handling and +# src/op_mode/pki.py's get_config_ca_certificate()) so consumers that +# build a full certificate chain via find_chain() - and "show pki ca" - +# see the same data they would if it had been manually configured, +# without it ever being a real, settable, or deletable CLI object. +AUTOCHAIN_PREFIX = 'AUTOCHAIN_' + +def acme_chain_ca_entry(vyos_certbot_dir: str, cert_name: str) -> dict | None: + """Build a synthetic 'pki ca '-shaped dict entry + (`{'certificate': }`) for an ACME certificate's intermediate + CA chain, read live from certbot's own chain.pem. + + Returns None if chain.pem is not (yet) present - e.g. the certificate + has not been issued yet, or a prior request failed - so callers can + skip this certificate's chain gracefully instead of crashing. + """ + from vyos.utils.file import read_file + tmp = read_file(f'{vyos_certbot_dir}/live/{cert_name}/chain.pem', + defaultonfailure=None) + if tmp is None: + return None + tmp = load_certificate(tmp, wrap_tags=False) + if not tmp: + return None + chain_base64 = "".join(encode_certificate(tmp).strip().split("\n")[1:-1]) + return {'certificate': chain_base64} + +def acme_chain_redundant(leaf_cert_base64: str, ca_certs: dict) -> bool: + """Return True if one of the already-configured CAs in ca_certs (a + 'pki ca'-shaped dict, e.g. Config's pki_dict['ca']) directly signs the + given leaf certificate - meaning an explicit, manually-configured CA + already completes the chain, so synthesizing/showing an + AUTOCHAIN_ entry (see acme_chain_ca_entry()) is redundant. + """ + leaf_cert = load_certificate(leaf_cert_base64) + if not leaf_cert: + return False + loaded_ca_certs = [ + load_certificate(ca['certificate']) + for ca in ca_certs.values() if 'certificate' in ca + ] + loaded_ca_certs = [cert for cert in loaded_ca_certs if cert] + return find_parent(leaf_cert, loaded_ca_certs) is not None + def sort_ca_chain(ca_names, pki_node): def ca_cmp(ca_name1, ca_name2, pki_node): cert1 = load_certificate(pki_node[ca_name1]['certificate']) diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index 08a3fa82d..5c2eae4d5 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -31,10 +31,13 @@ def file_is_persistent(path): absolute = os.path.abspath(os.path.dirname(path)) return re.match(location,absolute) -def read_file(fname, defaultonfailure=None, sudo=False): +_unset = object() + +def read_file(fname, defaultonfailure=_unset, sudo=False): """ read the content of a file, stripping any end characters (space, newlines) - should defaultonfailure be not None, it is returned on failure to read + should defaultonfailure be given (including None), it is returned on + failure to read instead of raising """ try: # Some files can only be read by root - emulate sudo cat call @@ -47,7 +50,7 @@ def read_file(fname, defaultonfailure=None, sudo=False): data = f.read() return data.strip() except Exception as e: - if defaultonfailure is not None: + if defaultonfailure is not _unset: return defaultonfailure raise e -- cgit v1.2.3 From 0cfdd6a869772defbd6ca7778273bdab4b85dfe7 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 10 Aug 2026 18:24:39 +0000 Subject: pki: T9135: don't crash on an ACME certificate not yet issued Both the with_pki=True chain injection and "show pki ca" unconditionally read a certificate's own content to check whether an explicit CA already covers its chain. For an ACME certificate with no cert.pem yet (pending its first issuance, or after a failed request), that content is never populated and the lookup raised KeyError - crashing every with_pki=True consumer and "show pki" alike. --- python/vyos/config.py | 5 +++-- src/op_mode/pki.py | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/config.py b/python/vyos/config.py index 9d7801758..e79f659b7 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -382,8 +382,9 @@ class Config(object): # already completes the chain, making this # redundant. cert_conf = pki_dict['certificate'][certificate] - if 'acme' in cert_conf and not acme_chain_redundant( - cert_conf['certificate'], real_ca_certs): + leaf_cert = cert_conf.get('certificate') + if leaf_cert and 'acme' in cert_conf and not acme_chain_redundant( + leaf_cert, real_ca_certs): chain_entry = acme_chain_ca_entry(vyos_certbot_dir, certificate) if chain_entry: ca_dict = pki_dict.setdefault('ca', {}) diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 9b5f9fdcc..91a83ea70 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -162,7 +162,10 @@ def get_config_ca_certificate(name=None): for cert_name, cert_conf in (get_config_certificate() or {}).items(): if 'acme' not in cert_conf: continue - if acme_chain_redundant(cert_conf['certificate'], real_ca_certs): + leaf_cert = cert_conf.get('certificate') + if not leaf_cert: + continue + if acme_chain_redundant(leaf_cert, real_ca_certs): continue chain_entry = acme_chain_ca_entry(vyos_certbot_dir, cert_name) if chain_entry: -- cgit v1.2.3 From 5faebdb613ae83947f0428951f6e68d6b66da7db Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 10 Aug 2026 18:32:47 +0000 Subject: pki: T9135: preserve every certificate in an ACME chain, not just the first certbot's chain.pem commonly holds more than one certificate - e.g. the immediate intermediate plus its own issuing root - but the synthetic CA entry built from it only ever kept the first, silently dropping the rest before find_chain() ever saw them. This left a shorter chain than certbot itself actually has, e.g. requiring a root to also be configured manually to reach the same result certbot's own data already provides. Parse every certificate block in chain.pem and emit one synthetic entry per certificate, numbering entries after the first so each is still its own addressable, non-redundant, non-settable object exactly like before. --- python/vyos/config.py | 8 +++----- python/vyos/pki.py | 45 +++++++++++++++++++++++++++++++-------------- src/op_mode/pki.py | 6 ++---- 3 files changed, 36 insertions(+), 23 deletions(-) (limited to 'python') diff --git a/python/vyos/config.py b/python/vyos/config.py index e79f659b7..113136901 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -360,7 +360,6 @@ class Config(object): if pki_dict: if 'certificate' in pki_dict: from vyos.defaults import directories - from vyos.pki import AUTOCHAIN_PREFIX from vyos.pki import acme_chain_ca_entry from vyos.pki import acme_chain_redundant vyos_certbot_dir = directories['certbot'] @@ -385,10 +384,9 @@ class Config(object): leaf_cert = cert_conf.get('certificate') if leaf_cert and 'acme' in cert_conf and not acme_chain_redundant( leaf_cert, real_ca_certs): - chain_entry = acme_chain_ca_entry(vyos_certbot_dir, certificate) - if chain_entry: - ca_dict = pki_dict.setdefault('ca', {}) - autochain_name = f'{AUTOCHAIN_PREFIX}{certificate}' + ca_dict = pki_dict.setdefault('ca', {}) + for autochain_name, chain_entry in acme_chain_ca_entry( + vyos_certbot_dir, certificate).items(): # A real, manually-configured CLI CA object # with this name wins over the synthetic one if autochain_name not in ca_dict: diff --git a/python/vyos/pki.py b/python/vyos/pki.py index 787b3fdbf..af0dee626 100644 --- a/python/vyos/pki.py +++ b/python/vyos/pki.py @@ -482,25 +482,42 @@ def find_chain(cert, ca_certs): # without it ever being a real, settable, or deletable CLI object. AUTOCHAIN_PREFIX = 'AUTOCHAIN_' -def acme_chain_ca_entry(vyos_certbot_dir: str, cert_name: str) -> dict | None: - """Build a synthetic 'pki ca '-shaped dict entry - (`{'certificate': }`) for an ACME certificate's intermediate - CA chain, read live from certbot's own chain.pem. - - Returns None if chain.pem is not (yet) present - e.g. the certificate - has not been issued yet, or a prior request failed - so callers can - skip this certificate's chain gracefully instead of crashing. +def acme_chain_ca_entry(vyos_certbot_dir: str, cert_name: str) -> dict: + """Build synthetic 'pki ca '-shaped dict entries + (`{: {'certificate': }, ...}`) for every certificate in + an ACME certificate's chain, read live from certbot's own chain.pem. + + certbot commonly writes more than just the immediate intermediate to + chain.pem (e.g. the intermediate's own issuing root too), and every + one of them is needed for find_chain() to walk the full chain - a + single 'pki ca' object only ever holds one certificate, so each one + gets its own synthetic entry here. + + Returns an empty dict if chain.pem is not (yet) present - e.g. the + certificate has not been issued yet, or a prior request failed - so + callers can skip this certificate's chain gracefully instead of + crashing. """ + import re from vyos.utils.file import read_file tmp = read_file(f'{vyos_certbot_dir}/live/{cert_name}/chain.pem', defaultonfailure=None) if tmp is None: - return None - tmp = load_certificate(tmp, wrap_tags=False) - if not tmp: - return None - chain_base64 = "".join(encode_certificate(tmp).strip().split("\n")[1:-1]) - return {'certificate': chain_base64} + return {} + + entries = {} + for block in re.findall( + r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', + tmp, re.DOTALL): + cert = load_certificate(block, wrap_tags=False) + if not cert: + continue + index = len(entries) + 1 + name = f'{AUTOCHAIN_PREFIX}{cert_name}' if index == 1 else \ + f'{AUTOCHAIN_PREFIX}{cert_name}_{index}' + chain_base64 = "".join(encode_certificate(cert).strip().split("\n")[1:-1]) + entries[name] = {'certificate': chain_base64} + return entries def acme_chain_redundant(leaf_cert_base64: str, ca_certs: dict) -> bool: """Return True if one of the already-configured CAs in ca_certs (a diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 91a83ea70..78b896edb 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -151,7 +151,6 @@ def get_config_ca_certificate(name=None): # object, but consumers (find_chain(), "show pki ca") should see it # the same way they'd see a manually-configured CA. from vyos.defaults import directories - from vyos.pki import AUTOCHAIN_PREFIX from vyos.pki import acme_chain_ca_entry from vyos.pki import acme_chain_redundant vyos_certbot_dir = directories['certbot'] @@ -167,9 +166,8 @@ def get_config_ca_certificate(name=None): continue if acme_chain_redundant(leaf_cert, real_ca_certs): continue - chain_entry = acme_chain_ca_entry(vyos_certbot_dir, cert_name) - if chain_entry: - autochain_name = f'{AUTOCHAIN_PREFIX}{cert_name}' + for autochain_name, chain_entry in acme_chain_ca_entry( + vyos_certbot_dir, cert_name).items(): # A real, manually-configured CLI CA object with this name # wins over the synthetic one if autochain_name not in ca_certs: -- cgit v1.2.3