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
+1 -1
View File
@@ -103,7 +103,7 @@ The full Traefik page at `/plugins/traefik/` reads top to bottom, for the time r
- **Slow responses** compare p95 with p50. When p95 is 4× p50 or more, a few requests are slow (one endpoint or a heavy page). When p50 is close behind, the whole app is slow.
- **Next:** a fault links to the swarm service behind the router (its tasks and logs) when Steward can match the name ("codex@swarm" → `codex_app`, "radarr@swarm" → `arr_radarr`), otherwise to Docker → Swarm. Traffic links to Settings → Thresholds.
4. **Traefik's own figures:** when its config last reloaded, open connections, memory.
5. **The healthy routers,** folded into one table, then the routers Traefik no longer reports, then TLS certificates by soonest expiry, then Traffic Origin when the access log is on.
5. **The healthy routers,** folded into one table, then the routers Traefik no longer reports, then TLS certificates by soonest expiry, then Traffic Origin when the access log is on. A certificate Traefik has renewed is listed as old, not by its expiry, though a Traefik that hasn't restarted since keeps reporting it: one whose every name a later-expiring certificate covers.
A router is graded on its last 15 minutes against Settings → Thresholds. When a problem started is read from the router's history over the range.
+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]:
@@ -175,6 +175,19 @@ def test_a_certificate_traefik_stopped_reporting_is_old_not_current():
assert current_certs([], 60) == ([], [])
def test_a_certificate_a_later_one_renewed_is_old_though_traefik_still_reports_it():
"""codex.fabledsword.com on 2026-10-15: the replaced certificate (21 days
left) still reported beside its renewal (81 days), #5864."""
from plugins.traefik.routes import current_certs
old_one, renewal = _cert("1", "codex.example", 21), _cert("2", "codex.example", 81)
wider = _cert("3", "a.example", 90, sans="a.example,b.example")
narrower = _cert("4", "b.example", 30)
unrelated = _cert("5", "c.example", 10)
served, old = current_certs([old_one, renewal, wider, narrower, unrelated], 60)
assert {c.serial for c in served} == {"2", "3", "5"}
assert {c.serial for c in old} == {"1", "4"}
def test_each_router_gets_the_soonest_certificate_for_its_hosts():
from plugins.traefik.routes import certs_by_router
now = datetime.now(timezone.utc)