Merge fix/ssl-renewal-durable into release/2026.10.3
Build and push coraza-spoa / Build-and-Push (push) Successful in 3m9s
HAProxy Manager Build and Push / Build-and-Push (push) Failing after 5m7s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-09 15:22:52 -07:00
commit 765b2ce00a
18 files changed
+1315 -114

No files matched your search

+455 -102
View File
@@ -9,7 +9,7 @@ import psutil
import functools
import hmac
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import json
import ipaddress
import shutil
@@ -598,6 +598,19 @@ def backup_existing_pem(dest_path):
return None
def leaf_is_expired(path):
"""True only when openssl positively says the first certificate in `path`
has expired. Unreadable/missing openssl returns False: those cases are
rejected, with a better message, by validate_pem_bundle()."""
try:
proc = subprocess.run(
['openssl', 'x509', '-checkend', '0', '-noout', '-in', path],
capture_output=True, text=True, stdin=subprocess.DEVNULL)
except Exception:
return False
return proc.returncode == 1 and 'will expire' in (proc.stdout or '')
def publish_pem_bundle(dest_path, source_paths):
"""Publish cert+key as a combined PEM at dest_path, atomically.
@@ -639,6 +652,15 @@ def publish_pem_bundle(dest_path, source_paths):
raise CertificatePublishError(
f'assembled bundle for {dest_path} is not usable: {msg}')
# Never publish an expired certificate. HAProxy loads the crt directory
# wholesale and serves the first file that claims an SNI name, so an
# expired file in there can only ever shadow a valid one (brain-jar.com.pem
# did exactly that to brainjar.net on 2026-10-07).
if leaf_is_expired(source_paths[0]):
raise CertificatePublishError(
f'refusing to publish {dest_path}: the certificate in '
f'{source_paths[0]} has expired')
dest_dir = os.path.dirname(dest_path) or '.'
try:
os.makedirs(dest_dir, exist_ok=True)
@@ -788,6 +810,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():
@@ -1008,132 +1054,372 @@ def request_ssl():
log_operation('request_ssl', False, str(e))
return jsonify({'status': 'error', 'message': str(e)}), 500
def _quarantine_superseded_certs(keep_path, keep_lineage, bundle_names):
"""Move aside cert files that the just-issued bundle supersedes.
# ---------------------------------------------------------------------------
# Superseded-certificate retirement (incident 2026-10-07, brainjar.net)
# ---------------------------------------------------------------------------
# HAProxy loads /etc/haproxy/certs as a DIRECTORY and, when two files claim the
# same SNI name, serves whichever it loaded first. So a new bundle has no effect
# while an older file that also lists one of its names is still in there.
#
# brainjar.net served an EXPIRED certificate for two days for exactly that
# reason. The orchestrator re-bundled brainjar.net and its seven siblings
# without the dead brain-jar.com, but the cleanup that was meant to move the old
# file aside matched on the old certificate's CN only. Its CN was brain-jar.com,
# which was not in the new bundle, so brain-jar.com.pem (which also listed all
# eight brainjar names) stayed put and kept winning the SNI match. On top of
# that, renew-certificates.sh re-published the old lineage from live/ every 12
# hours, so moving the file alone would not have stuck.
#
# What retirement does now:
# * any OTHER pem/lineage that shares ANY name with the new bundle (CN or
# SAN) is a candidate, not only one whose CN is in the bundle;
# * a candidate is moved aside only if no name that this edge still serves
# 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>, 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.)
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.
# Where certbot keeps its lineages. A module global so the tests can repoint
# it; find_certbot_live_dir() predates it and still hardcodes the live path.
LETSENCRYPT_DIR = '/etc/letsencrypt'
This used to os.remove() the file and immediately `certbot delete` the
lineage, both BEFORE anything had checked that the replacement bundle was
usable — destroying the only two copies of a working certificate on the
strength of a `cat` whose exit status nobody read. Now:
* the superseded file is MOVED to the cert backup directory instead of
unlinked, so an operator can put it back by hand;
* the certbot lineage is left alone here. Deleting it is irreversible
(archive, live and renewal config all go) and recovering means fresh,
rate-limited ACME orders, so it happens only in
_delete_superseded_lineages(), after HAProxy has actually loaded the
replacement.
def cert_retired_dir():
"""Where retired .pem files are moved. Sibling of the crt dir, never inside."""
return _cert_sibling_dir('cert-retired')
Moving still solves the problem the removal existed for: the old file no
longer sits in the crt directory shadowing the new bundle's SNI match.
Returns a summary dict for logging / API response. Entries in 'removed'
carry the lineage name for the deletion phase.
def parse_cert_text(text):
"""Parse `openssl x509 -noout -subject -nameopt multiline -enddate
-fingerprint -sha256 -ext subjectAltName` output.
Returns {'cn', 'names', 'not_after', 'fingerprint'}; names is the lower-cased
set of the CN plus every DNS SAN. not_after is a UTC epoch or None.
"""
summary = {'removed': [], 'errors': [], 'skipped': []}
cn = None
names = set()
not_after = None
fingerprint = None
in_san = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith('commonName') and '=' in line and cn is None:
cn = line.split('=', 1)[1].strip().lower()
continue
if line.startswith('notAfter='):
try:
not_after = int(datetime.strptime(
' '.join(line.split('=', 1)[1].split()),
'%b %d %H:%M:%S %Y %Z').replace(
tzinfo=timezone.utc).timestamp())
except ValueError:
not_after = None
continue
if '=' in line and line.lower().split('=', 1)[0].endswith('fingerprint'):
fingerprint = line.split('=', 1)[1].strip().upper()
continue
if line.startswith('X509v3 Subject Alternative Name'):
in_san = True
continue
if in_san:
for part in line.split(','):
part = part.strip()
if part.startswith('DNS:'):
names.add(part[4:].strip().lower())
in_san = False
if cn:
names.add(cn)
return {'cn': cn, 'names': names, 'not_after': not_after,
'fingerprint': fingerprint}
def read_cert_info(path):
"""parse_cert_text() for the first certificate in `path`, or None."""
try:
proc = subprocess.run(
['openssl', 'x509', '-in', path, '-noout', '-subject',
'-nameopt', 'multiline', '-enddate', '-fingerprint', '-sha256',
'-ext', 'subjectAltName'],
capture_output=True, text=True, stdin=subprocess.DEVNULL)
except Exception:
return None
if proc.returncode != 0:
return None
info = parse_cert_text(proc.stdout)
return info if info['names'] else None
def cert_name_covers(cert_name, host):
"""Does certificate name `cert_name` (maybe `*.x`) cover hostname `host`?"""
cert_name = (cert_name or '').lower()
host = (host or '').lower()
if cert_name == host:
return True
if cert_name.startswith('*.') and not host.startswith('*.'):
suffix = cert_name[1:] # ".example.com"
head = host[:-len(suffix)] if host.endswith(suffix) else ''
return bool(head) and '.' not in head
return False
def _cert_covers(names, host):
return any(cert_name_covers(n, host) for n in names)
def _served_names():
"""Every hostname this edge has a route for (the SQLite domains table)."""
try:
with sqlite3.connect(DB_FILE) as conn:
return {row[0].lower() for row in
conn.execute('SELECT domain FROM domains').fetchall()
if row[0]}
except Exception as e:
# Unknown served set -> treat every name as served, i.e. never retire a
# still-valid certificate on the strength of a failed query.
logger.error("Could not read the domains table for retirement checks: %s", e)
return None
def retirement_blockers(candidate, others, bundle_names, served_names,
unreachable_names, now=None):
"""Names that would lose their LAST valid certificate if `candidate` went.
candidate/others are read_cert_info() dicts. `others` are the certificates
that would remain in the crt directory. Pure, so it is unit-testable.
* An expired candidate blocks nothing: it is not a valid certificate for
anyone, and while it sits in the crt dir it can only shadow a good one.
* A name the caller asserts is not served here (unreachable_names - DNS
points elsewhere or nowhere) blocks nothing.
* A name with no route on this edge blocks nothing.
* served_names=None (could not read the DB) means "assume everything is
served", so a valid candidate is kept unless the bundle covers it all.
"""
now = time.time() if now is None else now
if candidate.get('not_after') is None:
return ['<unreadable expiry>']
if candidate['not_after'] <= now:
return []
unreachable = {n.lower() for n in (unreachable_names or ())}
bundle = {n.lower() for n in bundle_names}
if served_names is None:
candidates_to_check = set(candidate['names'])
else:
candidates_to_check = {s for s in served_names
if _cert_covers(candidate['names'], s)}
blockers = []
for name in sorted(candidates_to_check):
if name in unreachable or _cert_covers(bundle, name):
continue
covered_elsewhere = any(
o is not candidate
and o.get('fingerprint') != candidate.get('fingerprint')
and o.get('not_after') is not None and o['not_after'] > now
and _cert_covers(o['names'], name)
for o in others)
if not covered_elsewhere:
blockers.append(name)
return blockers
def _overlaps_bundle(info, bundle_names):
return any(_cert_covers(info['names'], b) for b in bundle_names) or \
any(n in {b.lower() for b in bundle_names} for n in info['names'])
def _unique_dest(directory, name):
dest = os.path.join(directory, name)
if not os.path.exists(dest):
return dest
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
dest = os.path.join(directory, f'{name}.{stamp}')
n = 1
while os.path.exists(dest):
dest = os.path.join(directory, f'{name}.{stamp}.{n}')
n += 1
return dest
def _quarantine_superseded_certs(keep_path, keep_lineage, bundle_names,
unreachable_names=()):
"""Move aside .pem files in the crt dir that overlap the new bundle.
Runs after the new bundle is published and before HAProxy reloads, so the
replacement is never the only thing we have. See the block comment above
for the rules. Returns {'removed', 'kept', 'skipped', 'errors'}.
"""
summary = {'removed': [], 'kept': [], 'skipped': [], 'errors': []}
if not os.path.isdir(SSL_CERTS_DIR):
return summary
keep_basename = os.path.basename(keep_path)
served = _served_names()
# Inventory once; `remaining` shrinks as files are retired so the coverage
# check for the next candidate sees what will actually be left.
inventory = {}
for fname in sorted(os.listdir(SSL_CERTS_DIR)):
if not fname.endswith('.pem'):
continue
info = read_cert_info(os.path.join(SSL_CERTS_DIR, fname))
if info is None:
summary['skipped'].append({'file': fname, 'reason': 'openssl could not read it'})
continue
inventory[fname] = info
remaining = dict(inventory)
for fname, info in inventory.items():
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}'})
if not _overlaps_bundle(info, bundle_names):
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.
others = [i for f, i in remaining.items() if f != fname]
blockers = retirement_blockers(info, others, bundle_names, served,
unreachable_names)
if blockers:
logger.warning(
"Not retiring %s although it overlaps the new %s bundle: it is "
"still the only valid certificate for %s", fname, keep_lineage,
', '.join(blockers))
summary['kept'].append({'file': fname, 'blockers': blockers})
continue
try:
os.makedirs(cert_backup_dir(), exist_ok=True)
quarantine_path = os.path.join(cert_backup_dir(), fname)
# Move, not unlink: out of the crt directory (so it stops shadowing
# the new bundle) but still on disk for manual recovery.
shutil.move(fpath, quarantine_path)
os.makedirs(cert_retired_dir(), exist_ok=True)
dest = _unique_dest(cert_retired_dir(), fname)
shutil.move(os.path.join(SSL_CERTS_DIR, fname), dest)
remaining.pop(fname, None)
logger.warning(
"Retired superseded certificate %s (names: %s; expires %s) -> %s "
"because it overlaps the new %s bundle",
fname, ', '.join(sorted(info['names'])),
datetime.fromtimestamp(info['not_after'], timezone.utc).isoformat()
if info['not_after'] else 'unknown', dest, keep_lineage)
summary['removed'].append({
'file': fname,
'cn': cn,
'lineage': lineage_name,
'moved_to': quarantine_path,
'lineage_deleted': False,
'cn': info['cn'],
'names': sorted(info['names']),
'expired': bool(info['not_after'] and info['not_after'] <= time.time()),
'moved_to': dest,
})
except Exception as e:
summary['errors'].append({'file': fname, 'error': str(e)})
return summary
def _delete_superseded_lineages(summary):
"""`certbot delete` the lineages quarantined by _quarantine_superseded_certs().
def retire_lineage_tree(lineage, stamp=None):
"""Move a certbot lineage COMPLETELY out of certbot's view, without deleting.
IRREVERSIBLE: certbot removes the lineage's archive, live symlinks and
renewal config. If it turns out we needed that certificate, the only way
back is a fresh ACME order, which Let's Encrypt rate-limits — an outage
measured in hours. So this is gated hardest of anything in this module: it
runs only after the replacement bundle has been assembled, validated,
published, AND loaded by a HAProxy that reloaded successfully.
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.
Mutates `summary` in place. Best-effort: some files have no corresponding
lineage (e.g. self-signed dev certs) and a failure here is harmless — a
dead lineage merely wastes a renewal attempt on the next 12h cron tick.
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.
"""
for entry in summary.get('removed', []):
lineage_name = entry.get('lineage')
if not lineage_name:
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 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. keep_lineage is the bundle's --cert-name and is
never retired.
"""
summary = {'retired': [], 'kept': [], 'errors': []}
live_root = os.path.join(LETSENCRYPT_DIR, 'live')
if not os.path.isdir(live_root):
return summary
served = _served_names()
remaining_pems = []
if os.path.isdir(SSL_CERTS_DIR):
for fname in sorted(os.listdir(SSL_CERTS_DIR)):
if fname.endswith('.pem'):
info = read_cert_info(os.path.join(SSL_CERTS_DIR, fname))
if info is not None:
info['_file'] = fname
remaining_pems.append(info)
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')
if not os.path.exists(cert_file):
cert_file = os.path.join(lineage_dir, 'fullchain.pem')
info = read_cert_info(cert_file)
if info is None or not _overlaps_bundle(info, bundle_names):
continue
if keep_fingerprint and info.get('fingerprint') == keep_fingerprint:
continue
# The lineage's own published pem is not "another" certificate, even if
# its fingerprint drifted from the lineage (renewed, not republished).
others = [p for p in remaining_pems if p['_file'] != f'{lineage}.pem']
blockers = retirement_blockers(info, others, bundle_names, served,
unreachable_names)
if blockers:
logger.warning(
"Not retiring certbot lineage %s although it overlaps the new %s "
"bundle: it is still the only valid certificate for %s",
lineage, keep_lineage, ', '.join(blockers))
summary['kept'].append({'lineage': lineage, 'blockers': blockers})
continue
entry = {'lineage': lineage, 'names': sorted(info['names'])}
try:
cb_proc = subprocess.run(
['certbot', 'delete', '--cert-name', lineage_name, '-n'],
capture_output=True, text=True
)
entry['lineage_deleted'] = (cb_proc.returncode == 0)
if cb_proc.returncode != 0:
entry['certbot_stderr'] = (cb_proc.stderr or '').strip()[:200]
entry.update(retire_lineage_tree(lineage))
logger.warning(
"Retired certbot lineage %s (names: %s) because it overlaps the "
"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['certbot_error'] = str(e)
entry['error'] = str(e)
summary['errors'].append(entry)
return summary
@app.route('/api/ssl/bundle', methods=['POST'])
@require_api_key
def request_ssl_bundle():
@@ -1144,7 +1430,18 @@ def request_ssl_bundle():
/api/ssl calls when a site has multiple domains.
Body:
{"primary": "example.com", "sans": ["www.example.com", ...]}
{"primary": "example.com", "sans": ["www.example.com", ...],
"unreachable_names": ["dead.example.com", ...]} # optional
unreachable_names: names the caller (WHP's orchestrator) has established do
not resolve to this host. They are left out of the bundle by the caller;
here they only tell superseded-certificate retirement that an older cert's
coverage of them is not worth keeping (see retirement_blockers()).
The response says whether certbot actually issued ("renewed") or found the
existing lineage not yet due and did nothing, and carries the published
certificate's not_after, so the caller can schedule from the real expiry
instead of recording a no-op as a renewal.
The cert lineage uses --cert-name <primary>, so renewal under the same
name doesn't proliferate -0001/-0002 dirs (the issue we hit with the
@@ -1182,6 +1479,13 @@ def request_ssl_bundle():
return jsonify({'status': 'error', 'message': f'invalid SAN: {s!r}'}), 400
cleaned_sans.append(s)
unreachable_names = data.get('unreachable_names') or []
if not isinstance(unreachable_names, list) or not all(
isinstance(n, str) for n in unreachable_names):
return jsonify({'status': 'error',
'message': '"unreachable_names" must be a list of strings'}), 400
unreachable_names = [n.strip().lower() for n in unreachable_names if n.strip()]
seen = {primary}
names = [primary]
for s in cleaned_sans:
@@ -1196,8 +1500,11 @@ def request_ssl_bundle():
'message': f'Too many SANs ({len(names)}); Let\'s Encrypt limit is 100',
}), 400
# --keep-until-expiring is what -n does anyway when the lineage exists with
# the same names; it is spelled out so the no-op case is deliberate, and it
# is detected below rather than reported as an issuance.
cmd = [
'certbot', 'certonly', '-n', '--standalone',
'certbot', 'certonly', '-n', '--standalone', '--keep-until-expiring',
'--preferred-challenges', 'http', '--http-01-port=8688',
'--cert-name', primary,
]
@@ -1208,6 +1515,17 @@ def request_ssl_bundle():
# Defensive: clear any stale lock left by a SIGKILLed prior run.
clear_stale_certbot_locks()
# 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:
stderr_excerpt = (result.stderr or '').strip()[:800]
@@ -1220,9 +1538,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)
@@ -1232,6 +1549,20 @@ def request_ssl_bundle():
key_path = os.path.join(live_dir, 'privkey.pem')
combined_path = f'{SSL_CERTS_DIR}/{primary}.pem'
# Did certbot issue, or did it find the lineage not yet due? Measured by
# the certificate itself (fingerprint before vs after), with certbot's
# own wording as a second signal.
new_info = read_cert_info(cert_path)
output = (result.stdout or '') + (result.stderr or '')
certbot_not_due = ('not yet due for renewal' in output.lower()
or 'no action taken' in output.lower())
if before_info is None:
renewed = new_info is not None
elif new_info is None:
renewed = not certbot_not_due
else:
renewed = new_info.get('fingerprint') != before_info.get('fingerprint')
os.makedirs(SSL_CERTS_DIR, exist_ok=True)
try:
publish_pem_bundle(combined_path, [cert_path, key_path])
@@ -1273,6 +1604,7 @@ def request_ssl_bundle():
keep_path=combined_path,
keep_lineage=primary,
bundle_names=set(names),
unreachable_names=unreachable_names,
)
# Raises if the config does not validate or HAProxy does not reload;
@@ -1285,26 +1617,47 @@ def request_ssl_bundle():
logger.critical(
"HAProxy did not reload after publishing the bundle for "
"%s. The superseded certificate files were moved to %s and "
"their certbot lineages were NOT deleted, so they can be "
"their certbot lineages were NOT retired, so they can be "
"restored by hand: %s",
primary, cert_backup_dir(),
primary, cert_retired_dir(),
[e['file'] for e in cleanup_summary['removed']])
raise
_delete_superseded_lineages(cleanup_summary)
# 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=primary,
bundle_names=set(names),
unreachable_names=unreachable_names,
keep_fingerprint=new_info.get('fingerprint') if new_info else None,
)
cleanup_summary['lineages'] = lineage_summary
not_after = new_info.get('not_after') if new_info else None
log_operation(
'request_ssl_bundle', True,
f'SSL bundle issued for {primary} covering {len(names)} names; '
f'cleaned up {len(cleanup_summary["removed"])} superseded lineage(s)'
f'SSL bundle {"issued" if renewed else "not yet due (existing certificate kept)"} '
f'for {primary} covering {len(names)} names; retired '
f'{len(cleanup_summary["removed"])} superseded pem(s) and '
f'{len(lineage_summary["retired"])} lineage(s)'
)
return jsonify({
'status': 'success',
'primary': primary,
'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()
if not_after else None,
'fingerprint': new_info.get('fingerprint') if new_info else None,
'cleanup': cleanup_summary,
'message': f'Bundled certificate obtained for {len(names)} names',
'message': (f'Bundled certificate obtained for {len(names)} names'
if renewed else
f'Existing certificate for {len(names)} names is not yet '
'due for renewal; nothing was issued'),
})
except Exception as e:
log_operation('request_ssl_bundle', False, str(e))