Fix wildcard SSL cert: find certbot -NNNN dirs and use _wildcard_ filename
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m1s

Add find_certbot_live_dir() helper to locate the most recent certbot live
directory for a domain, handling -NNNN suffixed dirs from repeated requests.
Fix combined cert filename from *.domain.pem to _wildcard_.domain.pem.
Apply the helper across all SSL endpoints (request, renew, verify, download).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 06:38:28 -08:00
parent 657cd28344
commit 124a5373d2

View File

@@ -137,6 +137,25 @@ def validate_ip_address(ip_string):
except ValueError: except ValueError:
return False return False
def find_certbot_live_dir(base_domain):
"""Find the most recent certbot live directory for a domain.
Certbot creates -NNNN suffixed dirs for repeated requests."""
live_dir = '/etc/letsencrypt/live'
if not os.path.isdir(live_dir):
return None
candidates = []
for entry in os.listdir(live_dir):
if entry == base_domain or re.match(rf'^{re.escape(base_domain)}-\d{{4}}$', entry):
full_path = os.path.join(live_dir, entry)
fullchain = os.path.join(full_path, 'fullchain.pem')
if os.path.exists(fullchain):
candidates.append((full_path, os.path.getmtime(fullchain)))
if not candidates:
return None
# Return the most recently modified
candidates.sort(key=lambda x: x[1], reverse=True)
return candidates[0][0]
def certbot_register(): def certbot_register():
"""Register with Let's Encrypt using the certbot client and agree to the terms of service""" """Register with Let's Encrypt using the certbot client and agree to the terms of service"""
result = subprocess.run(['certbot', 'show_account'], capture_output=True) result = subprocess.run(['certbot', 'show_account'], capture_output=True)
@@ -392,9 +411,15 @@ def request_ssl():
], capture_output=True, text=True) ], capture_output=True, text=True)
if result.returncode == 0: if result.returncode == 0:
# Combine cert files and copy to HAProxy certs directory # Find the certbot live directory (handles -NNNN suffixes)
cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' live_dir = find_certbot_live_dir(domain)
key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' if not live_dir:
error_msg = f'Certificate obtained but live directory not found for {domain}'
log_operation('request_ssl', False, error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500
cert_path = os.path.join(live_dir, 'fullchain.pem')
key_path = os.path.join(live_dir, 'privkey.pem')
combined_path = f'{SSL_CERTS_DIR}/{domain}.pem' combined_path = f'{SSL_CERTS_DIR}/{domain}.pem'
# Ensure SSL certs directory exists # Ensure SSL certs directory exists
@@ -451,9 +476,12 @@ def renew_certificates():
for domain, cert_path in domains: for domain, cert_path in domains:
if cert_path and os.path.exists(cert_path): if cert_path and os.path.exists(cert_path):
# Recreate combined certificate # For wildcard domains, strip *. prefix for directory lookup
letsencrypt_cert = f'/etc/letsencrypt/live/{domain}/fullchain.pem' lookup_domain = domain[2:] if domain.startswith('*.') else domain
letsencrypt_key = f'/etc/letsencrypt/live/{domain}/privkey.pem' live_dir = find_certbot_live_dir(lookup_domain)
if live_dir:
letsencrypt_cert = os.path.join(live_dir, 'fullchain.pem')
letsencrypt_key = os.path.join(live_dir, 'privkey.pem')
if os.path.exists(letsencrypt_cert) and os.path.exists(letsencrypt_key): if os.path.exists(letsencrypt_cert) and os.path.exists(letsencrypt_key):
with open(cert_path, 'w') as combined: with open(cert_path, 'w') as combined:
@@ -510,7 +538,11 @@ def download_certificate(domain):
def download_private_key(domain): def download_private_key(domain):
"""Download the private key for a domain""" """Download the private key for a domain"""
try: try:
key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' lookup_domain = domain[2:] if domain.startswith('*.') else domain
live_dir = find_certbot_live_dir(lookup_domain)
if not live_dir:
return jsonify({'status': 'error', 'message': 'Private key not found for domain'}), 404
key_path = os.path.join(live_dir, 'privkey.pem')
if not os.path.exists(key_path): if not os.path.exists(key_path):
return jsonify({'status': 'error', 'message': 'Private key not found for domain'}), 404 return jsonify({'status': 'error', 'message': 'Private key not found for domain'}), 404
@@ -525,7 +557,11 @@ def download_private_key(domain):
def download_cert_only(domain): def download_cert_only(domain):
"""Download only the certificate (without private key) for a domain""" """Download only the certificate (without private key) for a domain"""
try: try:
cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' lookup_domain = domain[2:] if domain.startswith('*.') else domain
live_dir = find_certbot_live_dir(lookup_domain)
if not live_dir:
return jsonify({'status': 'error', 'message': 'Certificate not found for domain'}), 404
cert_path = os.path.join(live_dir, 'fullchain.pem')
if not os.path.exists(cert_path): if not os.path.exists(cert_path):
return jsonify({'status': 'error', 'message': 'Certificate not found for domain'}), 404 return jsonify({'status': 'error', 'message': 'Certificate not found for domain'}), 404
@@ -630,9 +666,20 @@ def request_certificates():
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0: if result.returncode == 0:
# Combine cert files and copy to HAProxy certs directory # Find the certbot live directory (handles -NNNN suffixes)
cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' live_dir = find_certbot_live_dir(domain)
key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' if not live_dir:
error_msg = f'Certificate obtained but live directory not found for {domain}'
results.append({
'domain': domain,
'status': 'error',
'message': error_msg
})
error_count += 1
continue
cert_path = os.path.join(live_dir, 'fullchain.pem')
key_path = os.path.join(live_dir, 'privkey.pem')
combined_path = f'{SSL_CERTS_DIR}/{domain}.pem' combined_path = f'{SSL_CERTS_DIR}/{domain}.pem'
# Ensure SSL certs directory exists # Ensure SSL certs directory exists
@@ -1232,13 +1279,16 @@ def dns_challenge_verify():
return jsonify({'success': False, 'error': f'Failed to signal certbot: {e}'}), 500 return jsonify({'success': False, 'error': f'Failed to signal certbot: {e}'}), 500
# Wait for certbot to finish and produce the certificate # Wait for certbot to finish and produce the certificate
cert_path = f'/etc/letsencrypt/live/{base_domain}/fullchain.pem'
key_path = f'/etc/letsencrypt/live/{base_domain}/privkey.pem'
max_wait = 120 max_wait = 120
poll_interval = 1 poll_interval = 1
elapsed = 0 elapsed = 0
live_dir = None
while elapsed < max_wait: while elapsed < max_wait:
live_dir = find_certbot_live_dir(base_domain)
if live_dir:
cert_path = os.path.join(live_dir, 'fullchain.pem')
key_path = os.path.join(live_dir, 'privkey.pem')
if os.path.exists(cert_path) and os.path.exists(key_path): if os.path.exists(cert_path) and os.path.exists(key_path):
# Check that files were recently modified (within last 5 minutes) # Check that files were recently modified (within last 5 minutes)
cert_mtime = os.path.getmtime(cert_path) cert_mtime = os.path.getmtime(cert_path)
@@ -1247,14 +1297,16 @@ def dns_challenge_verify():
time.sleep(poll_interval) time.sleep(poll_interval)
elapsed += poll_interval elapsed += poll_interval
if elapsed >= max_wait: if elapsed >= max_wait or not live_dir:
log_operation('dns_challenge_verify', False, f'Timed out waiting for certificate for *.{base_domain}') log_operation('dns_challenge_verify', False, f'Timed out waiting for certificate for *.{base_domain}')
return jsonify({'success': False, 'error': 'Timed out waiting for certificate from certbot'}), 504 return jsonify({'success': False, 'error': 'Timed out waiting for certificate from certbot'}), 504
# Combine fullchain + privkey into HAProxy cert # Combine fullchain + privkey into HAProxy cert
cert_path = os.path.join(live_dir, 'fullchain.pem')
key_path = os.path.join(live_dir, 'privkey.pem')
try: try:
os.makedirs(SSL_CERTS_DIR, exist_ok=True) os.makedirs(SSL_CERTS_DIR, exist_ok=True)
combined_path = f'{SSL_CERTS_DIR}/*.{base_domain}.pem' combined_path = f'{SSL_CERTS_DIR}/_wildcard_.{base_domain}.pem'
with open(combined_path, 'w') as combined: with open(combined_path, 'w') as combined:
with open(cert_path, 'r') as cf: with open(cert_path, 'r') as cf: