fix(ssl): review round 1 - exact --cert-name lineage, retire whole lineage tree, sweep expired pems

- H1: request_ssl_bundle uses live/<primary> exactly (certbot was given
  --cert-name <primary>) instead of find_certbot_live_dir(), which returns the
  newest of <primary>/<primary>-NNNN. After a "not yet due" no-op that
  published a stale -0001 and retired the real <primary> lineage. keep_lineage
  is now the primary; the bundle's own lineage is never a retirement candidate.
- H2: retire_lineage_tree() moves live/<x>, archive/<x> and renewal/<x>.conf
  together to /etc/letsencrypt/retired/<stamp>/{live,archive,renewal}/ (same
  depth, relative symlinks still resolve, restore is three mv's). Leaving
  archive/<x> behind made certbot's new_lineage() refuse the name forever
  (CertStorageError) and leave an empty renewal conf. Test re-issues the name
  through certbot's own storage code after retirement.
- M2: cert_retire_expired_pems moves already-published EXPIRED pems out of
  /etc/haproxy/certs to cert-retired/ (never the last pem, never the default
  self-signed cert) and the scripts reload when anything was retired.
- GET /api/capabilities + `capabilities` in the bundle response, and VERSION is
  copied into the image, so WHP can refuse whole-site re-bundling against an
  older edge.

Tests: test-cert-write-safety.py 47 -> 52, test-cert-scripts.py 44 -> 50; each
fix reverted in a scratch copy turns its suite red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-09 14:00:03 -07:00
1 parent e58996d6c9
commit 8c7fd93387
8 files changed
+345 -45

No files matched your search

+97 -31
View File
@@ -774,6 +774,30 @@ def health_check():
'error': str(e)
}), 500
# What this edge supports. WHP's orchestrator only switches to whole-site
# re-bundling with unreachable_names when it sees these (HARD RULE: the WHP side
# must not rely on 2026.10.3 behaviour against an older image, whose CN-only
# quarantine + `certbot delete` would act on the larger bundles).
EDGE_CAPABILITIES = ['bundle_renewed_flag', 'san_overlap_retirement',
'unreachable_names', 'retired_lineage_tree']
def _image_version():
for path in ('/haproxy/VERSION', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION')):
try:
with open(path) as fh:
return fh.read().strip()
except OSError:
continue
return 'unknown'
@app.route('/api/capabilities', methods=['GET'])
@require_api_key
def get_capabilities():
return jsonify({'version': _image_version(), 'capabilities': EDGE_CAPABILITIES})
@app.route('/api/regenerate', methods=['GET'])
@require_api_key
def regenerate_conf():
@@ -1017,9 +1041,11 @@ def request_ssl():
# would lose its last valid certificate (the shared-PEM hazard: one bundle
# routinely serves several sites' names, see domains_referencing_cert_path);
# * nothing is deleted. The pem goes to the cert-retired sibling directory and
# the lineage's live/<name> and renewal/<name>.conf go to live-retired/ and
# renewal-retired/, so certbot stops renewing it and renew-certificates.sh
# stops re-publishing it. Every move is logged and can be reversed with mv.
# the lineage's live/<name>, archive/<name> and renewal/<name>.conf go
# together to /etc/letsencrypt/retired/<stamp>/ (retire_lineage_tree), so
# certbot stops renewing it, renew-certificates.sh stops re-publishing it,
# and the name can still be issued again later. Every move is logged and
# can be reversed with mv.
# (This used to `certbot delete` the lineage, which is irreversible.)
# Where certbot keeps its lineages. A module global so the tests can repoint
@@ -1249,14 +1275,58 @@ def _quarantine_superseded_certs(keep_path, keep_lineage, bundle_names,
return summary
def retire_lineage_tree(lineage, stamp=None):
"""Move a certbot lineage COMPLETELY out of certbot's view, without deleting.
live/<x>, archive/<x> and renewal/<x>.conf go together to
LETSENCRYPT_DIR/retired/<UTCstamp>/{live,archive,renewal}/. All three must
move: certbot's storage.RenewableCert.new_lineage() refuses to create a
lineage while archive/<x> exists ("archive directory exists for <x>") - and
it has already written an empty renewal/<x>.conf by then, which breaks every
later --cert-name <x> and every `certbot renew` parse (review H2, verified
against certbot 2.11). Moving only live/ and renewal/ (as the first version
of this did) therefore made the name permanently un-issuable.
The retired tree keeps the same depth as /etc/letsencrypt, so the relative
live/<x>/*.pem -> ../../archive/<x>/*N.pem symlinks still resolve, and a
restore is three mv's back. Returns {'retired_to': dir, 'moved': [...]}.
Raises on failure after moving back anything already moved.
"""
stamp = stamp or datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
root = os.path.join(LETSENCRYPT_DIR, 'retired', stamp)
n = 1
while any(os.path.exists(os.path.join(root, part, name)) for part, name in
(('live', lineage), ('archive', lineage), ('renewal', f'{lineage}.conf'))):
root = os.path.join(LETSENCRYPT_DIR, 'retired', f'{stamp}.{n}')
n += 1
plan = [(os.path.join(LETSENCRYPT_DIR, part, name), os.path.join(root, part, name))
for part, name in (('renewal', f'{lineage}.conf'), ('live', lineage),
('archive', lineage))]
moved = []
try:
for src, dst in plan:
if os.path.lexists(src):
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(src, dst)
moved.append((src, dst))
except Exception:
for src, dst in reversed(moved):
try:
shutil.move(dst, src)
except Exception as e: # pragma: no cover - logged, nothing else to do
logger.critical("Could not restore %s from %s: %s", src, dst, e)
raise
return {'retired_to': root, 'moved': [d for _, d in moved]}
def _retire_superseded_lineages(keep_lineage, bundle_names, unreachable_names=(),
keep_fingerprint=None):
"""Move overlapping certbot lineages out of live/ and renewal/.
"""Move overlapping certbot lineages out of certbot's tree (retire_lineage_tree).
Same overlap and safety rules as _quarantine_superseded_certs(), evaluated
against the pem files that are left in the crt dir. Runs only after HAProxy
has loaded the new bundle. Moving live/<x> to live-retired/<x> (same depth)
keeps its relative ../../archive symlinks valid, so `mv` back restores it.
has loaded the new bundle. keep_lineage is the bundle's --cert-name and is
never retired.
"""
summary = {'retired': [], 'kept': [], 'errors': []}
live_root = os.path.join(LETSENCRYPT_DIR, 'live')
@@ -1275,6 +1345,8 @@ def _retire_superseded_lineages(keep_lineage, bundle_names, unreachable_names=()
for lineage in sorted(os.listdir(live_root)):
lineage_dir = os.path.join(live_root, lineage)
# Hard guard: the bundle's own --cert-name lineage is never a candidate,
# whatever any other check says (review H1).
if lineage == keep_lineage or not os.path.isdir(lineage_dir):
continue
cert_file = os.path.join(lineage_dir, 'cert.pem')
@@ -1299,24 +1371,12 @@ def _retire_superseded_lineages(keep_lineage, bundle_names, unreachable_names=()
continue
entry = {'lineage': lineage, 'names': sorted(info['names'])}
try:
live_dest_root = os.path.join(LETSENCRYPT_DIR, 'live-retired')
os.makedirs(live_dest_root, exist_ok=True)
live_dest = _unique_dest(live_dest_root, lineage)
shutil.move(lineage_dir, live_dest)
entry['live_moved_to'] = live_dest
conf = os.path.join(LETSENCRYPT_DIR, 'renewal', f'{lineage}.conf')
if os.path.exists(conf):
conf_dest_root = os.path.join(LETSENCRYPT_DIR, 'renewal-retired')
os.makedirs(conf_dest_root, exist_ok=True)
conf_dest = _unique_dest(conf_dest_root, f'{lineage}.conf')
shutil.move(conf, conf_dest)
entry['renewal_moved_to'] = conf_dest
entry.update(retire_lineage_tree(lineage))
logger.warning(
"Retired certbot lineage %s (names: %s) because it overlaps the "
"new %s bundle: live -> %s, renewal conf -> %s. Archive left in "
"place; reverse with mv to restore.", lineage,
', '.join(entry['names']), keep_lineage, entry['live_moved_to'],
entry.get('renewal_moved_to', '(none)'))
"new %s bundle: live, archive and renewal conf moved to %s. "
"Restore with three mv's back.", lineage,
', '.join(entry['names']), keep_lineage, entry['retired_to'])
summary['retired'].append(entry)
except Exception as e:
entry['error'] = str(e)
@@ -1419,10 +1479,16 @@ def request_ssl_bundle():
# Defensive: clear any stale lock left by a SIGKILLed prior run.
clear_stale_certbot_locks()
# fullchain.pem starts with the leaf, so it identifies the certificate.
before_dir = find_certbot_live_dir(primary)
before_info = read_cert_info(os.path.join(before_dir, 'fullchain.pem')) \
if before_dir else None
# The lineage is EXACTLY live/<primary>: certbot was given
# --cert-name <primary>, which is authoritative. find_certbot_live_dir()
# must NOT be used here - it returns the newest of <primary> and
# <primary>-NNNN, so after a "not yet due" no-op a stale, more recently
# renewed <primary>-0001 would be published as <primary>.pem and the
# real <primary> lineage would then be retired as "superseded" (review
# H1, reproduced with brainjar.net). fullchain.pem starts with the leaf.
lineage_dir = os.path.join(LETSENCRYPT_DIR, 'live', primary)
before_info = read_cert_info(os.path.join(lineage_dir, 'fullchain.pem')) \
if os.path.isdir(lineage_dir) else None
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
@@ -1436,9 +1502,8 @@ def request_ssl_bundle():
'attempted_names': names,
}), 500
# Locate the lineage. With --cert-name primary, this should be a
# stable directory name (no -NNNN suffix on the first issuance).
live_dir = find_certbot_live_dir(primary)
# --cert-name <primary> means the lineage is live/<primary>, exactly.
live_dir = lineage_dir if os.path.isdir(lineage_dir) else None
if not live_dir:
error_msg = f'Bundle issued but live dir not found for {primary}'
log_operation('request_ssl_bundle', False, error_msg)
@@ -1501,7 +1566,7 @@ def request_ssl_bundle():
# only copy we have.
cleanup_summary = _quarantine_superseded_certs(
keep_path=combined_path,
keep_lineage=os.path.basename(live_dir),
keep_lineage=primary,
bundle_names=set(names),
unreachable_names=unreachable_names,
)
@@ -1525,7 +1590,7 @@ def request_ssl_bundle():
# Only now that HAProxy has loaded the new bundle: stop certbot renewing
# (and renew-certificates.sh re-publishing) overlapping old lineages.
lineage_summary = _retire_superseded_lineages(
keep_lineage=os.path.basename(live_dir),
keep_lineage=primary,
bundle_names=set(names),
unreachable_names=unreachable_names,
keep_fingerprint=new_info.get('fingerprint') if new_info else None,
@@ -1546,6 +1611,7 @@ def request_ssl_bundle():
'names': names,
'cert_path': combined_path,
'renewed': bool(renewed),
'capabilities': EDGE_CAPABILITIES,
'certbot_not_due': bool(certbot_not_due),
'not_after': not_after,
'not_after_iso': datetime.fromtimestamp(not_after, timezone.utc).isoformat()