From 31fa891a4ebfd68f57018fdbc1278742f80715cd Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 19 Aug 2026 21:45:07 +0200 Subject: [PATCH 1/2] feat(swarm-authelia): expose prometheus metrics and declare the scrape target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint was off, so nothing reported on the swarm's own SSO. Enabling it alone would have added no data — the scraper that reads it only landed with the swarm-tier prometheus receiver. Loopback only, like the main listener and for a stronger reason: this endpoint authenticates nothing and reports request volumes and outcomes for every login on the swarm. metricsPort is an option rather than a literal because every swarm container shares the host netns, so two services picking the same port do not conflict at build time — one loses at runtime with nothing in any log. 9959 is upstream's default and is unclaimed across nix/. --- nix/host-modules/swarm-authelia.nix | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 7d4dfd6f..09c7c5f5 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -339,6 +339,26 @@ in ''; }; + metricsPort = lib.mkOption { + type = lib.types.port; + default = 9959; + description = '' + TCP port authelia serves its Prometheus metrics on, bound to + loopback. Upstream's default, kept so an operator reading + authelia's documentation finds what they expect. + + A separate port from {option}`port` because it is a separate + listener with a different audience: the main one is proxied by + the gateway and reachable from the swarm, this one is scraped by + the collector on this host and by nothing else. + + ⚠️ Every swarm container shares the host network namespace, so + two services defaulting to the same port do not conflict at build + time — one simply loses at runtime, with nothing in any log. Check + a new value against the others before changing this. + ''; + }; + domain = lib.mkOption { type = lib.types.str; # Under the SWARM domain, like the forge and matrix: a swarm has one @@ -804,6 +824,12 @@ in # bridge at that wrong answer. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # Declared here rather than in the collector's module, per the option's + # own rule: an entry exists only where the service that named it runs, + # which is what keeps scraper and target on one host by construction + # rather than by the all-local deployment happening to co-locate them. + services.hyperhive.swarm.otel.scrapeTargets.authelia = "127.0.0.1:${toString cfg.metricsPort}"; + # This swarm-ui quick-links entry, same guard as the vhost/DNS name # above (only the host actually running the container claims it — # see `services.hyperhive.swarm.controller.links`'s description for @@ -1110,6 +1136,16 @@ in }; log.level = "info"; + # Prometheus exposition for the swarm collector to scrape. + # 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. + telemetry.metrics = { + enabled = true; + address = "tcp://127.0.0.1:${toString cfg.metricsPort}"; + }; + # `watch` is load-bearing, not a convenience: authelia reads # this file once at startup, and `swarm-authelia-bridge` writes # it to create agent identities while being unable to restart From 541dd298206648212d8a02e22bc9f81f9a926e05 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 24 Aug 2026 12:30:52 +0200 Subject: [PATCH 2/2] fix(swarm-authelia): only declare the scrape target where a collector reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the option's rule keeps scraper and target on one host by construction. It does not. Both services default from enableRequiredServices via mkDefault, which is an invitation to override rather than a guarantee, so co-location is a property of the auto-deployed topology and not of the module. Gating the target on the collector's own enable makes the loopback address honest: a host running authelia without a collector no longer declares a target nothing can read. That absence was the part worth fixing, because it is silent — no error, no metrics, nothing in a log to notice. This does not make authelia scrapeable from another host. That needs the endpoint published under a name with a certificate and an audience, which is separate work; the option's docs now say so where someone splitting the two would read it. --- nix/host-modules/swarm-authelia.nix | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 09c7c5f5..b3c9a027 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -43,6 +43,7 @@ let swarmDomain = hyperhiveCfg.swarm.domain; uiCfg = hyperhiveCfg.swarm.ui; forgeCfg = hyperhiveCfg.swarm.forge; + otelCfg = hyperhiveCfg.swarm.otel; # Group an account must hold to reach operator-only surfaces. Named # here because this module writes the rule that enforces it and @@ -356,6 +357,16 @@ in two services defaulting to the same port do not conflict at build time — one simply loses at runtime, with nothing in any log. Check a new value against the others before changing this. + + ::: {.note} + Loopback means this endpoint is only reachable by a collector on + the *same host*, so the scrape target is declared only when one is + enabled here. Run the swarm's collector elsewhere and authelia's + metrics are simply not collected — no error, and nothing in a log + to say so. Making them reachable across hosts is a different piece + of work: the endpoint would have to be published under a name, + with a certificate and an audience. + ::: ''; }; @@ -825,10 +836,24 @@ in services.hyperhive.gateway.localNames = [ cfg.domain ]; # Declared here rather than in the collector's module, per the option's - # own rule: an entry exists only where the service that named it runs, - # which is what keeps scraper and target on one host by construction - # rather than by the all-local deployment happening to co-locate them. - services.hyperhive.swarm.otel.scrapeTargets.authelia = "127.0.0.1:${toString cfg.metricsPort}"; + # own rule: an entry exists only where the service that named it runs. + # + # Gated on the collector's `enable` as well, and that second condition is + # what makes the loopback address honest. Both services default from + # `swarm.enableRequiredServices` — but `mkDefault` is an invitation to + # override, not a guarantee, so "they are on the same host" is a property + # of the auto-deployed topology rather than of the module. Without this + # gate, a host running authelia and no collector would declare a target + # nothing can read, and the absence would be silent: no error, no metrics, + # nothing to notice. + # + # It does not make authelia scrapeable from ANOTHER host — that needs the + # endpoint published under a name with a cert and an audience, which is a + # different piece of work. This only stops the config asserting a + # collection that is not happening. + services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf otelCfg.enable { + authelia = "127.0.0.1:${toString cfg.metricsPort}"; + }; # This swarm-ui quick-links entry, same guard as the vhost/DNS name # above (only the host actually running the container claims it —