From f7ef34b988529c801bc33c9404381627ec88e384 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 9 May 2026 11:58:21 -0700 Subject: [PATCH] feat(api/ssl/bundle): clean up superseded lineages after issuance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle endpoint correctly issued multi-SAN certs but left old single-SAN .pem files (e.g. -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 -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) --- haproxy_manager.py | 102 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/haproxy_manager.py b/haproxy_manager.py index 3390bc6..6ff63a9 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -455,6 +455,90 @@ def request_ssl(): log_operation('request_ssl', False, str(e)) 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']) @require_api_key def request_ssl_bundle(): @@ -567,16 +651,32 @@ def request_ssl_bundle(): conn.commit() 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() log_operation( '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({ 'status': 'success', 'primary': primary, 'names': names, 'cert_path': combined_path, + 'cleanup': cleanup_summary, 'message': f'Bundled certificate obtained for {len(names)} names', }) except Exception as e: