diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 87d1a50d..6775ee23 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -450,15 +450,54 @@ in "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; - locations."/" = { - proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 1G; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ''; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 1G; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ''; + }; + + # ⚠️ EXACT match, and that is what makes this safe. Forgejo + # serves `/metrics` on the same listener the `/` prefix above + # already proxies, so without a more specific location the + # endpoint would ride that catch-all to anyone. `= /metrics` + # outranks the `/` prefix in nginx, so this location — and its + # auth — is the one that runs. + "= /metrics" = { + proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/metrics"; + extraConfig = '' + auth_request /__forge_metrics_authz; + ''; + }; + + # 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` + # implementation reads. + # + # ⚠️ NO `error_page 401 =302` here, and its absence is the + # whole point. The swarm UI redirects an unauthenticated + # browser to a login page; a scraper handed that 302 would + # follow it and parse an HTML page as metrics. A machine-facing + # location lets the 401 reach the client unchanged. + "= /__forge_metrics_authz" = { + proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + extraConfig = '' + internal; + 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; + ''; + }; }; }; }; @@ -674,6 +713,20 @@ in DEFAULT_BRANCH = "main"; DEFAULT_PRIVATE = "private"; }; + # Not an option: a swarm-integrated, auto-deployed forge + # always has metrics. Tied to `behindGateway` because that + # IS the swarm-integrated shape — it is the condition under + # which the protected `= /metrics` location below exists. + # Serving the endpoint without that location would put it on + # a listener `openFirewall` can expose, with nothing in + # front of it. + # + # No `TOKEN` here on purpose. Forgejo can guard this itself + # with a static bearer, but the swarm authenticates the + # scraper at the gateway, so a second credential system per + # service would buy nothing and would be the one that stops + # getting rotated. + metrics.ENABLED = cfg.behindGateway; # Repo migrations / pull-mirrors fetch from the source # URL *inside* Forgejo. hyperhive code is synced from # `localhost` (and the host LAN), which Forgejo's diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 6a419cf4..7d4dfd6f 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -42,6 +42,7 @@ let hyperhiveDomain = hyperhiveCfg.domain; swarmDomain = hyperhiveCfg.swarm.domain; uiCfg = hyperhiveCfg.swarm.ui; + forgeCfg = hyperhiveCfg.swarm.forge; # Group an account must hold to reach operator-only surfaces. Named # here because this module writes the rule that enforces it and @@ -1078,6 +1079,35 @@ in settings = { theme = "dark"; server.address = "tcp://127.0.0.1:${toString cfg.port}"; + + # Let a machine present an OAuth2 access token to the same + # `auth_request` endpoint browsers use, so a scraper can be + # authenticated by the gateway instead of every service + # growing its own static bearer. + # + # ⚠️ `authn_strategies` REPLACES the defaults rather than + # adding to them, so `CookieSession` is listed explicitly. + # Dropping it does not fail to evaluate and does not fail to + # start — it silently ends every operator session on the + # swarm UI, which rides this same endpoint. + # + # Unconditional, and not keyed to whichever service is + # currently scraped: this only makes a *scheme* available. + # Authorisation is the audience — authelia refuses a token + # that carries no audience for the requested URL, and a + # client may only be issued audiences it is registered for. + # So enabling the scheme grants nobody anything until a + # client is registered for a specific URL. + server.endpoints.authz.auth-request = { + implementation = "AuthRequest"; + authn_strategies = [ + { + name = "HeaderAuthorization"; + schemes = [ "Bearer" ]; + } + { name = "CookieSession"; } + ]; + }; log.level = "info"; # `watch` is load-bearing, not a convenience: authelia reads @@ -1105,11 +1135,41 @@ in # the account to disagree silently. access_control = { default_policy = "one_factor"; - rules = lib.optional uiCfg.enable { - domain = uiCfg.domain; - subject = [ "group:${operatorGroup}" ]; - 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. + rules = + # The forge's metrics endpoint. `deny` is deliberate and + # is the whole protection right now: 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. + # + # Being reachable by a Bearer token is not sufficient on + # its own: `authn_strategies` on this endpoint also + # accepts `CookieSession`, and a cookie carries no + # audience, so the audience is not what stands between a + # browser session and this data. + # + # The collector gets in by REPLACING this with a + # client-scoped allow (`subject = ["oauth2:client:"]`) + # once such a client is registered. Denying until then is + # what makes publishing the endpoint safe on its own — + # authelia refuses a subject naming a client that is not + # registered, and it does so in a `preStart` validator, + # so naming one early takes the whole SSO service down on + # the next restart rather than failing the build. + lib.optional forgeCfg.behindGateway { + domain = forgeCfg.domain; + resources = [ "^/metrics$" ]; + policy = "deny"; + } + ++ lib.optional uiCfg.enable { + domain = uiCfg.domain; + subject = [ "group:${operatorGroup}" ]; + policy = "one_factor"; + }; }; # The cookie domain is the SWARM's domain, NOT authelia's