From 1db665fa1198a33a4d15fe107430bc8b2e8b5785 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 18 Mar 2025 09:34:34 -0500 Subject: [PATCH 01/16] feat: starttls --- salt/bugs/config/postfix/main.cf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/salt/bugs/config/postfix/main.cf b/salt/bugs/config/postfix/main.cf index c46aa400..fc67312a 100644 --- a/salt/bugs/config/postfix/main.cf +++ b/salt/bugs/config/postfix/main.cf @@ -24,8 +24,8 @@ compatibility_level = 3.6 # TLS parameters -smtpd_tls_cert_file=ssl_certificate /etc/ssl/private/bugs.psf.io.pem; -smtpd_tls_key_file=etc/ssl/private/bugs.psf.io.pem; +smtpd_tls_cert_file=/etc/ssl/private/bugs.psf.io.pem +smtpd_tls_key_file=/etc/ssl/private/bugs.psf.io.pem smtpd_tls_security_level=may smtp_tls_CApath=/etc/ssl/certs From e6555308bcfbf9339a13548bd50b4060e422a39e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 1 Apr 2025 10:33:26 -0500 Subject: [PATCH 02/16] (broken) auto lets encrypt via acme state --- pillar/dev/top.sls | 1 + salt/tls/init.sls | 104 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/pillar/dev/top.sls b/pillar/dev/top.sls index de9057ec..d0479a83 100644 --- a/pillar/dev/top.sls +++ b/pillar/dev/top.sls @@ -8,6 +8,7 @@ base: - tls - users.* - postgres.clusters + - pebble # needing to do this to have pebble rum in dev 'backup-server': - match: nodegroup diff --git a/salt/tls/init.sls b/salt/tls/init.sls index eee13192..85d3ad9c 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -1,6 +1,12 @@ +include: + - .pebble + - .lego + ssl-cert: pkg.installed +certbot: + pkg.installed {% for name in salt["pillar.get"]("tls:ca", {}) %} # " Syntax Hack /etc/ssl/certs/{{ name }}.pem: @@ -25,3 +31,101 @@ ssl-cert: - require: - pkg: ssl-cert {% endfor %} + +# initial test +{% if grains['id'] == 'salt.nyc1.psf.io' or grains['id'] == 'salt-master.vagrant.psf.io' %} +pypa.io: + acme.cert: + - email: infrastructure-staff@python.org + - webroot: /etc/lego + - renew: 14 + {% if pillar["dc"] == "vagrant" %} + - server: https://salt-master.vapsf.io:14000/dir + {% endif %} + - require: + - sls: tls.lego + - file: /etc/lego/.well-known/acme-challenge + +# DNS-validated domains +# dns plugins do not exist yet for route53 & gandi +{#star.python.org:#} +{# acme.cert:#} +{# - aliases:#} +{# - python.org#} +{# - email: infrastructure-staff@python.org#} +{## - dns_plugin: route53#} +{## - dns_plugin_credentials: route53.python#} +{# - renew: 14#} +{# - server: https://localhost:14000/dir#} +{# - require:#} +{# - pkg: certbot#} +{# +- sls: tls.lego#} +{#star.pycon.org:#} +{# acme.cert:#} +{# - aliases:#} +{# - pycon.org#} +{# - email: infrastructure-staff@python.org#} +{## - dns_plugin: route53#} +{## - dns_plugin_credentials: route53.pycon#} +{# - renew: 14#} +{# - server: https://localhost:14000/dir#} +{# - require:#} +{# - sls: tls.lego#} + +{#star.pyfound.org:#} +{# acme.cert:#} +{# - aliases:#} +{# - pyfound.org#} +{# - email: infrastructure-staff@python.org#} +{## - dns_plugin: gandiv5#} +{## - dns_plugin_credentials: gandi#} +{# - renew: 14#} +{# - require:#} +{# - sls: tls.lego#} + +# HTTP-validated domains +{#{% for domain in [#} +{# 'pypa.io',#} +{# 'www.pycon.org',#} +{# 'speed.pypy.org',#} +{# 'salt-public.psf.io',#} +{# 'planetpython.org',#} +{# 'bugs.python.org'#} +{#] %}#} +{#{{ domain }}:#} +{# acme.cert:#} +{# - email: infrastructure-staff@python.org#} +{# - webroot: /etc/lego#} +{# - renew: 14#} +{# - require:#} +{# - sls: tls.lego#} +{#{% endfor %}#} + +# Multi-domain certificates +{#jython.org:#} +{# acme.cert:#} +{# - aliases:#} +{# - www.jython.net#} +{# - jython.net#} +{# - www.jython.com#} +{# - jython.com#} +{# - email: infrastructure-staff@python.org#} +{# - webroot: /etc/lego#} +{# - renew: 14#} +{# - require:#} +{# - sls: tls.lego#} + +{#bugs.python.org-multi:#} +{# acme.cert:#} +{# - name: bugs.python.org#} +{# - aliases:#} +{# - bugs.jython.org#} +{# - issues.roundup-tracker.org#} +{# - mail.roundup-tracker.org#} +{# - email: infrastructure-staff@python.org#} +{# - webroot: /etc/lego#} +{# - renew: 14#} +{# - require:#} +{# - sls: tls.lego#} +{% endif %} \ No newline at end of file From 65dd645a2e73420a678e7698f85dde99f5b114b8 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 1 Apr 2025 10:36:18 -0500 Subject: [PATCH 03/16] (broken) correct commited result --- salt/tls/init.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/tls/init.sls b/salt/tls/init.sls index 85d3ad9c..6402cb37 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -40,7 +40,7 @@ pypa.io: - webroot: /etc/lego - renew: 14 {% if pillar["dc"] == "vagrant" %} - - server: https://salt-master.vapsf.io:14000/dir + - server: https://salt-master.psf.io:14000/dir {% endif %} - require: - sls: tls.lego From 0dbf3ca50a8114f1590f73bd551a16c48c04e3aa Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 2 Apr 2025 15:06:18 -0500 Subject: [PATCH 04/16] correct commited result again --- salt/tls/init.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/tls/init.sls b/salt/tls/init.sls index 6402cb37..0eb9846e 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -40,7 +40,7 @@ pypa.io: - webroot: /etc/lego - renew: 14 {% if pillar["dc"] == "vagrant" %} - - server: https://salt-master.psf.io:14000/dir + - server: https://salt-master.vagrant.psf.io:14000/dir {% endif %} - require: - sls: tls.lego From 0aefff26ea07e16abfa97cb90237eb862e6487cd Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Mon, 14 Apr 2025 05:19:02 -0400 Subject: [PATCH 05/16] properly install PSF_CA certificate so certbot can use it --- salt/tls/init.sls | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/salt/tls/init.sls b/salt/tls/init.sls index 0eb9846e..d7e1ab8d 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -17,8 +17,21 @@ certbot: - mode: "0644" - require: - pkg: ssl-cert + +/usr/local/share/ca-certificates/{{ name }}.crt: + file.managed: + - contents_pillar: tls:ca:{{ name }} + - user: root + - group: ssl-cert + - mode: "0644" + - require: + - pkg: ssl-cert {% endfor %} +/usr/sbin/update-ca-certificates: + cmd.wait: + - watch: + - file: /usr/local/share/ca-certificates/*.crt {% for name in salt["pillar.get"]("tls:certs", {}) %} # " Syntax Hack /etc/ssl/private/{{ name }}.pem: From 10a500661d280d12be94da1bfca7da36bf888069 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 15 Apr 2025 10:18:35 -0500 Subject: [PATCH 06/16] uncomment working parts --- salt/tls/init.sls | 51 +++++++++++++++++++---------------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/salt/tls/init.sls b/salt/tls/init.sls index d7e1ab8d..01044ab0 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -45,19 +45,7 @@ certbot: - pkg: ssl-cert {% endfor %} -# initial test {% if grains['id'] == 'salt.nyc1.psf.io' or grains['id'] == 'salt-master.vagrant.psf.io' %} -pypa.io: - acme.cert: - - email: infrastructure-staff@python.org - - webroot: /etc/lego - - renew: 14 - {% if pillar["dc"] == "vagrant" %} - - server: https://salt-master.vagrant.psf.io:14000/dir - {% endif %} - - require: - - sls: tls.lego - - file: /etc/lego/.well-known/acme-challenge # DNS-validated domains # dns plugins do not exist yet for route53 & gandi @@ -73,7 +61,7 @@ pypa.io: {# - require:#} {# - pkg: certbot#} {# -- sls: tls.lego#} +- sls: tls.lego {#star.pycon.org:#} {# acme.cert:#} {# - aliases:#} @@ -98,22 +86,25 @@ pypa.io: {# - sls: tls.lego#} # HTTP-validated domains -{#{% for domain in [#} -{# 'pypa.io',#} -{# 'www.pycon.org',#} -{# 'speed.pypy.org',#} -{# 'salt-public.psf.io',#} -{# 'planetpython.org',#} -{# 'bugs.python.org'#} -{#] %}#} -{#{{ domain }}:#} -{# acme.cert:#} -{# - email: infrastructure-staff@python.org#} -{# - webroot: /etc/lego#} -{# - renew: 14#} -{# - require:#} -{# - sls: tls.lego#} -{#{% endfor %}#} +{% for domain in [ + 'pypa.io', + 'www.pycon.org', + 'speed.pypy.org', + 'salt-public.psf.io', + 'planetpython.org', + 'bugs.python.org' +] %} +{{ domain }}: + acme.cert: + - email: infrastructure-staff@python.org + - webroot: /etc/lego + - renew: 14 + {% if pillar["dc"] == "vagrant" %} + - server: https://salt-master.vagrant.psf.io:14000/dir + {% endif %} + - require: + - sls: tls.lego +{% endfor %} # Multi-domain certificates {#jython.org:#} @@ -128,7 +119,7 @@ pypa.io: {# - renew: 14#} {# - require:#} {# - sls: tls.lego#} - +{##} {#bugs.python.org-multi:#} {# acme.cert:#} {# - name: bugs.python.org#} From 98dc839e82e8bee905fa83a95c106f0b9defb991 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 21 Apr 2025 13:58:04 -0500 Subject: [PATCH 07/16] stuff acme certs into appropriate pillar data bugs->bugs cert $host->$host cert loadbalancer->all certs --- salt/_extensions/pillar/ca.py | 228 ++++++++++++++++++++++++++++------ salt/tls/init.sls | 43 ++++--- salt/tls/pebble.sls | 2 + 3 files changed, 216 insertions(+), 57 deletions(-) diff --git a/salt/_extensions/pillar/ca.py b/salt/_extensions/pillar/ca.py index 13977d4f..b162e1fa 100644 --- a/salt/_extensions/pillar/ca.py +++ b/salt/_extensions/pillar/ca.py @@ -3,6 +3,7 @@ import binascii import datetime import os.path +from pathlib import Path import salt.loader @@ -295,46 +296,203 @@ def get_ca_signed_cert(cacert_path, ca_name, CN): return "\n".join([cert, key]) -def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): - if cert_opts is None: - cert_opts = {} +def _find_acme_certs(base_path="/etc/letsencrypt/live"): + """Read ACME certificates from /etc/letsencrypt/live + + returns dict with domain name (key) and data (value for each cert. + """ + acme_certs = {} + try: + if not Path(base_path).exists(): + print(f"ACME base path {base_path} does not exist") + return acme_certs + + print(f"Scanning for certificates in {base_path}") + for domain_dir in Path(base_path).iterdir(): + try: + domain_dir_path = Path(base_path) / domain_dir + if not domain_dir_path.is_dir() or domain_dir.name == "README": + continue + + domain_name = domain_dir.name + print(f"Found certificate directory: {domain_name}") + + # use fullchain.pem instead of just cert.pem to include the full certificate chain + cert_file = domain_dir_path / "fullchain.pem" + key_file = domain_dir_path / "privkey.pem" + + if not cert_file.exists(): + print(f"Certificate file not found: {cert_file}") + continue + + if not key_file.exists(): + print(f"Key file not found: {key_file}") + continue + + with cert_file.open('r') as f_cert: + cert_data = f_cert.read() + + with key_file.open('r') as f_key: + key_data = f_key.read() + + # Store combined certificate and key + combined_data = "\n".join([cert_data, key_data]) + acme_certs[domain_name] = combined_data + # print(f"read certificate for {domain_name}") + + except Exception as e: + print(f"Error processing certificate for {domain_dir.name}: {e}") + + except Exception as e: + print(f"Error scanning ACME certificates directory: {e}") + + print(f"Found {len(acme_certs)} ACME certificates") + return acme_certs + + +def _process_ca_certificates(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): + ca_data = { + "ca": {}, + "certs": {}, + } + + try: + if cert_opts is None: + cert_opts = {} + + # Create CA certificate + opts = cert_opts.copy() + opts["CN"] = name + create_ca(base, name, **opts) + + ca_data["ca"][name] = get_ca_cert(base, name) + + # Process CA-signed certificates (gen_certs) + gen_certs = pillar.get("tls", {}).get("gen_certs", {}) + for certificate, config in gen_certs.items(): + role_patterns = [ + role.get("pattern") + for role in [ + pillar.get("roles", {}).get(r) for r in config.get("roles", "") + ] + if role and role.get("pattern") is not None + ] + + if any(compound(pat, minion_id) for pat in role_patterns): + # Create the options + opts = cert_opts.copy() + opts["CN"] = certificate + opts["days"] = config.get("days", 1) + + create_ca_signed_cert(base, name, **opts) + + # Add the signed certificates to the pillar data + cert_data = get_ca_signed_cert(base, name, certificate) + ca_data["certs"][certificate] = cert_data + except Exception as e: + print(f"Error processing CA certificates: {e}") + + return ca_data - # Ensure we have a CA created. - opts = cert_opts.copy() - opts["CN"] = name - create_ca(base, name, **opts) - # Start our pillar with just the ca certificate. +def _process_acme_certificates(minion_id, pillar): + """Process ACME certificates + + Reads ACME certificates and determines which ones should be available + to the specified minion based on access rules. + """ + acme_certs = {} + + try: + print(f"Processing ACME certificates for minion: {minion_id}") + all_acme_certs = _find_acme_certs() + + # Check if this is a loadbalancer (gets all certs) + # todo: clean up all but the one that works + is_loadbalancer = False + try: + if 'loadbalancer' in minion_id.lower(): + is_loadbalancer = True + print(f"Minion {minion_id} identified as loadbalancer by name") + + # Also check via roles grain if that doesn't work + elif compound('G@roles:loadbalancer', minion_id): + is_loadbalancer = True + print(f"Minion {minion_id} identified as loadbalancer by grain") + + # Additional check - look for the loadbalancer role in the hostname + elif (minion_id.startswith('lb.') or minion_id.startswith('loadbalancer.')): + is_loadbalancer = True + print(f"Minion {minion_id} identified as loadbalancer by hostname pattern") + + if is_loadbalancer: + print(f"Minion {minion_id} is a loadbalancer, providing all certificates") + except Exception as e: + print(f"Error checking loadbalancer role: {e}") + + # Process each certificate + for domain_name, cert_data in all_acme_certs.items(): + should_include = False + + # Loadbalancer gets all certs + if is_loadbalancer: + should_include = True + reason = "loadbalancer role" + + # Minion name matches domain name + if minion_id.startswith(domain_name.split('.')[0]): + should_include = True + reason = "name match" + + # Add certificate if allowed + if should_include: + acme_certs[domain_name] = cert_data + print(f"Added ACME certificate {domain_name} to pillar data (reason: {reason})") + else: + print(f"Skipping certificate {domain_name} for minion {minion_id} (no access)") + + except Exception as e: + print(f"Error processing ACME certificates: {e}") + + return acme_certs + + +def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): + """Pillar extension to provide TLS certificates from internal PSFCA and acme.cert generated certs""" + print(f"Processing pillar data for minion: {minion_id}") + + # initial data structure for certs data = { "tls": { - "ca": { - name: get_ca_cert(base, name), - }, + "ca": {}, "certs": {}, + "certs_acme": {}, }, } - - # Create all of the certificates required by this minion - gen_certs = pillar.get("tls", {}).get("gen_certs", {}) - for certificate, config in gen_certs.items(): - role_patterns = [ - role.get("pattern") - for role in [ - pillar.get("roles", {}).get(r) for r in config.get("roles", "") - ] - if role and role.get("pattern") is not None - ] - if any([compound(pat, minion_id) for pat in role_patterns]): - # Create the options - opts = cert_opts.copy() - opts["CN"] = certificate - opts["days"] = config.get("days", 1) - - # Create the signed certificates - create_ca_signed_cert(base, name, **opts) - - # Add the signed certificates to the pillar data - cert_data = get_ca_signed_cert(base, name, certificate) - data["tls"]["certs"][certificate] = cert_data - + + # Process CA certificates and CA-signed certificates + ca_data = _process_ca_certificates(minion_id, pillar, base, name, cert_opts) + data["tls"]["ca"] = ca_data["ca"] + for cert_name, cert_data in ca_data["certs"].items(): + data["tls"]["certs"][cert_name] = cert_data + + # process ACME certificates + acme_certs = _process_acme_certificates(minion_id, pillar) + + # Add ACME certificates to both certs and certs_acme sections + for cert_name, cert_data in acme_certs.items(): + # Store in certs_acme section (dedicated for ACME certificates) + data["tls"]["certs_acme"][cert_name] = cert_data + + # Also store in general certs section for backward compatibility + # Only if not already present from CA-signed certs + if cert_name not in data["tls"]["certs"]: + data["tls"]["certs"][cert_name] = cert_data + + # Check if we have ACME certificates for debugging + if not acme_certs: + print(f"No ACME certificates were included for minion: {minion_id}") + else: + print(f"Included {len(acme_certs)} ACME certificates for minion: {minion_id}") + return data diff --git a/salt/tls/init.sls b/salt/tls/init.sls index 01044ab0..dcc797d5 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -45,7 +45,27 @@ certbot: - pkg: ssl-cert {% endfor %} -{% if grains['id'] == 'salt.nyc1.psf.io' or grains['id'] == 'salt-master.vagrant.psf.io' %} +{% if salt["match.compound"](pillar["roles"]["salt-master"]["pattern"]) %} +# HTTP-validated domains +{% for domain in [ + 'pypa.io', + 'www.pycon.org', + 'speed.pypy.org', + 'salt-public.psf.io', + 'planetpython.org', + 'bugs.python.org' +] %} +{{ domain }}: + acme.cert: + - email: infrastructure-staff@python.org + - webroot: /etc/lego + - renew: 14 + {% if pillar["dc"] == "vagrant" %} + - server: https://salt-master.vagrant.psf.io:14000/dir + {% endif %} + - require: + - sls: tls.lego +{% endfor %} # DNS-validated domains # dns plugins do not exist yet for route53 & gandi @@ -85,27 +105,6 @@ certbot: {# - require:#} {# - sls: tls.lego#} -# HTTP-validated domains -{% for domain in [ - 'pypa.io', - 'www.pycon.org', - 'speed.pypy.org', - 'salt-public.psf.io', - 'planetpython.org', - 'bugs.python.org' -] %} -{{ domain }}: - acme.cert: - - email: infrastructure-staff@python.org - - webroot: /etc/lego - - renew: 14 - {% if pillar["dc"] == "vagrant" %} - - server: https://salt-master.vagrant.psf.io:14000/dir - {% endif %} - - require: - - sls: tls.lego -{% endfor %} - # Multi-domain certificates {#jython.org:#} {# acme.cert:#} diff --git a/salt/tls/pebble.sls b/salt/tls/pebble.sls index ec6e991a..33fb87f7 100644 --- a/salt/tls/pebble.sls +++ b/salt/tls/pebble.sls @@ -1,3 +1,4 @@ +{% if salt["match.compound"](pillar["roles"]["salt-master"]["pattern"]) %} {% if pillar.get('pebble', {'enabled': False}).enabled %} pebble-build-deps: pkg.installed: @@ -60,3 +61,4 @@ pebble-service: - file: /etc/ssl/certs/PSF_CA.pem - file: /etc/ssl/private/salt-master.vagrant.psf.io.pem {% endif %} +{% endif %} From ca751e450bcda638d20886ae04dbfbe57cc69491 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 23 Apr 2025 11:18:48 -0500 Subject: [PATCH 08/16] feat: move acme stuff into pillar data --- pillar/base/tls.sls | 74 ++++++++++++++++++++++++++++++++++++++ salt/tls/init.sls | 88 ++++++++------------------------------------- 2 files changed, 88 insertions(+), 74 deletions(-) diff --git a/pillar/base/tls.sls b/pillar/base/tls.sls index bc6b5aee..b2b34ada 100644 --- a/pillar/base/tls.sls +++ b/pillar/base/tls.sls @@ -61,3 +61,77 @@ tls: svn.psf.io: roles: - hg + + acme_certs: + bugs.python.org: + validation: http + roles: +{# - bugs#} + - loadbalancer + pycon.org: + validation: http + roles: + - loadbalancer + aliases: + - www.pycon.org + speed.pypy.org: + validation: http + roles: + - loadbalancer + salt-public.psf.io: + validation: http + roles: + - loadbalancer + planetpython.org: + validation: http + roles: + - loadbalancer + aliases: + - www.planetpython.org + - planet.python.org + pypa.io: + validation: http + roles: + - loadbalancer + aliases: + - www.pypa.io + jython.org: + validation: http + roles: + - loadbalancer + aliases: + - www.jython.net + - jython.net + - www.jython.com + - jython.com + bugs.python.org-multi: + validation: http + name: bugs.python.org + roles: + - loadbalancer + aliases: + - bugs.jython.org + - issues.roundup-tracker.org + - mail.roundup-tracker.org +{# star.python.org:#} +{# validation: dns#} +{# dns_plugin: route53#} +{# dns_plugin_credentials: route53.python#} +{# roles:#} +{# - loadbalancer#} +{# star.pycon.org:#} +{# validation: dns#} +{# dns_plugin: route53#} +{# dns_plugin_credentials: route53.pycon#} +{# roles:#} +{# - loadbalancer#} +{# aliases:#} +{# - pycon.org#} +{# star.pyfound.org:#} +{# validation: dns#} +{# dns_plugin: gandiv5#} +{# dns_plugin_credentials: gandi#} +{# roles:#} +{# - loadbalancer#} +{# aliases:#} +{# - pyfound.org#} diff --git a/salt/tls/init.sls b/salt/tls/init.sls index dcc797d5..83bd8507 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -46,89 +46,29 @@ certbot: {% endfor %} {% if salt["match.compound"](pillar["roles"]["salt-master"]["pattern"]) %} -# HTTP-validated domains -{% for domain in [ - 'pypa.io', - 'www.pycon.org', - 'speed.pypy.org', - 'salt-public.psf.io', - 'planetpython.org', - 'bugs.python.org' -] %} +# Process ACME certificates +{% for domain, domain_config in salt["pillar.get"]("tls:acme_certs", {}).items() %} {{ domain }}: acme.cert: - email: infrastructure-staff@python.org - webroot: /etc/lego - renew: 14 + {% if domain_config.get('aliases') %} + - aliases: + {% for alias in domain_config.get('aliases', []) %} + - {{ alias }} + {% endfor %} + {% endif %} {% if pillar["dc"] == "vagrant" %} - server: https://salt-master.vagrant.psf.io:14000/dir {% endif %} + {% if domain_config.get('validation') == "dns" %} + - dns_plugin: {{ domain_config.get('dns_plugin') }} + - dns_plugin_credentials: {{ domain_config.get('dns_plugin_credentials') }} + {% else %} - require: - sls: tls.lego + - pkg: certbot + {% endif %} {% endfor %} - -# DNS-validated domains -# dns plugins do not exist yet for route53 & gandi -{#star.python.org:#} -{# acme.cert:#} -{# - aliases:#} -{# - python.org#} -{# - email: infrastructure-staff@python.org#} -{## - dns_plugin: route53#} -{## - dns_plugin_credentials: route53.python#} -{# - renew: 14#} -{# - server: https://localhost:14000/dir#} -{# - require:#} -{# - pkg: certbot#} -{# -- sls: tls.lego -{#star.pycon.org:#} -{# acme.cert:#} -{# - aliases:#} -{# - pycon.org#} -{# - email: infrastructure-staff@python.org#} -{## - dns_plugin: route53#} -{## - dns_plugin_credentials: route53.pycon#} -{# - renew: 14#} -{# - server: https://localhost:14000/dir#} -{# - require:#} -{# - sls: tls.lego#} - -{#star.pyfound.org:#} -{# acme.cert:#} -{# - aliases:#} -{# - pyfound.org#} -{# - email: infrastructure-staff@python.org#} -{## - dns_plugin: gandiv5#} -{## - dns_plugin_credentials: gandi#} -{# - renew: 14#} -{# - require:#} -{# - sls: tls.lego#} - -# Multi-domain certificates -{#jython.org:#} -{# acme.cert:#} -{# - aliases:#} -{# - www.jython.net#} -{# - jython.net#} -{# - www.jython.com#} -{# - jython.com#} -{# - email: infrastructure-staff@python.org#} -{# - webroot: /etc/lego#} -{# - renew: 14#} -{# - require:#} -{# - sls: tls.lego#} -{##} -{#bugs.python.org-multi:#} -{# acme.cert:#} -{# - name: bugs.python.org#} -{# - aliases:#} -{# - bugs.jython.org#} -{# - issues.roundup-tracker.org#} -{# - mail.roundup-tracker.org#} -{# - email: infrastructure-staff@python.org#} -{# - webroot: /etc/lego#} -{# - renew: 14#} -{# - require:#} -{# - sls: tls.lego#} {% endif %} \ No newline at end of file From 50e70e7ccda59cd9b478059b64c9b55ca231556f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 23 Apr 2025 17:08:23 -0500 Subject: [PATCH 09/16] fix: less suck, more good --- salt/_extensions/pillar/ca.py | 237 +++++++--------------------------- 1 file changed, 44 insertions(+), 193 deletions(-) diff --git a/salt/_extensions/pillar/ca.py b/salt/_extensions/pillar/ca.py index b162e1fa..fcaa5d7b 100644 --- a/salt/_extensions/pillar/ca.py +++ b/salt/_extensions/pillar/ca.py @@ -3,7 +3,6 @@ import binascii import datetime import os.path -from pathlib import Path import salt.loader @@ -296,203 +295,55 @@ def get_ca_signed_cert(cacert_path, ca_name, CN): return "\n".join([cert, key]) -def _find_acme_certs(base_path="/etc/letsencrypt/live"): - """Read ACME certificates from /etc/letsencrypt/live - - returns dict with domain name (key) and data (value for each cert. - """ - acme_certs = {} - try: - if not Path(base_path).exists(): - print(f"ACME base path {base_path} does not exist") - return acme_certs - - print(f"Scanning for certificates in {base_path}") - for domain_dir in Path(base_path).iterdir(): - try: - domain_dir_path = Path(base_path) / domain_dir - if not domain_dir_path.is_dir() or domain_dir.name == "README": - continue - - domain_name = domain_dir.name - print(f"Found certificate directory: {domain_name}") - - # use fullchain.pem instead of just cert.pem to include the full certificate chain - cert_file = domain_dir_path / "fullchain.pem" - key_file = domain_dir_path / "privkey.pem" - - if not cert_file.exists(): - print(f"Certificate file not found: {cert_file}") - continue - - if not key_file.exists(): - print(f"Key file not found: {key_file}") - continue - - with cert_file.open('r') as f_cert: - cert_data = f_cert.read() - - with key_file.open('r') as f_key: - key_data = f_key.read() - - # Store combined certificate and key - combined_data = "\n".join([cert_data, key_data]) - acme_certs[domain_name] = combined_data - # print(f"read certificate for {domain_name}") - - except Exception as e: - print(f"Error processing certificate for {domain_dir.name}: {e}") - - except Exception as e: - print(f"Error scanning ACME certificates directory: {e}") - - print(f"Found {len(acme_certs)} ACME certificates") - return acme_certs - - -def _process_ca_certificates(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): - ca_data = { - "ca": {}, - "certs": {}, - } - - try: - if cert_opts is None: - cert_opts = {} - - # Create CA certificate - opts = cert_opts.copy() - opts["CN"] = name - create_ca(base, name, **opts) - - ca_data["ca"][name] = get_ca_cert(base, name) - - # Process CA-signed certificates (gen_certs) - gen_certs = pillar.get("tls", {}).get("gen_certs", {}) - for certificate, config in gen_certs.items(): - role_patterns = [ - role.get("pattern") - for role in [ - pillar.get("roles", {}).get(r) for r in config.get("roles", "") - ] - if role and role.get("pattern") is not None - ] - - if any(compound(pat, minion_id) for pat in role_patterns): - # Create the options - opts = cert_opts.copy() - opts["CN"] = certificate - opts["days"] = config.get("days", 1) - - create_ca_signed_cert(base, name, **opts) - - # Add the signed certificates to the pillar data - cert_data = get_ca_signed_cert(base, name, certificate) - ca_data["certs"][certificate] = cert_data - except Exception as e: - print(f"Error processing CA certificates: {e}") - - return ca_data - - -def _process_acme_certificates(minion_id, pillar): - """Process ACME certificates - - Reads ACME certificates and determines which ones should be available - to the specified minion based on access rules. - """ - acme_certs = {} - - try: - print(f"Processing ACME certificates for minion: {minion_id}") - all_acme_certs = _find_acme_certs() - - # Check if this is a loadbalancer (gets all certs) - # todo: clean up all but the one that works - is_loadbalancer = False - try: - if 'loadbalancer' in minion_id.lower(): - is_loadbalancer = True - print(f"Minion {minion_id} identified as loadbalancer by name") - - # Also check via roles grain if that doesn't work - elif compound('G@roles:loadbalancer', minion_id): - is_loadbalancer = True - print(f"Minion {minion_id} identified as loadbalancer by grain") - - # Additional check - look for the loadbalancer role in the hostname - elif (minion_id.startswith('lb.') or minion_id.startswith('loadbalancer.')): - is_loadbalancer = True - print(f"Minion {minion_id} identified as loadbalancer by hostname pattern") - - if is_loadbalancer: - print(f"Minion {minion_id} is a loadbalancer, providing all certificates") - except Exception as e: - print(f"Error checking loadbalancer role: {e}") - - # Process each certificate - for domain_name, cert_data in all_acme_certs.items(): - should_include = False - - # Loadbalancer gets all certs - if is_loadbalancer: - should_include = True - reason = "loadbalancer role" - - # Minion name matches domain name - if minion_id.startswith(domain_name.split('.')[0]): - should_include = True - reason = "name match" - - # Add certificate if allowed - if should_include: - acme_certs[domain_name] = cert_data - print(f"Added ACME certificate {domain_name} to pillar data (reason: {reason})") - else: - print(f"Skipping certificate {domain_name} for minion {minion_id} (no access)") - - except Exception as e: - print(f"Error processing ACME certificates: {e}") - - return acme_certs +def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): + if cert_opts is None: + cert_opts = {} + # Create CA certificate + opts = cert_opts.copy() + opts["CN"] = name + create_ca(base, name, **opts) -def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): - """Pillar extension to provide TLS certificates from internal PSFCA and acme.cert generated certs""" - print(f"Processing pillar data for minion: {minion_id}") - - # initial data structure for certs data = { "tls": { - "ca": {}, + "ca": { + name: get_ca_cert(base, name), + }, "certs": {}, - "certs_acme": {}, + "acme_certs": {}, }, } - - # Process CA certificates and CA-signed certificates - ca_data = _process_ca_certificates(minion_id, pillar, base, name, cert_opts) - data["tls"]["ca"] = ca_data["ca"] - for cert_name, cert_data in ca_data["certs"].items(): - data["tls"]["certs"][cert_name] = cert_data - - # process ACME certificates - acme_certs = _process_acme_certificates(minion_id, pillar) - - # Add ACME certificates to both certs and certs_acme sections - for cert_name, cert_data in acme_certs.items(): - # Store in certs_acme section (dedicated for ACME certificates) - data["tls"]["certs_acme"][cert_name] = cert_data - - # Also store in general certs section for backward compatibility - # Only if not already present from CA-signed certs - if cert_name not in data["tls"]["certs"]: - data["tls"]["certs"][cert_name] = cert_data - - # Check if we have ACME certificates for debugging - if not acme_certs: - print(f"No ACME certificates were included for minion: {minion_id}") - else: - print(f"Included {len(acme_certs)} ACME certificates for minion: {minion_id}") - + + minion_roles = [] + minion_roles.extend( + role_name + for role_name, role_config in pillar.get("roles", {}).items() + if role_config.get("pattern") + and compound(role_config["pattern"], minion_id) + ) + + # Process CA-signed certificates (gen_certs) + gen_certs = pillar.get("tls", {}).get("gen_certs", {}) + for certificate, config in gen_certs.items(): + cert_roles = config.get("roles", []) + # Check if any of the minion's roles are in the certificate's required roles + if any(role in minion_roles for role in cert_roles): + # Create the options + opts = cert_opts.copy() + opts["CN"] = certificate + opts["days"] = config.get("days", 1) + + create_ca_signed_cert(base, name, **opts) + + # Add the signed certificates to the pillar data + cert_data = get_ca_signed_cert(base, name, certificate) + data["tls"]["certs"][certificate] = cert_data + + # Collect ACME certs (acme.cert) for this minion based on its roles + acme_certs = pillar.get("tls", {}).get("acme_certs", {}) + for domain, domain_config in acme_certs.items(): + cert_roles = domain_config.get("roles", []) + if any(role in minion_roles for role in cert_roles): + data["tls"]["acme_certs"][domain] = domain_config + return data From 4d172e7c813f0659e0c5c9b13a4aabadac81ccf2 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 24 Apr 2025 11:28:43 -0500 Subject: [PATCH 10/16] docs: explain a little --- salt/_extensions/pillar/ca.py | 1 + 1 file changed, 1 insertion(+) diff --git a/salt/_extensions/pillar/ca.py b/salt/_extensions/pillar/ca.py index fcaa5d7b..493dc53c 100644 --- a/salt/_extensions/pillar/ca.py +++ b/salt/_extensions/pillar/ca.py @@ -315,6 +315,7 @@ def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None) } minion_roles = [] + # match roles based on pillar.roles:pattern minion_roles.extend( role_name for role_name, role_config in pillar.get("roles", {}).items() From 536578ac12f10348abaffb12e697086535daf31a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 25 Apr 2025 13:57:20 -0500 Subject: [PATCH 11/16] fix: just one bugs section --- pillar/base/tls.sls | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pillar/base/tls.sls b/pillar/base/tls.sls index b2b34ada..6ac7ff6d 100644 --- a/pillar/base/tls.sls +++ b/pillar/base/tls.sls @@ -63,11 +63,6 @@ tls: - hg acme_certs: - bugs.python.org: - validation: http - roles: -{# - bugs#} - - loadbalancer pycon.org: validation: http roles: From 43ab8837e3481928a4fdeddb2fbf76df49bb6151 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 30 Apr 2025 13:51:20 -0500 Subject: [PATCH 12/16] fix: requires proper name to not break --- pillar/base/tls.sls | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pillar/base/tls.sls b/pillar/base/tls.sls index 6ac7ff6d..b02ebec8 100644 --- a/pillar/base/tls.sls +++ b/pillar/base/tls.sls @@ -99,11 +99,12 @@ tls: - jython.net - www.jython.com - jython.com - bugs.python.org-multi: + bugs.python.org: validation: http name: bugs.python.org roles: - loadbalancer + - bugs aliases: - bugs.jython.org - issues.roundup-tracker.org From fa30753ac7a68339aecb3ba23ea6876c7717e4f7 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 30 Apr 2025 13:53:44 -0500 Subject: [PATCH 13/16] feat: install certs alongside other certs. certs on certs on certs --- salt/tls/init.sls | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/salt/tls/init.sls b/salt/tls/init.sls index 83bd8507..18e62f3e 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -45,6 +45,19 @@ certbot: - pkg: ssl-cert {% endfor %} +# Install acme.cert certs prepended with acme-* to avoic conflicts +{% for name in salt["pillar.get"]("tls:acme_certs", {}) %} +/etc/ssl/private/acme-{{ name }}.pem: + file.managed: + - contents_pillar: tls:acme_certs:{{ name }} + - user: root + - group: ssl-cert + - mode: "0640" + - show_diff: False + - require: + - pkg: ssl-cert +{% endfor %} + {% if salt["match.compound"](pillar["roles"]["salt-master"]["pattern"]) %} # Process ACME certificates {% for domain, domain_config in salt["pillar.get"]("tls:acme_certs", {}).items() %} From 4d250dddd121aeddc3c03c51459a305939803d44 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 30 Apr 2025 13:55:52 -0500 Subject: [PATCH 14/16] feat: read actual cert instead of config.. --- salt/_extensions/pillar/ca.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/salt/_extensions/pillar/ca.py b/salt/_extensions/pillar/ca.py index 493dc53c..48684849 100644 --- a/salt/_extensions/pillar/ca.py +++ b/salt/_extensions/pillar/ca.py @@ -295,6 +295,15 @@ def get_ca_signed_cert(cacert_path, ca_name, CN): return "\n".join([cert, key]) +def _read_cert_file(path: str) -> str: + """Helper to read certificate files, which might be symlinks""" + try: + with open(path, 'r') as f: + return f.read() + except (IOError, OSError): + return None + + def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None): if cert_opts is None: cert_opts = {} @@ -315,7 +324,6 @@ def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None) } minion_roles = [] - # match roles based on pillar.roles:pattern minion_roles.extend( role_name for role_name, role_config in pillar.get("roles", {}).items() @@ -345,6 +353,11 @@ def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None) for domain, domain_config in acme_certs.items(): cert_roles = domain_config.get("roles", []) if any(role in minion_roles for role in cert_roles): - data["tls"]["acme_certs"][domain] = domain_config + cert_name = domain_config.get('name', domain) + full_cert_chain = _read_cert_file(f"/etc/letsencrypt/live/{cert_name}/fullchain.pem") + privkey = _read_cert_file(f"/etc/letsencrypt/live/{cert_name}/privkey.pem") + + if full_cert_chain and privkey: + data["tls"]["acme_certs"][domain] = full_cert_chain + "\n" + privkey - return data + return data \ No newline at end of file From bcb7bf627be02cdfb20660b5432d3c4aae9feeb5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 6 May 2025 11:24:35 -0500 Subject: [PATCH 15/16] chore: lint --- salt/tls/init.sls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/salt/tls/init.sls b/salt/tls/init.sls index 18e62f3e..13fa5b4d 100644 --- a/salt/tls/init.sls +++ b/salt/tls/init.sls @@ -29,8 +29,8 @@ certbot: {% endfor %} /usr/sbin/update-ca-certificates: - cmd.wait: - - watch: + cmd.run: + - onchanges: - file: /usr/local/share/ca-certificates/*.crt {% for name in salt["pillar.get"]("tls:certs", {}) %} # " Syntax Hack From f2ab9c1f6428641ea390fd1b95e01fc555e699b6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 6 May 2025 11:24:51 -0500 Subject: [PATCH 16/16] chore: lint again --- pillar/base/tls.sls | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/pillar/base/tls.sls b/pillar/base/tls.sls index b02ebec8..a17b1434 100644 --- a/pillar/base/tls.sls +++ b/pillar/base/tls.sls @@ -109,25 +109,25 @@ tls: - bugs.jython.org - issues.roundup-tracker.org - mail.roundup-tracker.org -{# star.python.org:#} -{# validation: dns#} -{# dns_plugin: route53#} -{# dns_plugin_credentials: route53.python#} -{# roles:#} -{# - loadbalancer#} -{# star.pycon.org:#} -{# validation: dns#} -{# dns_plugin: route53#} -{# dns_plugin_credentials: route53.pycon#} -{# roles:#} -{# - loadbalancer#} -{# aliases:#} -{# - pycon.org#} -{# star.pyfound.org:#} -{# validation: dns#} -{# dns_plugin: gandiv5#} -{# dns_plugin_credentials: gandi#} -{# roles:#} -{# - loadbalancer#} -{# aliases:#} -{# - pyfound.org#} +{# star.python.org: #} +{# validation: dns #} +{# dns_plugin: route53 #} +{# dns_plugin_credentials: route53.python #} +{# roles: #} +{# - loadbalancer #} +{# star.pycon.org: #} +{# validation: dns #} +{# dns_plugin: route53 #} +{# dns_plugin_credentials: route53.pycon #} +{# roles: #} +{# - loadbalancer #} +{# aliases: #} +{# - pycon.org #} +{# star.pyfound.org: #} +{# validation: dns #} +{# dns_plugin: gandiv5 #} +{# dns_plugin_credentials: gandi #} +{# roles: #} +{# - loadbalancer #} +{# aliases: #} +{# - pyfound.org #}