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)