From 398312b8bec189dfcb8c61e52c372d9b7ed546c3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 15 Oct 2026 16:00:19 -0400 Subject: [PATCH] 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 --- docs/plugins/traefik.md | 2 +- plugins/traefik/mcp_tools.py | 20 ++++++++++++++------ plugins/traefik/routes.py | 14 +++++++++++--- tests/plugins/traefik/test_traefik_page.py | 13 +++++++++++++ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/plugins/traefik.md b/docs/plugins/traefik.md index 0cb8f3e..1cf41a0 100644 --- a/docs/plugins/traefik.md +++ b/docs/plugins/traefik.md @@ -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. diff --git a/plugins/traefik/mcp_tools.py b/plugins/traefik/mcp_tools.py index 0c25d71..ddb6e02 100644 --- a/plugins/traefik/mcp_tools.py +++ b/plugins/traefik/mcp_tools.py @@ -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 diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index bee60c4..d0c6245 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -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]: diff --git a/tests/plugins/traefik/test_traefik_page.py b/tests/plugins/traefik/test_traefik_page.py index 44fc139..4e9d007 100644 --- a/tests/plugins/traefik/test_traefik_page.py +++ b/tests/plugins/traefik/test_traefik_page.py @@ -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)