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:
parent
1219091788
commit
393e84afd0
4 changed files with 194 additions and 42 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue