diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 6775ee23..e319da0c 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -442,6 +442,18 @@ in url = "https://${cfg.domain}/"; }; + # The address a scraper has to hold a token for. Same `behindGateway` + # guard, and for a stronger reason than the two above: with it off + # there is no `= /metrics` location and no `auth_request` in front of + # it, so the URL this names does not exist to be authorised. + # + # ⚠️ Written as the exact URL a collector requests, because that is + # what authelia compares against — this string agreeing with the + # `location` block above it is the whole mechanism. A near miss is a + # correctly minted token refused at the target. + services.hyperhive.swarm.otel.collectorAudiences = + lib.optional cfg.behindGateway "https://${cfg.domain}/metrics"; + # `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # clones). SSH stays direct on `forge.sshPort`. See diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 2ad3785c..9b6c9584 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -57,6 +57,47 @@ let # failing the check it was supposed to pass. operatorGroup = "admins"; + # Whether the swarm collector's client is actually registered here. + # + # Asked of the client list rather than re-derived from the conditions + # that produce it (`otel.enable`, a non-empty audience set, authelia + # being co-located). A second copy of that predicate is a second thing + # to keep in step, and the two drifting is not a build failure: a rule + # naming an unregistered client is refused by authelia's *startup* + # validator, so the whole SSO service fails to restart. + # + # Reading the registration itself also makes the rule correct for a + # registrar this module has never heard of — an operator registering + # the collector by hand against a provider that is not co-located with + # it gets the same rule, from the same expression. + # ⚠️ `swarm.otel` named in full, not through a let binding: there are two + # otel options one word apart, and only this one is the swarm's collector. + 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. + # + # `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 = forgeCfg.domain; + resources = [ "^/metrics$" ]; + } + // ( + if collectorRegistered then + { + policy = "one_factor"; + subject = [ "oauth2:client:${collectorClientId}" ]; + } + else + { policy = "deny"; } + ); + # Upstream's `services.authelia.instances.` derives the unit, # user, group and StateDirectory from the instance name # (`authelia` + `-`). Naming them here rather than repeating the @@ -1281,32 +1322,10 @@ in # 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"; - } + # Denied or client-scoped depending on whether a + # collector is registered — see `metricsRule` above, + # which is where the reasoning for both halves lives. + lib.optional forgeCfg.behindGateway metricsRule ++ lib.optional uiCfg.enable { domain = uiCfg.domain; subject = [ "group:${operatorGroup}" ]; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index cb67b155..c02563c4 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -223,6 +223,57 @@ in scrape is the inert configuration this option exists to avoid. ''; }; + + collectorAudiences = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = lib.literalExpression ''[ "https://forge.example.com/metrics" ]''; + description = '' + URLs this collector may mint an access token for, contributed by + the modules that publish them. + + A service module says **which URL it exposes**; this module turns + the set into the collector's single OAuth2 client — the same split + as `services.hyperhive.gateway.localNames`, and for the same + reason. The collector has no business knowing another service's + routes, and a service cannot reach into the client list to add + itself: client definitions concatenate rather than merging into a + shared entry, so the entry has to be built in one place from a + list many modules can write. + + ⚠️ An audience is a **URL**, not a label. Authelia validates a + bearer token against the address being requested, so a token + minted for one target is refused at another and a client may only + request an audience it is registered for — registration is the + authorisation. A near miss (a trailing slash, `http` for `https`) + presents as a valid token rejected at the target, several layers + from its cause. + + ⚠️ Deliberately not part of `scrapeTargets`. An entry there is + trusted because the scraper and the target share a host; an entry + here is trusted because it presents a credential. Folding the two + together would leave a reader unable to tell which of those a + given target relies on. + ''; + }; + + clientId = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "swarm-collector"; + description = '' + OAuth2 client id this collector authenticates as. Published so + authelia's `access_control` rules can name it without carrying a + second copy of the string, exactly as + `services.hyperhive.swarm.authelia.hiveClientPrefix` is published + for the queue's responder. + + Two spellings drifting apart is not a build failure: authelia + refuses a rule naming an unregistered client in its startup + validator, so the swarm's SSO service fails to *restart* — long + after the change that caused it evaluated cleanly. + ''; + }; }; config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { @@ -267,6 +318,43 @@ in # telemetry. services.hyperhive.swarm.authelia.oidc.hiveIdentities = true; + # The collector's own identity, for the other direction: the hive + # identities above are how this collector authenticates its *callers*, + # this is how it authenticates *itself* to a service published behind + # the gateway. + # + # Only where authelia is co-located. A client is a row in this host's + # provider config, so declaring one against a remote provider would + # render nothing while reading as done; a swarm whose authelia lives + # elsewhere registers it there. + # + # ⚠️ Also conditional on the audience set being non-empty, and that is + # the shipped case rather than an edge — nothing contributes a URL + # until some service publishes one behind the gateway. Authelia + # refuses a bearer-authz client with no audience, so an unconditional + # declaration would break every hive that runs a collector and + # publishes nothing. + services.hyperhive.swarm.authelia.oidc.clients = + lib.mkIf (autheliaCfg.enable && cfg.collectorAudiences != [ ]) + [ + { + id = cfg.clientId; + description = "HyperHive swarm collector"; + kind = "machine"; + # Grants `authelia.bearer.authz`, without which the authz + # endpoint refuses an otherwise valid token and blames the + # token rather than the missing grant. + bearerAuthz = true; + audience = cfg.collectorAudiences; + # Stated rather than left on authelia's default, because the + # two agreeing today is not the same as this being the + # required value: authelia permits only basic / JWT methods + # for a confidential client holding that scope, and enforces + # it in the startup validator. + tokenEndpointAuthMethod = "client_secret_basic"; + } + ]; + # The CA bind source is written at runtime by a host unit, so the # container has to start after it — otherwise nspawn sets up a mount # over a file that does not exist yet.