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

@ -101,7 +101,30 @@ let
# authelia with neither the forge nor the collector beside it.
inherit (shared) autheliaOnly;
# Every swarm service on one host, the collector and authelia included.
inherit (shared) allLocal;
forgeMetricsUrl = "https://forge.t.local/metrics";
autheliaMetricsUrl = "https://auth.t.local/metrics";
# The policy authelia applies to `subject` requesting `https://<domain><path>`:
# the first rule whose domain, resources and subject all match, else
# `default_policy`. ⚠️ A rule with a `subject` list does NOT match a subject
# outside it — authelia moves on to the next rule — which is the semantics a
# check of the rule's own fields cannot see.
autheliaPolicy =
m: subject: domain: path:
let
ac = m.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control;
matches =
r:
r.domain == domain
&& lib.any (re: builtins.match re path != null) (r.resources or [ ".*" ])
&& (!(r ? subject) || builtins.elem subject r.subject);
hit = lib.findFirst matches null ac.rules;
in
if hit == null then ac.default_policy else hit.policy;
cases = [
{
# The collector's non-assertion, the deliberate mirror of Grafana's
@ -196,6 +219,79 @@ let
subject = [ "oauth2:client:${h.swarm.otel.clientId}" ];
} rules;
}
{
# Authelia's half of the same move: its target is published by name, so
# a collector anywhere in the swarm has a job for it.
name = "a collector on a host without authelia scrapes authelia by name";
ok =
let
s = otelSettings otelRemoteAuthelia;
job = lib.findFirst (j: j.job_name == "authelia") null s.receivers.prometheus.config.scrape_configs;
in
!otelRemoteAuthelia.services.hyperhive.deploy.authelia.enable
&& job != null
&& job.static_configs == [ { targets = [ "auth.t.local" ]; } ]
&& job.metrics_path == "/metrics"
&& job.oauth2.endpoint_params.audience == autheliaMetricsUrl;
}
{
# The location the collector's job requests: gated, and proxying to the
# loopback listener that has no authentication of its own.
name = "authelia's vhost serves /metrics only behind the collector's auth subrequest";
ok =
let
h = autheliaOnly.services.hyperhive;
locs = autheliaOnly.services.nginx.virtualHosts.${h.swarm.authelia.domain}.locations;
authz = locs."= /__authelia_metrics_authz";
in
locs."= /metrics".proxyPass == "http://127.0.0.1:${toString h.swarm.authelia.metricsPort}/metrics"
&& lib.hasInfix "auth_request /__authelia_metrics_authz;" locs."= /metrics".extraConfig
&& authz.proxyPass == "https://${h.swarm.authelia.domain}/api/authz/auth-request"
&& lib.hasInfix "internal;" authz.extraConfig;
}
{
# Every published target admits the collector's client and refuses any
# other authenticated subject. Read through authelia's first-match
# semantics: a client-scoped rule alone lets an operator's session fall
# through to `default_policy = one_factor`.
name = "every published metrics URL admits the collector and refuses a plain session";
ok =
let
h = autheliaOnly.services.hyperhive;
collector = "oauth2:client:${h.swarm.otel.clientId}";
targets = lib.mapAttrsToList (
_: url: builtins.match "https://([^/]+)(/.*)" url
) h.swarm.otel.publishedScrapeTargets;
policy =
subject: t: autheliaPolicy autheliaOnly subject (builtins.elemAt t 0) (builtins.elemAt t 1);
in
lib.sort lib.lessThan (lib.attrValues h.swarm.otel.publishedScrapeTargets) == [
autheliaMetricsUrl
forgeMetricsUrl
]
&& lib.all (t: policy collector t == "one_factor") targets
&& lib.all (t: policy "user:someone" t == "deny") targets
&& lib.all (t: policy "group:admins" t == "deny") targets;
}
{
# Single host: authelia is scraped once, by its published name. A
# loopback job beside it would scrape the same listener twice — and share
# its job name, which ./swarm-otel.nix refuses.
name = "a host running authelia and the collector scrapes authelia once, by name";
ok =
let
o = allLocal.services.hyperhive.swarm.otel;
jobs =
lib.filter (j: j.job_name == "authelia")
(otelSettings allLocal).receivers.prometheus.config.scrape_configs;
in
allLocal.services.hyperhive.deploy.authelia.enable
&& allLocal.services.hyperhive.deploy.swarm-otel.enable
&& !(o.scrapeTargets ? authelia)
&& (o.publishedScrapeTargets.authelia or null) == autheliaMetricsUrl
&& lib.length jobs == 1
&& (lib.head jobs).static_configs == [ { targets = [ "auth.t.local" ]; } ];
}
{
# Read against the roster the fixture declares rather than against
# names spelled here: an arm naming `h1` passes on a single-hive