Skip to content

Commit 98dc839

Browse files
committed
stuff acme certs into appropriate pillar data
bugs->bugs cert $host->$host cert loadbalancer->all certs
1 parent 10a5006 commit 98dc839

3 files changed

Lines changed: 216 additions & 57 deletions

File tree

salt/_extensions/pillar/ca.py

Lines changed: 193 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import binascii
44
import datetime
55
import os.path
6+
from pathlib import Path
67

78
import salt.loader
89

@@ -295,46 +296,203 @@ def get_ca_signed_cert(cacert_path, ca_name, CN):
295296
return "\n".join([cert, key])
296297

297298

298-
def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None):
299-
if cert_opts is None:
300-
cert_opts = {}
299+
def _find_acme_certs(base_path="/etc/letsencrypt/live"):
300+
"""Read ACME certificates from /etc/letsencrypt/live
301+
302+
returns dict with domain name (key) and data (value for each cert.
303+
"""
304+
acme_certs = {}
305+
try:
306+
if not Path(base_path).exists():
307+
print(f"ACME base path {base_path} does not exist")
308+
return acme_certs
309+
310+
print(f"Scanning for certificates in {base_path}")
311+
for domain_dir in Path(base_path).iterdir():
312+
try:
313+
domain_dir_path = Path(base_path) / domain_dir
314+
if not domain_dir_path.is_dir() or domain_dir.name == "README":
315+
continue
316+
317+
domain_name = domain_dir.name
318+
print(f"Found certificate directory: {domain_name}")
319+
320+
# use fullchain.pem instead of just cert.pem to include the full certificate chain
321+
cert_file = domain_dir_path / "fullchain.pem"
322+
key_file = domain_dir_path / "privkey.pem"
323+
324+
if not cert_file.exists():
325+
print(f"Certificate file not found: {cert_file}")
326+
continue
327+
328+
if not key_file.exists():
329+
print(f"Key file not found: {key_file}")
330+
continue
331+
332+
with cert_file.open('r') as f_cert:
333+
cert_data = f_cert.read()
334+
335+
with key_file.open('r') as f_key:
336+
key_data = f_key.read()
337+
338+
# Store combined certificate and key
339+
combined_data = "\n".join([cert_data, key_data])
340+
acme_certs[domain_name] = combined_data
341+
# print(f"read certificate for {domain_name}")
342+
343+
except Exception as e:
344+
print(f"Error processing certificate for {domain_dir.name}: {e}")
345+
346+
except Exception as e:
347+
print(f"Error scanning ACME certificates directory: {e}")
348+
349+
print(f"Found {len(acme_certs)} ACME certificates")
350+
return acme_certs
351+
352+
353+
def _process_ca_certificates(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None):
354+
ca_data = {
355+
"ca": {},
356+
"certs": {},
357+
}
358+
359+
try:
360+
if cert_opts is None:
361+
cert_opts = {}
362+
363+
# Create CA certificate
364+
opts = cert_opts.copy()
365+
opts["CN"] = name
366+
create_ca(base, name, **opts)
367+
368+
ca_data["ca"][name] = get_ca_cert(base, name)
369+
370+
# Process CA-signed certificates (gen_certs)
371+
gen_certs = pillar.get("tls", {}).get("gen_certs", {})
372+
for certificate, config in gen_certs.items():
373+
role_patterns = [
374+
role.get("pattern")
375+
for role in [
376+
pillar.get("roles", {}).get(r) for r in config.get("roles", "")
377+
]
378+
if role and role.get("pattern") is not None
379+
]
380+
381+
if any(compound(pat, minion_id) for pat in role_patterns):
382+
# Create the options
383+
opts = cert_opts.copy()
384+
opts["CN"] = certificate
385+
opts["days"] = config.get("days", 1)
386+
387+
create_ca_signed_cert(base, name, **opts)
388+
389+
# Add the signed certificates to the pillar data
390+
cert_data = get_ca_signed_cert(base, name, certificate)
391+
ca_data["certs"][certificate] = cert_data
392+
except Exception as e:
393+
print(f"Error processing CA certificates: {e}")
394+
395+
return ca_data
301396

