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.
This commit is contained in:
atlas 2026-08-24 10:27:48 +02:00 committed by mara
commit ae129835ae

View file

@ -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://<domain>/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