feat(traefik): Routers widget shows current routers only, one header row, certs on the routers they serve (#5254)
#5254 "Traefik — Routers: current routers only, header row, certs on the router they serve". - Router hosts come from Traefik's API (/api/http|tcp/routers), read best-effort on each scrape. An optional api_url overrides it; the default is /api on the metrics host. A certificate's expiry then shows in a TLS column on the router whose host it covers. An expiring one makes that router a problem, graded by Settings → Thresholds. - Only current: routers and certificates must have appeared in the last three scrapes. Certificates are upserted by serial and never deleted, so renewed or retired ones lingered on the widget. - Widget order: verdict, problem routers with the reason, then the busiest healthy routers under one header (Router / Req/s / Bandwidth / p95 / TLS). A missing value shows "—". - If the API is unreachable, expiring certificates are listed after the routers, not above them. - The page splits certificates into served (with the routers using each, plus SANs) and "No longer served". - Latest row per router: an index walk (recursive CTE + LATERAL) replaces DISTINCT plus a query per router. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -274,3 +274,69 @@ async def fetch_metrics(metrics_url: str) -> ParsedMetrics:
|
||||
response = await client.get(metrics_url)
|
||||
response.raise_for_status()
|
||||
return parse_prometheus(response.text)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Router domains (Traefik API)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The metrics name each router but not the hosts it answers for, so which
|
||||
# certificate serves which router is only knowable from Traefik's API. Read
|
||||
# once per scrape; when the API isn't reachable, routers simply have no
|
||||
# domains and certificates are shown on their own (#5254).
|
||||
|
||||
_HOST_MATCHER = re.compile(r"Host(?:SNI)?\(([^)]*)\)")
|
||||
_QUOTED = re.compile(r"[`\"']([^`\"']+)[`\"']")
|
||||
|
||||
|
||||
def rule_hosts(rule: str) -> list[str]:
|
||||
"""The host names a router rule matches: Host(`a`, `b`) and HostSNI(`c`)
|
||||
give ["a", "b", "c"]; HostRegexp and path-only rules give nothing."""
|
||||
hosts: list[str] = []
|
||||
for args in _HOST_MATCHER.findall(rule or ""):
|
||||
hosts.extend(h.strip().lower() for h in _QUOTED.findall(args) if h.strip() != "*")
|
||||
return hosts
|
||||
|
||||
|
||||
def api_base(metrics_url: str, api_url: str = "") -> str:
|
||||
"""The Traefik API's base: the configured api_url, else /api on the
|
||||
metrics URL's own host (the default "traefik" entrypoint serves both)."""
|
||||
if api_url:
|
||||
return api_url.rstrip("/")
|
||||
parts = httpx.URL(metrics_url)
|
||||
return str(parts.copy_with(path="/api", query=None, fragment=None)).rstrip("/")
|
||||
|
||||
|
||||
async def fetch_router_domains(base: str) -> dict[str, list[str]]:
|
||||
"""{router name: host names} for every enabled HTTP and TCP router."""
|
||||
out: dict[str, list[str]] = {}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for kind in ("http", "tcp"):
|
||||
response = await client.get(f"{base}/{kind}/routers", params={"per_page": 1000})
|
||||
if kind == "tcp" and response.status_code == 404:
|
||||
continue
|
||||
response.raise_for_status()
|
||||
for r in response.json():
|
||||
if r.get("status", "enabled") != "enabled" or not r.get("name"):
|
||||
continue
|
||||
hosts = rule_hosts(r.get("rule", ""))
|
||||
if hosts:
|
||||
out.setdefault(r["name"], []).extend(hosts)
|
||||
return out
|
||||
|
||||
|
||||
def cert_names(cn: str | None, sans: str | None) -> set[str]:
|
||||
"""Every name a certificate covers: its CN and SANs, lower-cased."""
|
||||
names = {n.strip().lower() for n in (sans or "").split(",") if n.strip()}
|
||||
if cn:
|
||||
names.add(cn.strip().lower())
|
||||
return names
|
||||
|
||||
|
||||
def covers(names: set[str], host: str) -> bool:
|
||||
"""Whether a certificate with these names is valid for host, a wildcard
|
||||
"*.example.com" covering one label: "a.example.com", not "a.b.example.com"."""
|
||||
if host in names:
|
||||
return True
|
||||
head, _, rest = host.partition(".")
|
||||
return bool(rest) and f"*.{rest}" in names
|
||||
|
||||
Reference in New Issue
Block a user