302-
# Ensure we have a CA created.
303-
opts = cert_opts.copy()
304-
opts["CN"] = name
305-
create_ca(base, name, **opts)
306397

307-
# Start our pillar with just the ca certificate.
398+
def _process_acme_certificates(minion_id, pillar):
399+
"""Process ACME certificates
400+
401+
Reads ACME certificates and determines which ones should be available
402+
to the specified minion based on access rules.
403+
"""
404+
acme_certs = {}
405+
406+
try:
407+
print(f"Processing ACME certificates for minion: {minion_id}")
408+
all_acme_certs = _find_acme_certs()
409+
410+
# Check if this is a loadbalancer (gets all certs)
411+
# todo: clean up all but the one that works
412+
is_loadbalancer = False
413+
try:
414+
if 'loadbalancer' in minion_id.lower():
415+
is_loadbalancer = True
416+
print(f"Minion {minion_id} identified as loadbalancer by name")
417+
418+
# Also check via roles grain if that doesn't work
419+
elif compound('G@roles:loadbalancer', minion_id):
420+
is_loadbalancer = True
421+
print(f"Minion {minion_id} identified as loadbalancer by grain")
422+
423+
# Additional check - look for the loadbalancer role in the hostname
424+
elif (minion_id.startswith('lb.') or minion_id.startswith('loadbalancer.')):
425+
is_loadbalancer = True
426+
print(f"Minion {minion_id} identified as loadbalancer by hostname pattern")
427+
428+
if is_loadbalancer:
429+
print(f"Minion {minion_id} is a loadbalancer, providing all certificates")
430+
except Exception as e:
431+
print(f"Error checking loadbalancer role: {e}")
432+
433+
# Process each certificate
434+
for domain_name, cert_data in all_acme_certs.items():
435+
should_include = False
436+
437+
# Loadbalancer gets all certs
438+
if is_loadbalancer:
439+
should_include = True
440+
reason = "loadbalancer role"
441+
442+
# Minion name matches domain name
443+
if minion_id.startswith(domain_name.split('.')[0]):
444+
should_include = True
445+
reason = "name match"
446+
447+
# Add certificate if allowed
448+
if should_include:
449+
acme_certs[domain_name] = cert_data
450+
print(f"Added ACME certificate {domain_name} to pillar data (reason: {reason})")
451+
else:
452+
print(f"Skipping certificate {domain_name} for minion {minion_id} (no access)")
453+
454+
except Exception as e:
455+
print(f"Error processing ACME certificates: {e}")
456+
457+
return acme_certs
458+
459+
460+
def ext_pillar(minion_id, pillar, base="/etc/ssl", name="PSFCA", cert_opts=None):
461+
"""Pillar extension to provide TLS certificates from internal PSFCA and acme.cert generated certs"""
462+
print(f"Processing pillar data for minion: {minion_id}")
463+
464+
# initial data structure for certs
308465
data = {
309466
"tls": {
310-
"ca": {
311-
name: get_ca_cert(base, name),
312-
},
467+
"ca": {},
313468
"certs": {},
469+
"certs_acme": {},
314470
},
315471
}
316-
317-
# Create all of the certificates required by this minion
318-
gen_certs = pillar.get("tls", {}).get("gen_certs", {})
319-
for certificate, config in gen_certs.items():
320-
role_patterns = [
321-
role.get("pattern")
322-
for role in [
323-
pillar.get("roles", {}).get(r) for r in config.get("roles", "")
324-
]
325-
if role and role.get("pattern") is not None
326-
]
327-
if any([compound(pat, minion_id) for pat in role_patterns]):
328-
# Create the options
329-
opts = cert_opts.copy()
330-
opts["CN"] = certificate
331-
opts["days"] = config.get("days", 1)
332-
333-
# Create the signed certificates
334-
create_ca_signed_cert(base, name, **opts)
335-
336-
# Add the signed certificates to the pillar data
337-
cert_data = get_ca_signed_cert(base, name, certificate)
338-
data["tls"]["certs"][certificate] = cert_data
339-
472+
473+
# Process CA certificates and CA-signed certificates
474+
ca_data = _process_ca_certificates(minion_id, pillar, base, name, cert_opts)
475+
data["tls"]["ca"] = ca_data["ca"]
476+
for cert_name, cert_data in ca_data["certs"].items():
477+
data["tls"]["certs"][cert_name] = cert_data
478+
479+
# process ACME certificates
480+
acme_certs = _process_acme_certificates(minion_id, pillar)
481+
482+
# Add ACME certificates to both certs and certs_acme sections
483+
for cert_name, cert_data in acme_certs.items():
484+
# Store in certs_acme section (dedicated for ACME certificates)
485+
data["tls"]["certs_acme"][cert_name] = cert_data
486+
487+
# Also store in general certs section for backward compatibility
488+
# Only if not already present from CA-signed certs
489+
if cert_name not in data["tls"]["certs"]:
490+
data["tls"]["certs"][cert_name] = cert_data
491+
492+
# Check if we have ACME certificates for debugging
493+
if not acme_certs:
494+
print(f"No ACME certificates were included for minion: {minion_id}")
495+
else:
496+
print(f"Included {len(acme_certs)} ACME certificates for minion: {minion_id}")
497+
340498
return data

