Watch
0
0
Fork
You've already forked hyperhive
0

swarm-otel: scrape authelia through its published name

Authelia's metrics were scraped over loopback, declared only on a host
running both authelia and the collector, so a split deploy collected none.
Its target is now declared on every host as
`https://<swarm.authelia.domain>/metrics`, next to forgejo's, and authelia's
vhost serves `= /metrics` behind the same collector auth subrequest as the
forge's. The loopback entry is gone, so a single host still scrapes
authelia once, now by name.

The forge-only `metricsRule` becomes `metricsRules`, derived from every
`publishedScrapeTargets` entry. Each target gets the collector-scoped
`one_factor` rule followed by an unconditional `deny`. The `deny` is
needed because authelia 4.39 skips a rule whose `subject` does not
match and moves on. With nothing after the collector rule, any other
authenticated session reached `default_policy = one_factor` and was
admitted. That was already true of the forge's `/metrics`, and this
change closes it there too.

Part of #5205
This commit is contained in:
atlas 2026-10-13 10:36:15 +02:00 • committed by mara
commit 393e84afd0
4 changed files with 194 additions and 42 deletions

View file

@ -61,27 +61,44 @@ let
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
# The forge's metrics endpoint: deny until a collector exists to allow.
# Every published metrics endpoint: the collector's client, and nobody else.
#
# `deny` is not a placeholder, it is the protection. The endpoint is
# always served (a swarm-integrated forge always has metrics) and
# `default_policy` is `one_factor`, which means *any* authenticated
# subject — every operator today, every agent once they hold authelia
# accounts. Nor does the audience stand between a browser session and
# this data: `authn_strategies` on the authz endpoint also accepts
# `CookieSession`, and a cookie carries no audience at all.
metricsRule = {
domain = hyperhiveCfg.swarm.forge.domain;
resources = [ "^/metrics$" ];
}
// (
if collectorRegistered then
{
policy = "one_factor";
subject = [ "oauth2:client:${collectorClientId}" ];
}
else
{ policy = "deny"; }
# The `deny` is the protection, and it has to be a rule of its own. A rule
# with a `subject` does not match a subject outside it: authelia skips it and
# moves on, and with nothing after it lands on `default_policy`, which is
# `one_factor` — *any* authenticated subject, every operator today, every
# agent once they hold authelia accounts. Nor does the audience stand
# between a browser session and this data: `authn_strategies` on the authz
# endpoint also accepts `CookieSession`, and a cookie carries no audience.
# Until a collector is registered, the `deny` is the only rule.
#
# One pair per `swarm.otel.publishedScrapeTargets` entry, read off the same
# URL the collector requests and its token is minted for, so a target
# cannot be published without the rules that confine it to the collector.
metricsRules = lib.concatLists (
lib.mapAttrsToList (
job: url:
let
# The split `parsePublished` in ./swarm-otel.nix makes for the scrape
# job; the two must agree on what the URL's host and path are.
parts = builtins.match "https://([^/]+)(/.*)" url;
target = {
domain = builtins.elemAt parts 0;
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}$" ];
};
in
if parts == null then
throw "swarm.otel.publishedScrapeTargets.${job} = \"${url}\" is not an https URL with a path; authelia's access rule for it cannot be derived."
else
lib.optional collectorRegistered (
target
// {
policy = "one_factor";
subject = [ "oauth2:client:${collectorClientId}" ];
}
)
++ [ (target // { policy = "deny"; }) ]
) config.services.hyperhive.swarm.otel.publishedScrapeTargets
);
# Upstream's `services.authelia.instances.<name>` derives the unit,
@ -779,6 +796,48 @@ in
locations."/bridge/" = {
proxyPass = "http://127.0.0.1:${toString cfg.bridgePort}/";
};
# Authelia's own metrics, for the swarm collector wherever it runs.
# The listener (`metricsPort`) is loopback-only and unauthenticated;
# this location is the only way in from off the machine. `= /metrics`
# outranks the `/` prefix, so its auth is the one that runs.
#
# The collector requests exactly this URL, declared as
# `publishedScrapeTargets.authelia` in ./swarm-otel-service.nix; the two
# must agree, since that string is also the token's audience.
locations."= /metrics" = {
proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics";
extraConfig = ''
auth_request /__authelia_metrics_authz;
'';
};
# The subrequest, same shape as the forge's `= /__forge_metrics_authz`
# (./hive-forge/default.nix), and machine-facing for the same reason: no
# `error_page 401 =302`, so a scraper gets the 401 rather than a login
# page parsed as metrics.
#
# Authelia authorising a request for itself does not loop: the
# subrequest lands on `/api/` above, which carries no `auth_request`.
# An authelia that is down answers the subrequest 502, which
# `auth_request` turns into a 500 for the scrape — closed, not open.
locations."= /__authelia_metrics_authz" = {
proxyPass = "https://${cfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which would
# override verifiedProxyTo's — see hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo cfg.domain}
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;
'';
};
};
# Order the container after the host CA generator, so the bind source
@ -1038,7 +1097,8 @@ in
# Loopback only, like the main listener above and for a
# stronger reason: this endpoint has no authentication of its
# own, and it reports request volumes and outcomes for every
# SSO login on the swarm.
# SSO login on the swarm. The collector reaches it through the
# vhost's gated `= /metrics` location.
telemetry.metrics = {
enabled = true;
address = "tcp://127.0.0.1:${toString cfg.metricsPort}";
@ -1070,13 +1130,13 @@ in
access_control = {
default_policy = "one_factor";
# ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule.
# The metrics rule is listed first so it cannot be shadowed
# by a broader domain rule added later.
# The metrics rules are listed first so they cannot be
# shadowed by a broader domain rule added later.
rules =
# Denied or client-scoped depending on whether a
# collector is registered — see `metricsRule` above,
# which is where the reasoning for both halves lives.
[ metricsRule ]
# The collector's client where one is registered, then
# `deny` — see `metricsRules` above, which is where the
# reasoning for both halves lives.
metricsRules
# Also guarded on the domain being set: without it a null
# apex would render a rule matching the string "null".
++ lib.optional (deployCfg.swarm-controller.enable && swarmDomain != null) {