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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user