summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorChristian Breunig <christian@breunig.cc>2026-08-09 18:13:46 +0000
committerChristian Breunig <christian@breunig.cc>2026-08-10 21:04:07 +0200
commit096665fcc20a2a4952846f3514ae54fc101218ed (patch)
tree74f954baaee9cd5c9ee6748e56aa9b461d52b3ed /python
parente0d7cd46336f104869fb73001f010a59f2b70b04 (diff)
downloadvyos-1x-096665fcc20a2a4952846f3514ae54fc101218ed.tar.gz
vyos-1x-096665fcc20a2a4952846f3514ae54fc101218ed.zip
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.
Diffstat (limited to 'python')
-rw-r--r--python/vyos/config.py30
-rw-r--r--python/vyos/pki.py49
-rw-r--r--python/vyos/utils/file.py9
3 files changed, 85 insertions, 3 deletions
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/<cert_name>/ - 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_<cert_name>
+# 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 <name>'-shaped dict entry
+ (`{'certificate': <base64>}`) 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_<cert_name> 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