fix(traefik): a renewed certificate is old, and list_certificates is bounded (#5864)

A Traefik that hasn't restarted keeps reporting the certificate it
replaced, so codex.fabledsword.com's old one (21 days left) was listed
beside its renewal (81 days) and would have raised an expiry warning on
2026-10-22. A reported certificate whose every name a later-expiring
one covers is now old, on the page, the widget, the overview and MCP.

list_certificates takes `limit` (default 100) with total/truncated, and
says how many served certificates no HTTP router uses (`unrouted`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:00:19 -04:00
co-authored by Claude Opus 5.5
parent 622addc09e
commit 398312b8be
4 changed files with 39 additions and 10 deletions
+14 -6
View File
@@ -189,15 +189,20 @@ async def get_router(router: str, range: str = "24h") -> dict:
return out
async def list_certificates(include_old: bool = False) -> dict:
async def list_certificates(include_old: bool = False, limit: int = 100) -> dict:
"""The TLS certificates Traefik serves, soonest expiry first: names
covered, expiry, days left, and the routers whose hosts each covers
(null when Traefik's API couldn't say which hosts a router serves).
(null when Traefik's API couldn't say which hosts a router serves; []
when no current HTTP router serves its names, as for a certificate
Traefik holds for a retired service, or one only a TCP router uses).
include_old: also list certificates Traefik stopped serving (renewed or
retired), which Steward keeps on record.
retired; a renewed one counts as old though Traefik still reports
it), which Steward keeps on record.
limit: certificates to return, default 100, max 500.
`warn_days` / `crit_days` are the Settings → Thresholds cutoffs
get_overview reports a served certificate at.
get_overview reports a served certificate at. `unrouted` counts the
served certificates with no router.
"""
thresholds, scrape = _cfg()
now = now_utc()
@@ -215,8 +220,11 @@ async def list_certificates(include_old: bool = False) -> dict:
return sorted({n for n in names for h in page.router_hosts(n, domains) if covers(covered, h)})
tls = thresholds.get("tls_days", {})
out = {"certificates": [_cert_view(c, now, routers_for(c)) for c in served],
"warn_days": tls.get("warn", 14), "crit_days": tls.get("crit", 3)}
views = [_cert_view(c, now, routers_for(c)) for c in served]
out = bounded(views, clamp_limit(limit, 100, 500), key="certificates",
more="Pass a larger `limit`.")
out.update({"unrouted": sum(1 for v in views if v["routers"] == []),
"warn_days": tls.get("warn", 14), "crit_days": tls.get("crit", 3)})
if include_old:
out["old"] = [_cert_view(c, now, None) | {"last_seen_at": iso(c.scraped_at)} for c in old[:50]]
return out
+11 -3
View File
@@ -307,14 +307,22 @@ def current_certs(certs, scrape_seconds: int) -> tuple[list, list]:
"""(served, old): a certificate is served while Traefik still reports it.
Rows are upserted by serial and never deleted, so a renewed or retired
certificate lingers; one missing from the last GONE_AFTER_SCRAPES
scrapes is old (#5254)."""
scrapes is old (#5254). So is one renewed: a Traefik that hasn't
restarted since keeps reporting the certificate it replaced, so one
whose every name a later-expiring reported certificate covers is old
too (#5864); otherwise it reads as expiring while the renewal serves."""
certs = list(certs)
if not certs:
return [], []
newest = max(c.scraped_at for c in certs)
cutoff = newest - timedelta(seconds=GONE_AFTER_SCRAPES * max(int(scrape_seconds), 1))
return ([c for c in certs if c.scraped_at >= cutoff],
sorted((c for c in certs if c.scraped_at < cutoff), key=lambda c: c.scraped_at, reverse=True))
reported = [c for c in certs if c.scraped_at >= cutoff]
names = {c.serial: cert_names(c.cn, c.sans) for c in reported}
renewed = {c.serial for c in reported for n in reported
if n.serial != c.serial and n.not_after > c.not_after and names[c.serial] <= names[n.serial]}
return ([c for c in reported if c.serial not in renewed],
sorted((c for c in certs if c.scraped_at < cutoff or c.serial in renewed),
key=lambda c: c.scraped_at, reverse=True))
def router_hosts(name: str, domains: dict[str, list[str]]) -> list[str]: