diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index cbfd7028..7e982e48 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -24,6 +24,7 @@ ./otel.nix ./swarm-authelia.nix ./swarm-ca.nix + ./swarm-nats.nix ./swarm-controller.nix ./swarm-snapshot-store.nix ./swarm-ui.nix diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix new file mode 100644 index 00000000..054b3b51 --- /dev/null +++ b/nix/host-modules/swarm-nats.nix @@ -0,0 +1,220 @@ +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.swarm.nats; + autheliaCfg = config.services.hyperhive.swarm.authelia; + autheliaUrl = autheliaCfg.url; + + # The account the callout responder authenticates as, and the account + # authorized clients are placed in. Two accounts rather than one: an + # account is NATS' isolation boundary, so a responder that shares an + # account with its clients can be published to by the things it + # authorizes. + calloutAccount = "AUTH"; + clientAccount = "APP"; + calloutUser = "auth"; +in +{ + # The swarm's message queue: one NATS server, reached by every hive. + # + # ⚠️ There is deliberately NO gateway vhost here, and this is the first + # swarm service where that is true — the next reader will go looking for + # one. NATS speaks its own TCP protocol rather than HTTP, so nginx + # cannot front it the way it fronts the forge, matrix and authelia. + # Cross-hive reach is the wireguard mesh; `gateway.localNames` and the + # per-service vhost pattern do not apply. + + options.services.hyperhive.swarm.nats = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm's message queue in a `swarm-nats` container on this + host. A swarm has one queue, so this belongs on the same host as + the rest of the shared services. + + Off by default, and off means *absent*: no container is created + and nothing else in the evaluated config changes. + ''; + }; + + # ⚠️ Deliberately NO `package` option, unlike this module's siblings. + # `services.nats` upstream does not expose one — it resolves + # `pkgs.nats-server` itself — so an option here would either be + # ignored or need an overlay to mean anything, and an option that + # does not control what it names is worse than its absence. Pin the + # build with `nixpkgs.overlays` if you need to. + + port = lib.mkOption { + type = lib.types.port; + default = 4222; + description = '' + TCP port the queue listens on. 4222 is upstream's default and + sits outside hyperhive's claimed ranges (dashboard 7000, forge + 3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash). + ''; + }; + + clientId = lib.mkOption { + type = lib.types.str; + default = "swarm-nats"; + description = '' + OAuth2 client id the queue's authentication path identifies + itself with. Must match the `id` of the corresponding entry in + `services.hyperhive.swarm.authelia.oidc.clients` — which this + module contributes for you when both run on this host. + ''; + }; + + calloutIssuerPublicKey = lib.mkOption { + type = lib.types.str; + default = ""; + example = "ACYR44YO3XZRZBJIYLI5SL6LOPIW37JTD52LNOBHUE34XMH7N5ABMFJH"; + description = '' + Public half of the account nkey whose signature the server + accepts on a user JWT minted by the auth-callout responder. + + **A public key, and therefore a value rather than a path** — + the deliberate exception to the rule that credentials are + `*File` options. It is published to every client that connects + and its whole job is to be widely known; the matching *seed* is + the secret, is never named here, and reaches only the responder. + + Required when `enable` is set. Without it the server has no + issuer to trust and no client can be authorized — which is the + fail-closed state described below, but arrived at by accident + rather than on purpose, so it fails at eval instead. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + # Fail at EVAL, not at boot: a queue that comes up unable to + # authenticate anyone presents as every client hanging, which is + # several layers from "the operator never set the issuer". + assertion = cfg.calloutIssuerPublicKey != ""; + message = '' + services.hyperhive.swarm.nats.enable requires + nats.calloutIssuerPublicKey — the public half of the account + nkey that signs user JWTs for this queue. + + It is public and belongs in config; the matching seed is a + secret and is delivered to the callout responder instead. See + docs/swarm/secrets.md for which is which. + ''; + } + { + assertion = autheliaUrl != null; + message = '' + services.hyperhive.swarm.nats.enable requires + services.hyperhive.swarm.authelia.url — the queue authenticates + clients by validating tokens that authelia issued. + + It defaults to this host's own instance only when this host + runs authelia. A hive that federates with a swarm sets it + explicitly to wherever that provider lives. + ''; + } + ]; + + # One declaration, two readers. The queue knows which client id it + # authenticates under; making the operator restate it in authelia's + # client list would be a second source of truth for a string whose + # mismatch is an opaque 401 from the token endpoint. + # + # `client_credentials` rather than an authorization-code flow: a + # queue's clients are daemons with nobody to redirect, so a + # non-interactive grant is what makes a hive able to authenticate at + # all. No redirect URI exists or is wanted. + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [ + { + id = cfg.clientId; + description = "HyperHive swarm queue"; + redirectUris = [ ]; + } + ]; + + containers.swarm-nats = { + autoStart = true; + ephemeral = false; + # Shared host netns, like every sibling container. + # + # ⚠️ Which is exactly why the server below must refuse everyone + # until the callout responder exists: on this netns the queue is + # reachable from every agent container on the hive, so an + # unauthenticated interim state would be a hole rather than a + # rough edge. + privateNetwork = false; + config = + { ... }: + { + system.stateVersion = "26.05"; + + # Shared host netns: this container's own firewall.service + # would rewrite the HOST ruleset at every boot. The host + # firewall owns all filtering. + networking.firewall.enable = false; + # Keep the host-copied /etc/resolv.conf intact — resolvconf's + # host-tracking regenerates it empty, since the host's copy + # does not cross the boundary after start. + networking.resolvconf.enable = lib.mkForce false; + + services.nats = { + enable = true; + + serverName = "swarm-nats"; + port = cfg.port; + settings = { + # Two accounts, and the callout user lives in neither of + # the accounts it authorizes into. + accounts = { + ${calloutAccount}.users = [ { user = calloutUser; } ]; + ${clientAccount} = { }; + }; + + authorization = { + timeout = "2s"; + # 🔒 THIS BLOCK IS THE FAIL-CLOSED STATE, and it is the + # measured one rather than the obvious one. + # + # Measured on the pinned nats-server 2.14.1: both + # `authorization { }` and `authorization { users: [] }` + # accept an anonymous client and answer PONG — they read + # like "authorize nobody" and are wide open. An + # auth_callout block sets `auth_required` and refuses + # every client whose credential no responder has + # approved, so a config whose responder does not exist + # yet denies everyone. + # + # `nats-server -t` calls all three valid; it parses, it + # does not authenticate. Only running them tells the + # difference. + # + # ⇒ this is both the safe interim state and the final + # shape. Nothing here has to be swapped out when the + # responder lands beside it — it only starts being able + # to say yes. + auth_callout = { + issuer = cfg.calloutIssuerPublicKey; + auth_users = [ calloutUser ]; + account = calloutAccount; + }; + }; + }; + }; + + # The server binary, so an operator with a shell in here can + # run `nats-server -t` against the generated config. The unit + # resolves ExecStart through the store path and puts nothing + # on PATH. + environment.systemPackages = [ pkgs.nats-server ]; + }; + }; + }; +}