diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 76b912ec..0edf7691 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -41,6 +41,7 @@ ./glue-swarm-bao-otel-oidc-client.nix ./glue-swarm-otel-oidc-client.nix ./swarm-authelia.nix + ./swarm-bao-service.nix ./swarm-bao.nix ./swarm-ca.nix ./swarm-secret-publisher.nix diff --git a/nix/host-modules/swarm-bao-service.nix b/nix/host-modules/swarm-bao-service.nix new file mode 100644 index 00000000..7c83df05 --- /dev/null +++ b/nix/host-modules/swarm-bao-service.nix @@ -0,0 +1,153 @@ +# The swarm's secret store as every hive sees it: the names it is reached on, +# its port, its container, and the client ids it is registered under, +# identical on every host. What the host running it decides, and the +# container itself, are in ./swarm-bao.nix. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.swarm.bao; + swarmDomain = config.services.hyperhive.swarm.domain; + + # Total on a null swarm domain for the same reason every sibling module is: + # the required-domain assertion in hive-network.nix should be what an operator + # sees, not a coercion error from here. + domainBase = if swarmDomain == null then "invalid" else swarmDomain; +in +{ + options.services.hyperhive.swarm.bao = { + machine = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "swarm-bao"; + description = '' + Container name. Read-only: the name appears in host paths and in + `machinectl`, so it is a fact other modules may read rather than a knob. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + default = "bao.${domainBase}"; + defaultText = lib.literalExpression ''"bao.''${services.hyperhive.swarm.domain}"''; + description = '' + Name the store is reached on. A **sibling** of the swarm's other + service names, not a child of any hive domain: an authority whose + `nameConstraints` permit one hive's domain cannot issue for a sibling + of it, so the shape of this name decides which authorities could ever + sign for the store. That is a property of the name, not a choice of + issuer — this module makes no such choice. + ''; + }; + + ui.domain = lib.mkOption { + type = lib.types.str; + default = "bao-ui.${domainBase}"; + defaultText = lib.literalExpression ''"bao-ui.''${services.hyperhive.swarm.domain}"''; + description = '' + Name the gateway serves the store's browser UI on, to members of + authelia's `admins` group only. Swarm-wide because authelia's host + writes the access rule for it and the store's host serves it. + + Must differ from {option}`services.hyperhive.swarm.bao.domain`: that + name is the mutual-TLS endpoint every reader dials, and it has no + vhost. + ''; + }; + + ui.oidc.clientId = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "swarm-bao-ui"; + description = '' + OAuth2 client id the store's `oidc` auth method logs browser users + in as, at authelia. + + Swarm-wide and read-only because two hosts have to agree on it: + authelia registers the client (`glue-bao-ui-oidc-client.nix`) and + mints its secret, and the store's host reads that secret back out of + the store under a path composed from this id. + ''; + }; + + ui.oidc.redirectUri = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "https://${cfg.ui.domain}/ui/vault/auth/oidc/oidc/callback"; + defaultText = lib.literalExpression ''"https://''${services.hyperhive.swarm.bao.ui.domain}/ui/vault/auth/oidc/oidc/callback"''; + description = '' + Where authelia sends the browser back to after an OIDC login, and the + URI both authelia and the store's `oidc` role match **exactly**. + + The format is the OpenBao UI's own route, + `/ui/vault/auth//oidc/callback`, with the mount `oidc`. The + UI composes it from the page's origin, so it only matches when the + gateway serves the UI on port 443. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8200; + description = '' + TCP port the store listens on. Upstream's own default, kept so an + operator reading OpenBao documentation finds what they expect. + + Swarm-wide because a client has to know it to reach the store, and + the same port on every listener: which *addresses* the store answers + on is the running host's business + ({option}`services.hyperhive.deploy.bao.extraListenAddresses`), but + which port it answers on is something the whole swarm agrees. + ''; + }; + + otel.clientId = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "swarm-bao-collector"; + description = '' + OAuth2 client id the collector inside the store's container + authenticates as, and — self-referentially, the shape every hive's + client already uses — the audience it asks its token for. + + **Its own, not the swarm collector's and not + `swarm-controller`'s.** One identity per principal: this forwarder + runs wherever the store runs, which is not where either of those + two runs, and the receiver it pushes to + (`swarm.otel.storeProducerName`) admits this id alone. + + Swarm-wide and read-only because three hosts have to agree on it: + authelia registers the client + (`glue-swarm-bao-otel-oidc-client.nix`), the swarm collector checks + the audience (`swarm-otel.nix`), and the store's host reads the + minted secret back out of the store under a path composed from it. + Two spellings present as a healthy-looking 401. + ''; + }; + + otel.telemetryPort = lib.mkOption { + type = lib.types.port; + default = 8890; + description = '' + Port the collector inside the store's container serves its **own** + metrics on — queue depth, refused and dropped samples, exporter + failures. How you find out that telemetry is being lost, so it is + worth keeping rather than switching off. + + ⚠️ **Deliberately neither 8888 nor 8889.** 8888 is the collector + binary's built-in default, which the hive tier + ({option}`services.hyperhive.otel.telemetryPort`) already binds, and + 8889 is the swarm tier's + ({option}`services.hyperhive.swarm.otel.telemetryPort`). This + container runs with `privateNetwork = false`, so all three share the + host's network namespace whenever they are co-located — and unlike + the OTLP ports this one appears nowhere in either config when it is + left undeclared, so nothing that compares configured ports can see + the clash. The second collector to start simply dies with + `bind: address already in use`. + ''; + }; + }; +} diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index df81daee..59540b66 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -40,7 +40,6 @@ let deployCfg = hyperhiveCfg.deploy; baoDeploy = deployCfg.bao; networkCfg = hyperhiveCfg.network; - swarmDomain = hyperhiveCfg.swarm.domain; # What this container's own collector stamps as `service.name` on every # log line and metric it forwards, and the value the shipped Grafana @@ -125,11 +124,6 @@ let }; }; - # Total on a null swarm domain for the same reason every sibling module is: - # the required-domain assertion in hive-network.nix should be what an operator - # sees, not a coercion error from here. - domainBase = if swarmDomain == null then "invalid" else swarmDomain; - # Where the leaf lands for openbao to read. `tlsDir` is bind-mounted at the # same path on both sides, so the delivery below needs no second mount, and # nothing has to bind `deploy.hive-controller.tls.stateDir`, which holds the @@ -1214,9 +1208,10 @@ in # Reading this block as store-runner-only is what makes an off-host reader # look inexpressible when it is already supported. # - # `swarm.bao.*` below is what every host in the swarm has to agree on — the - # name the store answers to, its port, its container. A host that is purely - # a *client* needs all of that, because it is how the client finds the store. + # `swarm.bao.*`, in ./swarm-bao-service.nix, is what every host in the swarm + # has to agree on — the name the store answers to, its port, its container. + # A host that is purely a *client* needs all of that, because it is how the + # client finds the store. options.services.hyperhive.deploy.bao = { package = lib.mkOption { type = lib.types.package; @@ -1948,140 +1943,6 @@ in }; }; - options.services.hyperhive.swarm.bao = { - machine = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "swarm-bao"; - description = '' - Container name. Read-only: the name appears in host paths and in - `machinectl`, so it is a fact other modules may read rather than a knob. - ''; - }; - - domain = lib.mkOption { - type = lib.types.str; - default = "bao.${domainBase}"; - defaultText = lib.literalExpression ''"bao.''${services.hyperhive.swarm.domain}"''; - description = '' - Name the store is reached on. A **sibling** of the swarm's other - service names, not a child of any hive domain: an authority whose - `nameConstraints` permit one hive's domain cannot issue for a sibling - of it, so the shape of this name decides which authorities could ever - sign for the store. That is a property of the name, not a choice of - issuer — this module makes no such choice. - ''; - }; - - ui.domain = lib.mkOption { - type = lib.types.str; - default = "bao-ui.${domainBase}"; - defaultText = lib.literalExpression ''"bao-ui.''${services.hyperhive.swarm.domain}"''; - description = '' - Name the gateway serves the store's browser UI on, to members of - authelia's `admins` group only. Swarm-wide because authelia's host - writes the access rule for it and the store's host serves it. - - Must differ from {option}`services.hyperhive.swarm.bao.domain`: that - name is the mutual-TLS endpoint every reader dials, and it has no - vhost. - ''; - }; - - ui.oidc.clientId = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "swarm-bao-ui"; - description = '' - OAuth2 client id the store's `oidc` auth method logs browser users - in as, at authelia. - - Swarm-wide and read-only because two hosts have to agree on it: - authelia registers the client (`glue-bao-ui-oidc-client.nix`) and - mints its secret, and the store's host reads that secret back out of - the store under a path composed from this id. - ''; - }; - - ui.oidc.redirectUri = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "https://${cfg.ui.domain}/ui/vault/auth/oidc/oidc/callback"; - defaultText = lib.literalExpression ''"https://''${services.hyperhive.swarm.bao.ui.domain}/ui/vault/auth/oidc/oidc/callback"''; - description = '' - Where authelia sends the browser back to after an OIDC login, and the - URI both authelia and the store's `oidc` role match **exactly**. - - The format is the OpenBao UI's own route, - `/ui/vault/auth//oidc/callback`, with the mount `oidc`. The - UI composes it from the page's origin, so it only matches when the - gateway serves the UI on port 443. - ''; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 8200; - description = '' - TCP port the store listens on. Upstream's own default, kept so an - operator reading OpenBao documentation finds what they expect. - - Swarm-wide because a client has to know it to reach the store, and - the same port on every listener: which *addresses* the store answers - on is the running host's business - ({option}`services.hyperhive.deploy.bao.extraListenAddresses`), but - which port it answers on is something the whole swarm agrees. - ''; - }; - - otel.clientId = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "swarm-bao-collector"; - description = '' - OAuth2 client id the collector inside the store's container - authenticates as, and — self-referentially, the shape every hive's - client already uses — the audience it asks its token for. - - **Its own, not the swarm collector's and not - `swarm-controller`'s.** One identity per principal: this forwarder - runs wherever the store runs, which is not where either of those - two runs, and the receiver it pushes to - (`swarm.otel.storeProducerName`) admits this id alone. - - Swarm-wide and read-only because three hosts have to agree on it: - authelia registers the client - (`glue-swarm-bao-otel-oidc-client.nix`), the swarm collector checks - the audience (`swarm-otel.nix`), and the store's host reads the - minted secret back out of the store under a path composed from it. - Two spellings present as a healthy-looking 401. - ''; - }; - - otel.telemetryPort = lib.mkOption { - type = lib.types.port; - default = 8890; - description = '' - Port the collector inside the store's container serves its **own** - metrics on — queue depth, refused and dropped samples, exporter - failures. How you find out that telemetry is being lost, so it is - worth keeping rather than switching off. - - ⚠️ **Deliberately neither 8888 nor 8889.** 8888 is the collector - binary's built-in default, which the hive tier - ({option}`services.hyperhive.otel.telemetryPort`) already binds, and - 8889 is the swarm tier's - ({option}`services.hyperhive.swarm.otel.telemetryPort`). This - container runs with `privateNetwork = false`, so all three share the - host's network namespace whenever they are co-located — and unlike - the OTLP ports this one appears nowhere in either config when it is - left undeclared, so nothing that compares configured ports can see - the clash. The second collector to start simply dies with - `bind: address already in use`. - ''; - }; - }; - # ⚠️ Gated on `deploy.bao.enable`, and that is load-bearing rather than # tidiness: an unconditional `config` block would evaluate the seal # assertion on EVERY hive, so a hive that runs no secret store at all diff --git a/nix/host-modules/swarm-nats-service.nix b/nix/host-modules/swarm-nats-service.nix index 50860aee..c13ba81f 100644 --- a/nix/host-modules/swarm-nats-service.nix +++ b/nix/host-modules/swarm-nats-service.nix @@ -8,7 +8,7 @@ }: let swarmDomain = config.services.hyperhive.swarm.domain; - # Total on a null swarm domain, for the reason ./swarm-bao.nix gives. + # Total on a null swarm domain, for the reason ./swarm-bao-service.nix gives. domainBase = if swarmDomain == null then "invalid" else swarmDomain; in {