diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index 5a20473f..d0203c15 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -52,7 +52,7 @@ The gateway always terminates TLS. Which certificate it serves depends on what y `tls.certDir` together with `tls.acme.enable` fails an assertion — pick one. -⚠️ One vhost class has no TLS: a vhost bound to loopback for a local consumer. `grafana-metrics` (`nix/host-modules/swarm-grafana.nix`) is the one in the tree: it listens on `127.0.0.1` only and serves `= /metrics` from grafana's unix socket for this host's collector. Everything with a routable name follows the table above. +⚠️ One vhost class has no TLS: a vhost bound to loopback for a local consumer. `grafana-metrics` (`nix/host-modules/swarm-grafana.nix`) is the one in the tree: it listens on `127.0.0.1` only and serves `= /metrics` from grafana's unix socket for this host's collector. The published `grafana.` vhost answers `/metrics` with 403. Everything with a routable name follows the table above. ### ACME / Let's Encrypt (`tls.acme`) diff --git a/nix/host-modules/swarm-grafana-service.nix b/nix/host-modules/swarm-grafana-service.nix index 15e95047..131c1cb0 100644 --- a/nix/host-modules/swarm-grafana-service.nix +++ b/nix/host-modules/swarm-grafana-service.nix @@ -72,7 +72,9 @@ in {option}`services.hyperhive.swarm.otel.scrapeTargets` has: those targets are trusted by *proximity* rather than by credential. Deliberately **not** the published `grafana.` vhost, which - would put an authorization decision in front of a scrape. + would put an authorization decision in front of a scrape: that vhost + answers `/metrics` with 403, because Grafana serves it without + authentication. The number itself is arbitrary and free today; `state/eval-port-collisions.sh` is what keeps it that way, since a diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index db32cdfd..6e70f69a 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -458,6 +458,13 @@ in services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; + # Grafana serves its Prometheus exposition to anyone, signed in or not + # (`metrics.enabled` below, no `basic_auth_*`), so the published name + # refuses it; the scrape goes through the loopback `grafana-metrics` + # vhost instead. `=` is enough: nginx matches the decoded, normalised + # path, so `//metrics`, `/./metrics`, `/%6detrics` and `/metrics?x` all + # land here, and Grafana itself answers `/metrics/` with a login redirect. + locations."= /metrics".return = "403"; locations."/" = { proxyPass = "http://unix:${socketPath}:/"; proxyWebsockets = true; @@ -484,7 +491,8 @@ in # ⚠️ `= /metrics` is an EXACT match, not a prefix: it is the difference # between exposing one endpoint and re-serving the whole of Grafana on an # unauthenticated port. Widening it would be a silent authorization bypass, - # since nothing else about this block would have to change. + # since nothing else about this block would have to change. The published + # vhost above refuses `/metrics`, so this listener is the only way to it. services.nginx.virtualHosts."grafana-metrics" = { listen = [ { diff --git a/nix/module-eval/grafana.nix b/nix/module-eval/grafana.nix index 5de0206e..c1319f1b 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -121,6 +121,21 @@ let grafanaOldPath.services.nginx.virtualHosts."grafana-metrics".locations."= /metrics".proxyPass == "http://unix:/run/swarm-grafana/grafana.sock:/metrics"; } + { + # Grafana serves `/metrics` unauthenticated, so the published name must + # answer it itself; the scrape reads the loopback vhost above instead. + # `=` is what catches the normalised variants and still leaves the rest + # of Grafana proxied. + name = "grafana's published vhost refuses /metrics and still proxies everything else"; + ok = + let + g = grafanaOldPath.services.hyperhive.swarm.grafana; + locs = grafanaOldPath.services.nginx.virtualHosts.${g.domain}.locations; + in + locs."= /metrics".return == "403" + && locs."= /metrics".proxyPass == null + && lib.hasPrefix "http://unix:" locs."/".proxyPass; + } { # This fixture enables grafana and NOT authelia, so the login-form toggle # has to follow "grafana requires SSO" rather than "both services are on