salt/tls/init.sls

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,27 @@ certbot:
4545
- pkg: ssl-cert
4646
{% endfor %}
4747

48-
{% if grains['id'] == 'salt.nyc1.psf.io' or grains['id'] == 'salt-master.vagrant.psf.io' %}
48+
{% if salt["match.compound"](pillar["roles"]["salt-master"]["pattern"]) %}
49+
# HTTP-validated domains
50+
{% for domain in [
51+
'pypa.io',
52+
'www.pycon.org',
53+
'speed.pypy.org',
54+
'salt-public.psf.io',
55+
'planetpython.org',
56+
'bugs.python.org'
57+
] %}
58+
{{ domain }}:
59+
acme.cert:
60+
- email: infrastructure-staff@python.org
61+
- webroot: /etc/lego
62+
- renew: 14
63+
{% if pillar["dc"] == "vagrant" %}
64+
- server: https://salt-master.vagrant.psf.io:14000/dir
65+
{% endif %}
66+
- require:
67+
- sls: tls.lego
68+
{% endfor %}
4969

5070
# DNS-validated domains
5171
# dns plugins do not exist yet for route53 & gandi
@@ -85,27 +105,6 @@ certbot:
85105
{# - require:#}
86106
{# - sls: tls.lego#}
87107

88-
# HTTP-validated domains
89-
{% for domain in [
90-
'pypa.io',
91-
'www.pycon.org',
92-
'speed.pypy.org',
93-
'salt-public.psf.io',
94-
'planetpython.org',
95-
'bugs.python.org'
96-
] %}
97-
{{ domain }}:
98-
acme.cert:
99-
- email: infrastructure-staff@python.org
100-
- webroot: /etc/lego
101-
- renew: 14
102-
{% if pillar["dc"] == "vagrant" %}
103-
- server: https://salt-master.vagrant.psf.io:14000/dir
104-
{% endif %}
105-
- require:
106-
- sls: tls.lego
107-
{% endfor %}
108-
109108
# Multi-domain certificates
110109
{#jython.org:#}
111110
{# acme.cert:#}

salt/tls/pebble.sls

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{% if salt["match.compound"](pillar["roles"]["salt-master"]["pattern"]) %}
12
{% if pillar.get('pebble', {'enabled': False}).enabled %}
23
pebble-build-deps:
34
pkg.installed:
@@ -60,3 +61,4 @@ pebble-service:
6061
- file: /etc/ssl/certs/PSF_CA.pem
6162
- file: /etc/ssl/private/salt-master.vagrant.psf.io.pem
6263
{% endif %}
64+
{% endif %}

0 commit comments

Comments
 (0)