diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index f207c627..abe84206 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -327,6 +327,13 @@ in ''; }; + # ⚠️ Forgejo strips trailing slashes before routing, so `/metrics/` + # reaches the same unauthenticated handler — and the exact match + # above does not take it, leaving it to the `/` catch-all. nginx + # merges slashes and decodes `%2F` before matching, so this one + # location also takes `/metrics//` and `/metrics%2F`. + "= /metrics/".return = "404"; + # 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` diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index c514460a..a5c5d760 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -84,7 +84,11 @@ let parts = builtins.match "https://([^/]+)(/.*)" url; target = { domain = builtins.elemAt parts 0; - resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}$" ]; + # ⚠️ Authelia matches `resources` against the cleaned path PLUS the + # query string, while nginx's `= ` location ignores the query. + # A bare `^$` therefore misses `?x`, which the location + # still serves, and that request falls through to `default_policy`. + resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ]; }; in if parts == null then diff --git a/nix/module-eval/swarm-otel-identity.nix b/nix/module-eval/swarm-otel-identity.nix index 6d22d213..bf666f9f 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -107,8 +107,10 @@ let forgeMetricsUrl = "https://forge.t.local/metrics"; autheliaMetricsUrl = "https://auth.t.local/metrics"; - # The policy authelia applies to `subject` requesting `https://`: - # the first rule whose domain, resources and subject all match, else + # The policy authelia applies to `subject` requesting `https://`, + # where `path` is what authelia matches `resources` against: the cleaned path + # plus `?` when there is one. + # It is 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. @@ -214,7 +216,7 @@ let && builtins.elem forgeMetricsUrl client.audience && builtins.elem { domain = "forge.t.local"; - resources = [ "^/metrics$" ]; + resources = [ "^/metrics([?].*)?$" ]; policy = "one_factor"; subject = [ "oauth2:client:${h.swarm.otel.clientId}" ]; } rules; @@ -251,17 +253,30 @@ let } { # 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`. + # other authenticated subject, with or without a query string. Read + # through authelia's first-match semantics: a client-scoped rule alone + # lets an operator's session fall through to `default_policy = one_factor`, + # and so does a request the rule's regex does not match. 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; + targets = lib.concatMap ( + url: + let + t = builtins.match "https://([^/]+)(/.*)" url; + in + map + (q: [ + (builtins.elemAt t 0) + "${builtins.elemAt t 1}${q}" + ]) + [ + "" + "?x=1" + ] + ) (lib.attrValues h.swarm.otel.publishedScrapeTargets); policy = subject: t: autheliaPolicy autheliaOnly subject (builtins.elemAt t 0) (builtins.elemAt t 1); in @@ -269,10 +284,70 @@ let autheliaMetricsUrl forgeMetricsUrl ] + && lib.length targets == 4 && 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; } + { + # Which request forms reach `= /metrics`, and whether the rule's regex + # matches the path authelia derives from each. `nginx` records the + # location each form lands in (measured against a stand-alone nginx); + # `path` is authelia's cleaned path plus query for that form. The + # invariant: every form the gated location serves is matched by the rule, + # so none of them reaches `default_policy`. + name = "the metrics rule matches every request form the gated location serves"; + ok = + let + rules = + autheliaOnly.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules; + metricsRules = lib.filter (r: r ? resources) rules; + re = lib.head (lib.head metricsRules).resources; + table = [ + { + request = "/metrics"; + nginx = "= /metrics"; + path = "/metrics"; + } + { + request = "/metrics?x=1"; + nginx = "= /metrics"; + path = "/metrics?x=1"; + } + { + request = "//metrics"; + nginx = "= /metrics"; + path = "/metrics"; + } + { + request = "/metrics/"; + nginx = "= /metrics/ (forge: 404) or /"; + path = "/metrics/"; + } + { + request = "/metricsx"; + nginx = "/"; + path = "/metricsx"; + } + ]; + matched = row: builtins.match re row.path != null; + in + lib.length metricsRules == 4 + && lib.all (r: r.resources == [ re ]) metricsRules + && re == "^/metrics([?].*)?$" + && lib.all (row: matched row == (row.nginx == "= /metrics")) table; + } + { + # Forgejo strips trailing slashes before routing, so `/metrics/` reaches + # its metrics handler unless nginx refuses it first. + name = "the forge refuses /metrics/ rather than proxying it to forgejo's metrics"; + ok = + let + locs = + allLocal.services.nginx.virtualHosts.${allLocal.services.hyperhive.swarm.forge.domain}.locations; + in + (locs."= /metrics/".return or null) == "404"; + } { # Single host: authelia is scraped once, by its published name. A # loopback job beside it would scrape the same listener twice — and share