From ae129835ae68d859515b11368065c06347dc51f9 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 24 Aug 2026 10:27:48 +0200 Subject: [PATCH] feat(#3517): publish forgejo's metrics behind the gateway Forgejo can serve prometheus metrics but nothing turned them on, and turning them on alone would have published them: forgejo serves `/metrics` on its normal listener and the gateway vhost proxies `/` to that listener, so the existing catch-all would have carried the endpoint to anyone. The option is therefore one switch for both halves, and the `= /metrics` location is an exact match so it outranks that prefix. Authentication is the gateway's rather than forgejo's own `[metrics] TOKEN`: a scraper presents an audience-scoped authelia token which nginx checks via auth_request, so the swarm keeps one identity system instead of gaining a static bearer per service. The subrequest deliberately omits the `error_page 401 =302` that swarm-ui uses. That redirect sends a browser to a login page; a scraper would follow it and parse HTML as metrics. --- nix/host-modules/hive-forge/default.nix | 103 +++++++++++++++++++++--- 1 file changed, 94 insertions(+), 9 deletions(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 87d1a50d..007d30f0 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -305,6 +305,36 @@ in ''; }; + metricsEnable = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Serve forgejo's prometheus metrics and publish them, behind + authelia, at `https:///metrics`. + + Off by default. Forgejo's metrics carry repository, user and + request counts for the whole hive, so turning them on is a + disclosure decision an operator should make deliberately rather + than inherit from a collector appearing elsewhere in the swarm. + + ::: {.warning} + This option is deliberately **one** switch for two changes that + must never be made separately. Forgejo serves `/metrics` on its + normal HTTP listener, and the gateway vhost proxies `/` to that + listener — so enabling the endpoint *without* the protected + location would publish it through the existing catch-all, to + anyone. The `= /metrics` location added alongside is an exact + match and therefore wins over the `/` prefix. + ::: + + Authentication is the gateway's, not forgejo's own `[metrics] + TOKEN`: a scraper presents an audience-scoped authelia token and + nginx checks it via `auth_request`, so the swarm keeps one + identity system instead of a static bearer per service. + ''; + }; + mirrors = lib.mkOption { type = lib.types.listOf ( lib.types.submodule { @@ -450,15 +480,59 @@ in "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; - locations."/" = { - proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 1G; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ''; + # ⚠️ The metrics locations merge into THIS attrset, not into the + # vhost above it. `//` is shallow: merging at the vhost level + # would replace `locations` wholesale and silently drop the `/` + # proxy — a config that still renders and still starts. + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 1G; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ''; + }; + } + // lib.optionalAttrs cfg.metricsEnable { + # ⚠️ EXACT match, and that is what makes this safe. Forgejo + # serves `/metrics` on the same listener the `/` prefix above + # already proxies, so without a more specific location the + # endpoint would be public the moment it is enabled. + # `= /metrics` outranks the `/` prefix in nginx, so this + # location — and its auth — is the one that runs. + "= /metrics" = { + proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/metrics"; + extraConfig = '' + auth_request /__forge_metrics_authz; + ''; + }; + + # The subrequest. Same implementation and header set as + # `swarm-ui.nix` uses, for the same reason: `X-Original-URL` + # and `X-Original-Method` are what authelia's `auth-request` + # implementation reads. + # + # ⚠️ NO `error_page 401 =302` here, and its absence is the + # whole point. The swarm UI redirects an unauthenticated + # browser to a login page; a scraper handed that 302 would + # follow it and parse an HTML page as metrics. A machine-facing + # location lets the 401 reach the client unchanged. + "= /__forge_metrics_authz" = { + proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + extraConfig = '' + internal; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; }; }; }; @@ -674,6 +748,17 @@ in DEFAULT_BRANCH = "main"; DEFAULT_PRIVATE = "private"; }; + # No `TOKEN` here on purpose. Forgejo can guard this itself + # with a static bearer, but the swarm authenticates the + # scraper at the gateway with an audience-scoped authelia + # token, so a second credential system per service would buy + # nothing and would be the one that stops getting rotated. + # + # Reachability is not decided here: forgejo serves this on + # its normal listener, which is loopback-only. What publishes + # it is the `= /metrics` vhost location, and that location is + # what authenticates it. + metrics.ENABLED = cfg.metricsEnable; # Repo migrations / pull-mirrors fetch from the source # URL *inside* Forgejo. hyperhive code is synced from # `localhost` (and the host LAN), which Forgejo's