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
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:
@@ -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,13 +476,16 @@ 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:
|
||||||
if os.path.exists(letsencrypt_cert) and os.path.exists(letsencrypt_key):
|
letsencrypt_cert = os.path.join(live_dir, 'fullchain.pem')
|
||||||
with open(cert_path, 'w') as combined:
|
letsencrypt_key = os.path.join(live_dir, 'privkey.pem')
|
||||||
subprocess.run(['cat', letsencrypt_cert, letsencrypt_key], stdout=combined)
|
|
||||||
|
if os.path.exists(letsencrypt_cert) and os.path.exists(letsencrypt_key):
|
||||||
|
with open(cert_path, 'w') as combined:
|
||||||
|
subprocess.run(['cat', letsencrypt_cert, letsencrypt_key], stdout=combined)
|
||||||
|
|
||||||
# Regenerate config and reload HAProxy
|
# Regenerate config and reload HAProxy
|
||||||
generate_config()
|
generate_config()
|
||||||
@@ -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,14 +666,25 @@ 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
|
||||||
os.makedirs(SSL_CERTS_DIR, exist_ok=True)
|
os.makedirs(SSL_CERTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
with open(combined_path, 'w') as combined:
|
with open(combined_path, 'w') as combined:
|
||||||
subprocess.run(['cat', cert_path, key_path], stdout=combined)
|
subprocess.run(['cat', cert_path, key_path], stdout=combined)
|
||||||
|
|
||||||
@@ -1232,29 +1279,34 @@ 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:
|
||||||
if os.path.exists(cert_path) and os.path.exists(key_path):
|
live_dir = find_certbot_live_dir(base_domain)
|
||||||
# Check that files were recently modified (within last 5 minutes)
|
if live_dir:
|
||||||
cert_mtime = os.path.getmtime(cert_path)
|
cert_path = os.path.join(live_dir, 'fullchain.pem')
|
||||||
if (time.time() - cert_mtime) < 300:
|
key_path = os.path.join(live_dir, 'privkey.pem')
|
||||||
break
|
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
# Check that files were recently modified (within last 5 minutes)
|
||||||
|
cert_mtime = os.path.getmtime(cert_path)
|
||||||
|
if (time.time() - cert_mtime) < 300:
|
||||||
|
break
|
||||||
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user