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