diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 92ae1978..f1964fc9 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -69,6 +69,22 @@ in [ "services" "hyperhive" "swarm" "controller" "enable" ] [ "services" "hyperhive" "deploy" "swarm-controller" "enable" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "controller" "socketPath" ] + [ "services" "hyperhive" "deploy" "swarm-controller" "socketPath" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "controller" "forgeTokenFile" ] + [ "services" "hyperhive" "deploy" "swarm-controller" "forgeTokenFile" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "controller" "authBridgeUrl" ] + [ "services" "hyperhive" "deploy" "swarm-controller" "authBridgeUrl" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "controller" "queue" "clientSecretFile" ] + [ "services" "hyperhive" "deploy" "swarm-controller" "queue" "clientSecretFile" ] + ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "ui" "enable" ] [ "services" "hyperhive" "deploy" "swarm-ui" "enable" ] diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index 8ef4b51d..afa94b7b 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -112,9 +112,6 @@ in controller.queue.natsUrl = lib.mkIf cfg.deploy.singleHostSwarm ( lib.mkDefault "nats://127.0.0.1:${toString config.services.hyperhive.swarm.nats.port}" ); - controller.queue.clientSecretFile = lib.mkIf cfg.deploy.singleHostSwarm ( - lib.mkDefault "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/swarm-controller.secret" - ); }; # The controller is asserted by the MODE and by nothing else. Its own @@ -131,4 +128,15 @@ in # does not apply here — that one is about two definitions of `swarm` # itself, and this is a different top-level path. config.services.hyperhive.deploy.swarm-controller.enable = lib.mkDefault cfg.deploy.singleHostSwarm; + + # Same reason, one option later: the queue secret is a path on THIS host, + # so it moved to `deploy.*` with the rest of the controller's credentials. + # It has to sit out here rather than in the `swarm` attrset above — a bare + # `controller.` prefix in there means `swarm.controller`, which is now only + # a rename shim, so the definition would still resolve and warn on every + # evaluation of a single-host swarm. + config.services.hyperhive.deploy.swarm-controller.queue.clientSecretFile = + lib.mkIf cfg.deploy.singleHostSwarm ( + lib.mkDefault "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/swarm-controller.secret" + ); } diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index b1f92ff4..fe1c505e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -91,15 +91,15 @@ let # (`queueLocal`) is gone — its coordinates are options with assertions, # so a split-host controller fails at eval instead of silently losing # its identity. Forge access still has nothing to do with where authelia - # and NATS run, only with whether `cfg.forgeTokenFile` resolves to a real - # file — which `forgeTokenFile`'s own default already handles + # and NATS run, only with whether `forgeTokenFile` resolves to a real + # file — which that option's own default already handles # (forge-local vs. operator-copied). Gating a second time here would # just repeat that option's own logic under a different name. # # ⚠️ The asymmetry is deliberate: the queue is REQUIRED for a controller # and the forge is not, so absence is an error for one and a supported # shape for the other. - forgeEnv = lib.optionalAttrs (cfg.forgeTokenFile != null) { + forgeEnv = lib.optionalAttrs (deployCfg.swarm-controller.forgeTokenFile != null) { SWARM_CONTROLLER_FORGE_URL = "https://${forgeCfg.domain}"; # Same `%d` shape as the queue secret above — root reads the plaintext # at unit start, the daemon's own user sees a 0400 copy. @@ -137,8 +137,8 @@ let # is satisfied by THIS daemon's existing queue OIDC identity # (`queueEnv` above): "one identity per principal" already covers this, # so there is nothing new to mint or copy, just the bridge's address. - authBridgeEnv = lib.optionalAttrs (cfg.authBridgeUrl != null) { - SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl; + authBridgeEnv = lib.optionalAttrs (deployCfg.swarm-controller.authBridgeUrl != null) { + SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl; }; # The swarm's own display name, for `GET /api/swarm` (swarm-ui's chrome). @@ -268,27 +268,6 @@ in ''; }; - socketPath = lib.mkOption { - type = lib.types.str; - default = "/run/swarm-controller/controller.sock"; - description = '' - Unix socket the daemon serves on, and the path the gateway's nginx - proxies to. - - The **directory** is the access control here, not the socket mode: - the socket itself is `0666` (nginx runs as another user, and - `connect(2)` needs write), exactly as hive-c0re publishes the - per-agent sockets. What keeps that safe is that the directory holds - exactly one socket and is traverse-only (`0751`) for everyone else. - Moving this path under a directory that carries anything else — - `/run/hyperhive`, which holds the host admin socket, above all — - exposes whatever else lives there to the same reachability. - - Changing this therefore means re-checking what else lives in the - new directory, not just the daemon. - ''; - }; - staleAfterSeconds = lib.mkOption { type = lib.types.ints.positive; default = 120; @@ -340,27 +319,6 @@ in correct for a remote provider as long as that URL is set. ''; }; - - clientSecretFile = lib.mkOption { - type = lib.types.str; - default = ""; - example = "/var/lib/secrets/swarm-controller-queue.secret"; - description = '' - Path on **this** host holding the plaintext of the controller's - OAuth2 client secret. A path, never a value: the secret would - otherwise land in the world-readable nix store. - - The controller cannot mint its own — minting happens inside - authelia's state directory during its first boot — so away from - that host the operator places the secret and names it here. - `singleHostSwarm` points this at the minted file, which - is exactly the case where one exists locally. - - Read by `LoadCredential`, so it needs to be readable by root at - unit start and nothing more; the daemon's own user never sees - the original path. - ''; - }; }; links = lib.mkOption { @@ -410,6 +368,40 @@ in ''; }; + }; + + # What stays above is what the controller IS to the swarm: the quick-links + # it publishes, the client id it is registered under, how stale a snapshot + # may read, and its packages. What the host running it decides is here — + # where its socket lives, and the three credentials it reads off local + # disk. `enable` already lives in ./deploy.nix, which also carries the + # renames. + # + # ⚠️ `deploy.swarm-controller`, NOT `deploy.hive-controller`. Both exist: + # the latter is hive-c0re's namespace, where `tls.*` lives. A definition on + # the wrong one lands on a live, unrelated option rather than failing. + options.services.hyperhive.deploy.swarm-controller = { + socketPath = lib.mkOption { + type = lib.types.str; + default = "/run/swarm-controller/controller.sock"; + description = '' + Unix socket the daemon serves on, and the path the gateway's nginx + proxies to. + + The **directory** is the access control here, not the socket mode: + the socket itself is `0666` (nginx runs as another user, and + `connect(2)` needs write), exactly as hive-c0re publishes the + per-agent sockets. What keeps that safe is that the directory holds + exactly one socket and is traverse-only (`0751`) for everyone else. + Moving this path under a directory that carries anything else — + `/run/hyperhive`, which holds the host admin socket, above all — + exposes whatever else lives there to the same reachability. + + Changing this therefore means re-checking what else lives in the + new directory, not just the daemon. + ''; + }; + forgeTokenFile = lib.mkOption { type = lib.types.nullOr lib.types.str; # The forge has no `enable` of its own to check — neither half of @@ -465,13 +457,37 @@ in No new credential to configure: the bearer token presented to the bridge is minted from THIS daemon's own existing queue OIDC - identity (`queue.*` above) — "one identity per principal" + identity — its endpoints stay in `swarm.controller.queue`, its secret is + `queue.clientSecretFile` below — "one identity per principal" already covers it. `null` means no agent-identity support: `CreateIdentity` jobs fail with a clear "no auth bridge configured here" error rather than the daemon refusing to start, the same graceful-absence shape `forgeTokenFile` uses. ''; }; + + queue = { + clientSecretFile = lib.mkOption { + type = lib.types.str; + default = ""; + example = "/var/lib/secrets/swarm-controller-queue.secret"; + description = '' + Path on **this** host holding the plaintext of the controller's + OAuth2 client secret. A path, never a value: the secret would + otherwise land in the world-readable nix store. + + The controller cannot mint its own — minting happens inside + authelia's state directory during its first boot — so away from + that host the operator places the secret and names it here. + `singleHostSwarm` points this at the minted file, which + is exactly the case where one exists locally. + + Read by `LoadCredential`, so it needs to be readable by root at + unit start and nothing more; the daemon's own user never sees + the original path. + ''; + }; + }; }; config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-controller.enable) { @@ -555,10 +571,10 @@ in ''; } { - assertion = cfg.queue.clientSecretFile != ""; + assertion = deployCfg.swarm-controller.queue.clientSecretFile != ""; message = '' - services.hyperhive.swarm.controller.queue.clientSecretFile is - unset. + services.hyperhive.deploy.swarm-controller.queue.clientSecretFile + is unset. It defaults to the file authelia's first-boot generator mints, which only exists when authelia runs on this host. Elsewhere the @@ -593,9 +609,11 @@ in # that race on a real boot — two seconds more and it would have hit # `start-limit-hit`, which does not self-heal. LoadCredential = [ - "queue-client.secret:${cfg.queue.clientSecretFile}" + "queue-client.secret:${deployCfg.swarm-controller.queue.clientSecretFile}" ] - ++ lib.optional (cfg.forgeTokenFile != null) "forge-token:${cfg.forgeTokenFile}"; + ++ lib.optional ( + deployCfg.swarm-controller.forgeTokenFile != null + ) "forge-token:${deployCfg.swarm-controller.forgeTokenFile}"; # The placeholder default that makes the above non-fatal. # `LoadCredential=` takes priority over `SetCredential=`, so this is @@ -685,7 +703,7 @@ in # broken rather than lighter; forge's and the auth bridge's are # genuinely optional and stay gated on their own option resolving. environment = { - SWARM_CONTROLLER_SOCKET = cfg.socketPath; + SWARM_CONTROLLER_SOCKET = deployCfg.swarm-controller.socketPath; # The swarm's hive directory, JSON-encoded — the full directory # (this daemon has no "self" hive to exclude, unlike # `swarm.peerHives`, `swarm.hives` minus this hive) rather than @@ -750,7 +768,7 @@ in description = "watch the swarm controller's queue credential"; wantedBy = [ "multi-user.target" ]; pathConfig = { - PathChanged = cfg.queue.clientSecretFile; + PathChanged = deployCfg.swarm-controller.queue.clientSecretFile; Unit = "swarm-controller-credential.service"; }; }; diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 08068f55..dd389558 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -17,7 +17,6 @@ let deployCfg = config.services.hyperhive.deploy; gatewayCfg = config.services.hyperhive.gateway; autheliaCfg = config.services.hyperhive.swarm.authelia; - controllerCfg = config.services.hyperhive.swarm.controller; # Same stylix auto-theming `hive-c0re/theme.nix` gives the dashboard — # this UI was left out of that overlay entirely (an operator with a @@ -211,7 +210,7 @@ in # path swarm-controller registered a route at is the path # nginx forwards, no prefix-stripping to keep in sync by hand. "/api/" = { - proxyPass = "http://unix:${controllerCfg.socketPath}:"; + proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:"; extraConfig = swarmAuthRequest; }; # ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`, @@ -233,7 +232,7 @@ in # a future `/webhook/` does not inherit the # bypass by living under a shared prefix. "/webhook/forge/" = { - proxyPass = "http://unix:${controllerCfg.socketPath}:"; + proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:"; }; # Swagger UI: same "nginx hosts the themed dist straight from # the store, only /api/openapi.json is dynamic" shape as the diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 3c5cde46..29e23a1b 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -118,6 +118,14 @@ let swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; }; + controllerOldPath = hive { + deploy.swarm-controller.enable = true; + swarm.controller.socketPath = "/run/test-ctrl/ctrl.sock"; + swarm.controller.forgeTokenFile = "/run/secrets/ctrl-forge.token"; + swarm.controller.authBridgeUrl = "http://127.0.0.1:19097"; + swarm.controller.queue.clientSecretFile = "/run/secrets/ctrl-queue.secret"; + }; + grafanaOldPath = hive { deploy.grafana.enable = true; swarm.grafana.socketDir = "/run/test-grafana-sock"; @@ -308,6 +316,21 @@ let # proxying to a path nothing creates. All four old paths are defined in # the fixture, so removing any single shim fails the eval rather than # only the one this assertion reads. + # All FOUR movers are asserted as rendered effects rather than as option + # values, so a rename that resolved but stopped reaching the module is + # caught per-option instead of only where one assertion happens to look. + name = "a config written against the pre-rename swarm-controller paths still reaches the unit"; + ok = + let + u = controllerOldPath.systemd.services.swarm-controller; + creds = u.serviceConfig.LoadCredential; + in + u.environment.SWARM_CONTROLLER_SOCKET == "/run/test-ctrl/ctrl.sock" + && u.environment.SWARM_CONTROLLER_AUTH_BRIDGE_URL == "http://127.0.0.1:19097" + && lib.any (c: lib.hasInfix "/run/secrets/ctrl-queue.secret" c) creds + && lib.any (c: lib.hasInfix "/run/secrets/ctrl-forge.token" c) creds; + } + { name = "a config written against the pre-rename grafana paths still creates the socket directory"; ok = lib.any ( rule: lib.hasInfix "/run/test-grafana-sock" rule diff --git a/swarm-controller/README.md b/swarm-controller/README.md index b9900d14..745ee52b 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -24,7 +24,7 @@ The hive-gateway's nginx is the only intended client and reaches the socket through a bind-mount. A listener that is never bound to an address cannot be reached from off-host by mistake. -The socket path is `services.hyperhive.swarm.controller.socketPath`, default +The socket path is `services.hyperhive.deploy.swarm-controller.socketPath`, default `/run/swarm-controller/controller.sock`, exported to the process as `SWARM_CONTROLLER_SOCKET`.