feat(api/ssl/bundle): clean up superseded lineages after issuance
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 53s
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 53s
The bundle endpoint correctly issued multi-SAN certs but left old single-SAN .pem files (e.g. <name>-0001.pem) in /etc/haproxy/certs/. HAProxy's `bind ... ssl crt /etc/haproxy/certs` loads everything in the directory and picked the alphabetically-first matching file — typically the older single-SAN one — so the new bundle had no effect on what was served. Repro on peptidesaver.net: bundle covered 4 SANs but HAProxy kept serving peptidesaver.net-0001.pem (single SAN, April-issued). After a successful bundle write, walk SSL_CERTS_DIR and remove any .pem whose CN is in the new bundle's name list (excluding the bundle's own combined file). Drop the matching certbot lineage with `certbot delete --cert-name <X> -n` so `certbot renew` stops touching the dead lineage too. Returns a `cleanup` summary in the API response so callers can log / display what was deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -455,6 +455,90 @@ def request_ssl():
|
|||||||
log_operation('request_ssl', False, str(e))
|
log_operation('request_ssl', False, str(e))
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
def _cleanup_superseded_lineages(keep_path, keep_lineage, bundle_names):
|
||||||
|
"""Remove cert files + certbot lineages that the just-issued bundle supersedes.
|
||||||
|
|
||||||
|
A `.pem` in /etc/haproxy/certs/ is "superseded" iff its certificate's CN
|
||||||
|
is one of the bundle's names AND the file isn't the bundle's own combined
|
||||||
|
file. We don't look at SANs of the OLD certs — being the CN is enough,
|
||||||
|
since that's what HAProxy SNI-matches against and what the file
|
||||||
|
convention names it after.
|
||||||
|
|
||||||
|
Also drops the corresponding certbot renewal config so `certbot renew`
|
||||||
|
stops trying to renew the dead lineage on its next 12h cron tick.
|
||||||
|
|
||||||
|
Returns a small summary dict for logging / API response.
|
||||||
|
"""
|
||||||
|
summary = {'removed': [], 'errors': [], 'skipped': []}
|
||||||
|
|
||||||
|
if not os.path.isdir(SSL_CERTS_DIR):
|
||||||
|
return summary
|
||||||
|
|
||||||
|
keep_basename = os.path.basename(keep_path)
|
||||||
|
|
||||||
|
for fname in sorted(os.listdir(SSL_CERTS_DIR)):
|
||||||
|
if not fname.endswith('.pem'):
|
||||||
|
continue
|
||||||
|
if fname == keep_basename:
|
||||||
|
continue
|
||||||
|
fpath = os.path.join(SSL_CERTS_DIR, fname)
|
||||||
|
try:
|
||||||
|
cn_proc = subprocess.run(
|
||||||
|
['openssl', 'x509', '-in', fpath, '-noout', '-subject', '-nameopt', 'multiline'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if cn_proc.returncode != 0:
|
||||||
|
summary['skipped'].append({'file': fname, 'reason': 'openssl read failed'})
|
||||||
|
continue
|
||||||
|
# `-nameopt multiline` lays out the subject one RDN per line; CN is
|
||||||
|
# the row matching `commonName`. Robust against unusual subject orderings.
|
||||||
|
cn = None
|
||||||
|
for line in cn_proc.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('commonName'):
|
||||||
|
# format: "commonName = example.com"
|
||||||
|
parts = line.split('=', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
cn = parts[1].strip()
|
||||||
|
break
|
||||||
|
if not cn:
|
||||||
|
summary['skipped'].append({'file': fname, 'reason': 'no CN found'})
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
summary['skipped'].append({'file': fname, 'reason': f'inspect failed: {e}'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cn not in bundle_names:
|
||||||
|
continue # not superseded — different domain group
|
||||||
|
|
||||||
|
# This file's CN is now part of our new bundle — supersede it.
|
||||||
|
lineage_name = fname[:-len('.pem')]
|
||||||
|
if lineage_name == keep_lineage:
|
||||||
|
# Defensive: shouldn't happen because of keep_basename check, but
|
||||||
|
# don't accidentally drop the lineage we just wrote.
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(fpath)
|
||||||
|
removed_entry = {'file': fname, 'cn': cn, 'lineage_deleted': False}
|
||||||
|
# Best-effort certbot lineage delete. Some files may not have a
|
||||||
|
# corresponding lineage (e.g. self-signed dev certs); ignore those.
|
||||||
|
try:
|
||||||
|
cb_proc = subprocess.run(
|
||||||
|
['certbot', 'delete', '--cert-name', lineage_name, '-n'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
removed_entry['lineage_deleted'] = (cb_proc.returncode == 0)
|
||||||
|
if cb_proc.returncode != 0:
|
||||||
|
removed_entry['certbot_stderr'] = (cb_proc.stderr or '').strip()[:200]
|
||||||
|
except Exception as e:
|
||||||
|
removed_entry['certbot_error'] = str(e)
|
||||||
|
summary['removed'].append(removed_entry)
|
||||||
|
except Exception as e:
|
||||||
|
summary['errors'].append({'file': fname, 'error': str(e)})
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
@app.route('/api/ssl/bundle', methods=['POST'])
|
@app.route('/api/ssl/bundle', methods=['POST'])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def request_ssl_bundle():
|
def request_ssl_bundle():
|
||||||
@@ -567,16 +651,32 @@ def request_ssl_bundle():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
# Clean up superseded lineages. When the bundle covers names that were
|
||||||
|
# previously each in their own single-SAN -0001/-0002 lineage, those
|
||||||
|
# older .pem files coexist in /etc/haproxy/certs/ and get loaded by the
|
||||||
|
# `bind ... ssl crt /etc/haproxy/certs` directive. HAProxy then picks
|
||||||
|
# one of them by alphabetical/load order — frequently the older
|
||||||
|
# single-SAN file — and the new bundle has no effect on what's served.
|
||||||
|
# This block deletes those superseded files (and their certbot lineage)
|
||||||
|
# before the generate_config() reload so HAProxy picks up the bundle.
|
||||||
|
cleanup_summary = _cleanup_superseded_lineages(
|
||||||
|
keep_path=combined_path,
|
||||||
|
keep_lineage=primary,
|
||||||
|
bundle_names=set(names),
|
||||||
|
)
|
||||||
|
|
||||||
generate_config()
|
generate_config()
|
||||||
log_operation(
|
log_operation(
|
||||||
'request_ssl_bundle', True,
|
'request_ssl_bundle', True,
|
||||||
f'SSL bundle issued for {primary} covering {len(names)} names'
|
f'SSL bundle issued for {primary} covering {len(names)} names; '
|
||||||
|
f'cleaned up {len(cleanup_summary["removed"])} superseded lineage(s)'
|
||||||
)
|
)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'primary': primary,
|
'primary': primary,
|
||||||
'names': names,
|
'names': names,
|
||||||
'cert_path': combined_path,
|
'cert_path': combined_path,
|
||||||
|
'cleanup': cleanup_summary,
|
||||||
'message': f'Bundled certificate obtained for {len(names)} names',
|
'message': f'Bundled certificate obtained for {len(names)} names',
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user