From 1db2ac26a799da9917cbdecbc58fb6636c36023c Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 7 Sep 2026 01:38:53 +0200 Subject: [PATCH] deploy: split the forge's host decisions out of swarm.forge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swarm.*` is what a hive needs to be a *client* of the swarm. For the forge that is what it IS from any hive's point of view: its package, the names and ports it answers on, the URLs it advertises, and the client id it is registered under. How it is served, what it mirrors and where its host-local secrets sit are decisions of the machine running it, so behindGateway, openFirewall, mirrors, sso.clientSecretFile and hostSwarmControllerTokenFile move to `deploy.forgejo.*`. Unlike the wireguard mesh this SPLITS a module rather than relocating a whole namespace. `sso` splits with it: `clientId` stays because it must match the id in authelia's register, while the secret beside it is a path on one host. Moving the whole `sso` block for symmetry with `ci` was considered and rejected on exactly that asymmetry. Declared in hive-forge/default.nix under the `deploy.*` path, following swarm-victorialogs.nix; deploy.nix carries only the renames. `mirrors` renames in one entry rather than one per field — it is a single option of a list-of-submodule type, so the rename carries its whole value, where `ci` needed five because it is a plain attrset of options. Readers outside the module: hive-ci.nix binds `deploy.forgejo` for its behindGateway assertion; swarm-authelia.nix and swarm-controller.nix read theirs off the `deployCfg` they already bind. hivectl's `open` printed `services.hyperhive.forge.behindGateway` in an operator-facing hint — a path that never existed, missing `swarm.` — and hive-c0re's state_snapshot doc comment carried the same defect; both now name the new path. The rendered docs put the two halves on separate pages, so the five descriptions of staying options that explain themselves in terms of `behindGateway` now qualify it in full. module-eval gains a forge case configured entirely through the old paths, asserting the rendered firewall ports and the mirror env var c0re seeds from: the new paths evaluate fine without the shims, so dropping them reads as a clean tree. All five old paths are defined in the fixture, so removing any single shim entry fails the eval rather than only the two the assertion reads. --- docs/integrations/forge.md | 6 +- docs/networking/gateway.md | 20 ++- docs/scheduler/ci.md | 6 +- docs/tools/hivectl.md | 2 +- hive-c0re/src/dashboard/state_snapshot.rs | 4 +- hivectl/src/open.rs | 2 +- nix/host-modules/deploy.nix | 32 ++++ nix/host-modules/hive-c0re/environment.nix | 4 +- nix/host-modules/hive-ci.nix | 17 +- nix/host-modules/hive-forge/default.nix | 194 +++++++++++---------- nix/host-modules/swarm-authelia.nix | 2 +- nix/host-modules/swarm-controller.nix | 9 +- nix/module-eval.nix | 31 ++++ 13 files changed, 209 insertions(+), 120 deletions(-) diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 10c15f01..26e899ad 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -2,7 +2,11 @@ Private Forgejo instance running in a nixos-container, used as the swarm's persistent code-collaboration surface (issues, PRs, reviews, -attachments). Configured via `services.hyperhive.swarm.forge.*`. Container +attachments). Configured via `services.hyperhive.swarm.forge.*` (what the +forge IS to every hive — its package, names, ports, URLs) plus +`services.hyperhive.deploy.forgejo.*` (what the host running it decides — +how it is served, what it mirrors, where its host-local secrets sit). +Container shape, ROOT_URL / sub-domain routing, and operator-vs-in-cluster URL handling live in [`docs/networking/gateway.md`](../networking/gateway.md); this file owns the per-agent integration story and the notification pump that wakes diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index a5aa5c75..338a019e 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -10,7 +10,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next | `/agent//` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) | | `/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` | | `/matrix/` (deprecated) | `_` | 301 → `chat./` | `matrix.gui.enable` | -| `forge./` | `forge.` | forgejo (`3000`) | `forge.behindGateway` | +| `forge./` | `forge.` | forgejo (`3000`) | `deploy.forgejo.behindGateway` | | `chat./_matrix/*` | `chat.` | tuwunel (`8008`) | `matrix.gatewayHost != null` | | `chat./` | `chat.` | fluffychat-web static | `matrix.gui.enable` | | `chat./config.json` | `chat.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | @@ -62,7 +62,7 @@ Each location carries a duplicated `auth_basic` block (separate locations don't `services.hyperhive.gateway.localHostsEntry = true` adds entries to the host's `/etc/hosts`: - `` → `127.0.0.1` -- `forge.` → `127.0.0.1` (when forge.behindGateway) +- `forge.` → `127.0.0.1` (when deploy.forgejo.behindGateway) - `chat.` → `127.0.0.1` (when matrix.gatewayHost set) - `auth.` → `127.0.0.1` (when deploy.authelia) @@ -159,7 +159,7 @@ flip together: the primary agent-name link, the favicon fetch (`/icon`), and the nav-strip `container`-kind links from `DashboardState.links` (`GET /api/dashboard-state`). `forge`-kind nav-strip links still resolve against `http://:3000` (separate sub-domain transition -tracked by `forge.behindGateway`); `external`-kind links are +tracked by `deploy.forgejo.behindGateway`); `external`-kind links are already absolute. See `docs/web-ui/dashboard.md::Container row` for the frontend-side derivation. @@ -333,10 +333,12 @@ container. ```nix services.hyperhive.swarm.forge = { - httpPort = 3000; # default — HTTP listener; outside hyperhive's 7000/8100-8999 range - sshPort = 2222; # default — git-over-SSH; kept off 22 so it doesn't collide with the host openssh - openFirewall = false; # default — expose httpPort + sshPort to the host firewall + httpPort = 3000; # default — HTTP listener; outside hyperhive's 7000/8100-8999 range + sshPort = 2222; # default — git-over-SSH; kept off 22 so it doesn't collide with the host openssh }; +# Which ports the forge answers on is swarm-wide; whether THIS host opens +# them in its firewall is a deployment decision, so it lives under deploy.*. +services.hyperhive.deploy.forgejo.openFirewall = false; # default ``` `httpPort` (default **3000**) is the port Forgejo's HTTP server binds to. @@ -357,7 +359,7 @@ the bridge), not the raw port, so no firewall hole is needed. Flip to (not behind the gateway). - External git clients that push/pull via SSH directly to the host. -Forgejo served through the gateway (`forge.behindGateway = true`) does +Forgejo served through the gateway (`deploy.forgejo.behindGateway = true`) does not need `openFirewall` — the gateway's own `openFirewall` option covers that path. @@ -373,8 +375,8 @@ covers most cases: | Shape | Auto-derived `ROOT_URL` | |---|---| -| `behindGateway = true` | `https:///` (port suffix omitted when `gateway.httpsPort == 443`) | -| `behindGateway = false` | `http://:/` | +| `deploy.forgejo.behindGateway = true` | `https:///` (port suffix omitted when `gateway.httpsPort == 443`) | +| `deploy.forgejo.behindGateway = false` | `http://:/` | The gateway always terminates TLS, so the `behindGateway = true` case is always advertised over `https://`; only the direct (`behindGateway = diff --git a/docs/scheduler/ci.md b/docs/scheduler/ci.md index 6d1ced37..2640a155 100644 --- a/docs/scheduler/ci.md +++ b/docs/scheduler/ci.md @@ -100,7 +100,7 @@ build can't hold the runner's single slot indefinitely). ## Container design -- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `forge.behindGateway = true`. +- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `deploy.forgejo.behindGateway = true`. - **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter). - **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`. - **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only. @@ -150,10 +150,10 @@ forge provisioning sweep (orgs, agent accounts, etc.), so mirror seeding lives in the same place rather than a separate host-side unit. **General-purpose mirrors**: you can pre-seed any external repo as a -pull-mirror via `services.hyperhive.swarm.forge.mirrors`: +pull-mirror via `services.hyperhive.deploy.forgejo.mirrors`: ```nix -services.hyperhive.swarm.forge.mirrors = [ +services.hyperhive.deploy.forgejo.mirrors = [ { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; } { upstream = "https://github.com/example/tool"; dest = "mirrors/tool"; } ]; diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index c701e39e..d985d582 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -314,6 +314,6 @@ an error. A surface has no URL when it isn't browser-reachable: `home` needs `services.hyperhive.domain`; `forge` needs -`services.hyperhive.swarm.forge.behindGateway = true`; `matrix` needs +`services.hyperhive.deploy.forgejo.behindGateway = true`; `matrix` needs `services.hyperhive.swarm.matrix.gui.enable = true`. In those cases the command exits with a hint naming the option to set. diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 5b7fc68b..be12d782 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -94,8 +94,8 @@ pub(super) struct StateSnapshot { /// Public URL of the forge (e.g. /// `"https://forge.pr1ma.darkest.space"`). Sourced from the /// `HIVE_FORGE_PUBLIC_URL` env var, which the c0re NixOS module - /// sets from `services.hyperhive.forge.publicUrl` (defaults to - /// the gateway vhost URL when `forge.behindGateway = true`, + /// sets from `services.hyperhive.swarm.forge.publicUrl` (defaults to + /// the gateway vhost URL when `deploy.forgejo.behindGateway = true`, /// `null` otherwise). `None` when absent — the frontend **hides** /// forge links rather than guessing `http://:3000`, /// which is only right by accident on deployments that aren't diff --git a/hivectl/src/open.rs b/hivectl/src/open.rs index c497cbf0..16e366dd 100644 --- a/hivectl/src/open.rs +++ b/hivectl/src/open.rs @@ -26,7 +26,7 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> { ), OpenTarget::Forge => ( urls.forge, - "the public forge URL needs `services.hyperhive.forge.behindGateway = true`", + "the public forge URL needs `services.hyperhive.deploy.forgejo.behindGateway = true`", ), OpenTarget::Matrix => ( urls.matrix, diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 29e0171c..78d74f71 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -98,6 +98,38 @@ in [ "services" "hyperhive" "deploy" "forgejo" "ci" "package" ] ) + # The rest of the forge split. What stays under `swarm.forge` is what the + # forge IS from any hive's point of view — its package, the names and ports + # it answers on, the URLs it advertises, the client id it is registered + # under; these five are what the host running it decides. `sso` splits + # because its two halves are different facts: the client id must match the + # entry in authelia's register, the secret is a path on this machine. + # + # `mirrors` renames in one entry rather than one per field: it is a single + # option of a list-of-submodule type, so the rename carries its whole + # value. The `ci` block above needs five because it is a plain attrset of + # separate options, which is the case with no parent path to rename. + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "behindGateway" ] + [ "services" "hyperhive" "deploy" "forgejo" "behindGateway" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "openFirewall" ] + [ "services" "hyperhive" "deploy" "forgejo" "openFirewall" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "mirrors" ] + [ "services" "hyperhive" "deploy" "forgejo" "mirrors" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "sso" "clientSecretFile" ] + [ "services" "hyperhive" "deploy" "forgejo" "sso" "clientSecretFile" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "forge" "hostSwarmControllerTokenFile" ] + [ "services" "hyperhive" "deploy" "forgejo" "hostSwarmControllerTokenFile" ] + ) + # Retention is read only where the container is defined, so it is a # decision of the host running the store rather than something the swarm # agrees on. The two stores keep everything else — package, domain, port diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 8539e6b4..9882bead 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -220,8 +220,8 @@ in # breaks the moment the operator's browser hostname isn't the forge # host, e.g. through the gateway or a reverse proxy). Sourced from # `services.hyperhive.swarm.forge.publicUrl`, which itself defaults to the - # gateway vhost URL when `behindGateway = true` and `null` otherwise - # — see that option's doc for the "hide, don't guess" rationale. + # gateway vhost URL when `deploy.forgejo.behindGateway = true` and `null` + # otherwise — see that option's doc for the "hide, don't guess" rationale. # Absent here whenever `publicUrl` is `null`; the dashboard hides # forge links rather than emitting one it can't justify. HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.swarm.forge.publicUrl; diff --git a/nix/host-modules/hive-ci.nix b/nix/host-modules/hive-ci.nix index e52f8adb..1e41285d 100644 --- a/nix/host-modules/hive-ci.nix +++ b/nix/host-modules/hive-ci.nix @@ -7,6 +7,7 @@ let cfg = config.services.hyperhive.deploy.forgejo.ci; forgeCfg = config.services.hyperhive.swarm.forge; + forgeDeployCfg = config.services.hyperhive.deploy.forgejo; gatewayCfg = config.services.hyperhive.gateway; networkCfg = config.services.hyperhive.network; tlsCfg = config.services.hyperhive.deploy.hive-controller.tls; @@ -79,8 +80,8 @@ in example = true; description = '' Run a Forgejo Actions runner in a `hive-ci` nixos-container. - Grouped under `services.hyperhive.swarm.forge` because the runner is - tightly coupled to the forge instance it registers against. + Grouped under `services.hyperhive.deploy.forgejo` because the runner + is tightly coupled to the forge instance it registers against. Disabled by default; the internal forge it registers against is always present (mandatory), so enabling this is all that's needed. @@ -153,17 +154,17 @@ in }; config = lib.mkIf cfg.enable { - # `forge.behindGateway = true` (the default) is required because the - # CI container uses private networking and reaches the forge through + # `deploy.forgejo.behindGateway = true` (the default) is required because + # the CI container uses private networking and reaches the forge through # the gateway vhost. Without the gateway vhost there is no HTTP # listener for `forgeCfg.domain` on the bridge that the runner can # connect to. assertions = [ { - assertion = forgeCfg.behindGateway; + assertion = forgeDeployCfg.behindGateway; message = '' services.hyperhive.deploy.forgejo.ci.enable requires - services.hyperhive.swarm.forge.behindGateway = true. + services.hyperhive.deploy.forgejo.behindGateway = true. The CI container runs with a private network namespace and reaches the forge through the gateway vhost on the bridge IP. Set behindGateway = true (it defaults to true alongside @@ -216,8 +217,8 @@ in # Private network namespace, attached to the hive bridge so the # runner reaches the forge via the gateway — and cannot reach # host-loopback (127.0.0.1:7000 dashboard, raw forge port, etc.). - # Requires `forge.behindGateway = true` (asserted in the options - # block above). See docs/networking/network.md. + # Requires `deploy.forgejo.behindGateway = true` (asserted in the + # options block above). See docs/networking/network.md. privateNetwork = true; hostBridge = networkCfg.bridgeName; diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 43bc9ea1..b89b5120 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -75,7 +75,7 @@ let # links. Operators can still override via `cfg.rootUrl` for bespoke # shapes. defaultRootUrl = - if cfg.behindGateway then + if deployCfg.forgejo.behindGateway then let portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}"; in @@ -98,9 +98,9 @@ let # operator hasn't already declared that dest themselves (else CI-on + # an explicit `actions/checkout` entry would duplicate it). effectiveMirrors = - cfg.mirrors + deployCfg.forgejo.mirrors ++ lib.optional ( - ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.mirrors) + ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) deployCfg.forgejo.mirrors) ) actionCheckoutMirror; in { @@ -133,7 +133,7 @@ in Removed rather than defaulted to true so a config that turned it OFF fails here, where the line is, instead of silently gaining a login provider on the next rebuild. Drop the line; if it was - false, set services.hyperhive.swarm.forge.sso.clientSecretFile and + false, set services.hyperhive.deploy.forgejo.sso.clientSecretFile and services.hyperhive.swarm.authelia.url as the assertions describe. '') ]; @@ -178,7 +178,7 @@ in description = '' Public hostname for the forge. Doubles as both the forgejo `DOMAIN` setting (clone URLs forgejo advertises) AND the - gateway vhost server-name when `behindGateway = true` + gateway vhost server-name when `deploy.forgejo.behindGateway = true` (sub-domain routing — see `docs/networking/gateway.md`). Defaults to `forge.''${services.hyperhive.swarm.domain}` — the @@ -201,7 +201,10 @@ in publicUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if config.services.hyperhive.enable && cfg.behindGateway then "https://${cfg.domain}" else null; + if config.services.hyperhive.enable && deployCfg.forgejo.behindGateway then + "https://${cfg.domain}" + else + null; defaultText = lib.literalExpression '' if behindGateway then "https://''${domain}" else null ''; @@ -212,7 +215,7 @@ in the approval-queue's "review PR on forge" link) — sourced into every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`. - Defaults to `https://''${cfg.domain}` when `behindGateway = + Defaults to `https://''${cfg.domain}` when `deploy.forgejo.behindGateway = true` (the gateway vhost is genuinely reachable at that URL) and `null` otherwise. When `null`, the dashboard **hides** forge links rather than guessing one — see @@ -221,7 +224,7 @@ in container port is only an accident away from wrong on any deployment that isn't plain localhost). - **Set this explicitly if `behindGateway = false`** and the + **Set this explicitly if `deploy.forgejo.behindGateway = false`** and the forge is still reachable at a stable URL you want linked from the dashboard (e.g. `http://:''${toString cfg.httpPort}` for an all-LAN deployment) — leaving it unset there means the @@ -244,6 +247,60 @@ in ''; }; + rootUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://forge.example.com/"; + description = '' + Override the auto-derived forgejo `ROOT_URL`. When `null` + (default), `ROOT_URL` is derived from `cfg.domain` + gateway + state, including the scheme: + + - `deploy.forgejo.behindGateway = true` → `https://''${cfg.domain}/`. The gateway + always terminates TLS (self-signed is the implicit floor when no + `gateway.tls.certDir` / ACME is set), so the forge is always + advertised over https. A non-canonical `gateway.httpsPort` is + appended as `:`. + - `deploy.forgejo.behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` + + The TLS scheme is derived automatically now, so you only need to + set this for a genuinely bespoke shape (e.g. an external reverse + proxy on a different host/path). Must end with `/` per forgejo's + `ROOT_URL` contract. + ''; + }; + + # The swarm's authelia is always registered as an OpenID Connect + # login source here — there is no toggle, for the same reason the + # forge itself has none. + # + # **Additive, never exclusive.** Forgejo keeps its local password + # database and gains an extra "sign in with" button; this does not + # disable local login. Deliberate: an identity provider that can take + # the forge offline when it hiccups is a worse forge than one with + # two ways in — which is also what makes always-on safe. + sso = { + clientId = lib.mkOption { + type = lib.types.str; + default = "forgejo"; + description = '' + OAuth2 client id this forge identifies itself with. Must match + the `id` of the corresponding entry in + `services.hyperhive.swarm.authelia.oidc.clients`. + ''; + }; + # The secret half is a path on the host that runs the forge, so it + # lives under `deploy.forgejo.sso` — see the block below. + }; + }; + + # What stays above is what the forge IS from any hive's point of view: its + # package, the names and ports it answers on, the URLs it advertises, and + # the client id it is registered under. What lives here is what the host + # running it decides — how it is served, what it mirrors, and where its + # host-local secrets sit. Same rule as ./swarm-victorialogs.nix, and the + # renames are in ./deploy.nix with the rest. + options.services.hyperhive.deploy.forgejo = { behindGateway = lib.mkOption { type = lib.types.bool; default = config.services.hyperhive.enable; @@ -274,29 +331,6 @@ in ''; }; - rootUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "https://forge.example.com/"; - description = '' - Override the auto-derived forgejo `ROOT_URL`. When `null` - (default), `ROOT_URL` is derived from `cfg.domain` + gateway - state, including the scheme: - - - `behindGateway = true` → `https://''${cfg.domain}/`. The gateway - always terminates TLS (self-signed is the implicit floor when no - `gateway.tls.certDir` / ACME is set), so the forge is always - advertised over https. A non-canonical `gateway.httpsPort` is - appended as `:`. - - `behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` - - The TLS scheme is derived automatically now, so you only need to - set this for a genuinely bespoke shape (e.g. an external reverse - proxy on a different host/path). Must end with `/` per forgejo's - `ROOT_URL` contract. - ''; - }; - openFirewall = lib.mkOption { type = lib.types.bool; default = false; @@ -312,8 +346,8 @@ in **Breaking change**: this used to default to `true`. If you relied on the old default for external reach, add - `services.hyperhive.swarm.forge.openFirewall = true;` to your host - config before rebuilding. + `services.hyperhive.deploy.forgejo.openFirewall = true;` to your + host config before rebuilding. ''; }; @@ -360,45 +394,27 @@ in ''; }; - # The swarm's authelia is always registered as an OpenID Connect - # login source here — there is no toggle, for the same reason the - # forge itself has none. - # - # **Additive, never exclusive.** Forgejo keeps its local password - # database and gains an extra "sign in with" button; this does not - # disable local login. Deliberate: an identity provider that can take - # the forge offline when it hiccups is a worse forge than one with - # two ways in — which is also what makes always-on safe. - sso = { - clientId = lib.mkOption { - type = lib.types.str; - default = "forgejo"; - description = '' - OAuth2 client id this forge identifies itself with. Must match - the `id` of the corresponding entry in - `services.hyperhive.swarm.authelia.oidc.clients`. - ''; - }; + # The other half of the SSO pair: the client *id* is swarm-wide (it has + # to match authelia's register), the secret is a path on this host. The + # design note for the login source itself is with the id. + sso.clientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/var/lib/hyperhive/forge-oidc-secret"; + description = '' + Path **inside the forge container** holding the client + secret's plaintext. - clientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "/var/lib/hyperhive/forge-oidc-secret"; - description = '' - Path **inside the forge container** holding the client - secret's plaintext. + A path, never a value: an OIDC client secret has two holders + in two containers (authelia keeps a hash, this forge needs the + plaintext), and a literal written here would be rendered into + the world-readable nix store. - A path, never a value: an OIDC client secret has two holders - in two containers (authelia keeps a hash, this forge needs the - plaintext), and a literal written here would be rendered into - the world-readable nix store. - - Required when `enable` is set — deliberately no fallback. A - forge that boots with SSO half-configured presents as a login - button that always fails, which is harder to diagnose than an - eval error. - ''; - }; + Required when `enable` is set — deliberately no fallback. A + forge that boots with SSO half-configured presents as a login + button that always fails, which is harder to diagnose than an + eval error. + ''; }; hostSwarmControllerTokenFile = lib.mkOption { @@ -443,13 +459,13 @@ in # Both halves are gated on `behindGateway`: with it off the operator # fronts forgejo themselves, so this hive must neither claim the # vhost nor answer DNS for it. - services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain; + services.hyperhive.gateway.localNames = lib.optional deployCfg.forgejo.behindGateway cfg.domain; # This swarm-ui quick-links entry, same `behindGateway` guard as the # vhost/DNS name above — with it off, this host doesn't actually # serve `cfg.domain`, so linking to it would be dead. See # `services.hyperhive.swarm.controller.links`'s description. - services.hyperhive.swarm.controller.links = lib.optional cfg.behindGateway { + services.hyperhive.swarm.controller.links = lib.optional deployCfg.forgejo.behindGateway { label = "Forge"; icon = "⚒"; url = "https://${cfg.domain}/"; @@ -466,15 +482,17 @@ in # 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.publishedScrapeTargets = lib.optionalAttrs cfg.behindGateway { - forgejo = "https://${cfg.domain}/metrics"; - }; + services.hyperhive.swarm.otel.publishedScrapeTargets = + lib.optionalAttrs deployCfg.forgejo.behindGateway + { + forgejo = "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 # `docs/networking/gateway.md`. - services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway { + services.nginx.virtualHosts = lib.optionalAttrs deployCfg.forgejo.behindGateway { "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; @@ -543,10 +561,10 @@ in { # Fail at EVAL, not at boot. The alternative failure is a login # button that always 401s, three layers from the missing file. - assertion = cfg.sso.clientSecretFile != null; + assertion = deployCfg.forgejo.sso.clientSecretFile != null; message = '' The forge's SSO login source requires - services.hyperhive.swarm.forge.sso.clientSecretFile — the path + services.hyperhive.deploy.forgejo.sso.clientSecretFile — the path (inside the forge container) holding the OIDC client secret's plaintext. @@ -600,7 +618,7 @@ in # splits on the single slash to create the org + repo. assertion = lib.all (m: lib.length (lib.splitString "/" m.dest) == 2) effectiveMirrors; message = '' - Every services.hyperhive.swarm.forge.mirrors[].dest must be exactly + Every services.hyperhive.deploy.forgejo.mirrors[].dest must be exactly "/" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors} ''; } @@ -618,7 +636,7 @@ in ]) ) effectiveMirrors; message = '' - services.hyperhive.swarm.forge.mirrors[].dest must not place a mirror + services.hyperhive.deploy.forgejo.mirrors[].dest must not place a mirror in a hive-c0re-managed org (config / shared / agents / core) — those are provisioned by hive-c0re and a mirror there would collide. Use a dedicated org (e.g. "actions/checkout"). @@ -771,7 +789,7 @@ in # 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; + metrics.ENABLED = deployCfg.forgejo.behindGateway; # The two per-dimension breakdowns, on the same condition as # the endpoint itself: `gitea_issues_by_label{label=…}` and # `gitea_issues_by_repository{repository=…}`. Off by default @@ -790,8 +808,8 @@ in # forge that grew to thousands of repos would want this # revisited — that is a real trigger, unlike a time-based one: # `count(gitea_issues_by_repository)` answers it directly. - metrics.ENABLED_ISSUE_BY_LABEL = cfg.behindGateway; - metrics.ENABLED_ISSUE_BY_REPOSITORY = cfg.behindGateway; + metrics.ENABLED_ISSUE_BY_LABEL = deployCfg.forgejo.behindGateway; + metrics.ENABLED_ISSUE_BY_REPOSITORY = deployCfg.forgejo.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 @@ -1025,9 +1043,9 @@ in script = '' set -euo pipefail - secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile}) + secret=$(cat ${lib.escapeShellArg deployCfg.forgejo.sso.clientSecretFile}) if [ -z "$secret" ]; then - echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2 + echo "empty OIDC client secret at ${deployCfg.forgejo.sso.clientSecretFile}" >&2 exit 1 fi @@ -1204,7 +1222,7 @@ in # Same case, same reasoning: this host minted the secret, so it can # say where the forge will find it. - services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal ( + services.hyperhive.deploy.forgejo.sso.clientSecretFile = lib.mkIf ssoLocal ( lib.mkDefault forgeSecretPath ); @@ -1298,7 +1316,7 @@ in set -euo pipefail src=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${swarmControllerTokenPath}"} - dst=${lib.escapeShellArg cfg.hostSwarmControllerTokenFile} + dst=${lib.escapeShellArg deployCfg.forgejo.hostSwarmControllerTokenFile} for _ in $(seq 1 60); do [ -s "$src" ] && break @@ -1313,7 +1331,7 @@ in ''; }; - networking.firewall = lib.mkIf cfg.openFirewall { + networking.firewall = lib.mkIf deployCfg.forgejo.openFirewall { allowedTCPPorts = [ cfg.httpPort cfg.sshPort diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 9684293f..afb989f6 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1361,7 +1361,7 @@ in # 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 deployCfg.forgejo.behindGateway metricsRule # Also guarded on the domain being set: without it a null # apex would render a rule matching the string "null". ++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) { diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index e46f2c2e..bb7d8ddd 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -413,8 +413,8 @@ in forgeTokenFile = lib.mkOption { type = lib.types.nullOr lib.types.str; - # `services.hyperhive.swarm.forge` has no `enable` of its own to - # check — the module activates on the general + # The forge has no `enable` of its own to check — neither half of + # its split namespace carries one — the module activates on the general # `config.services.hyperhive.enable` instead (see # `hive-forge/default.nix`'s own `config = lib.mkIf # config.services.hyperhive.enable { ... }`), so that's the @@ -423,7 +423,8 @@ in # that turns swarm-controller on — caught in review, not by # `nix flake check` (nothing in its checked combinations forced # this particular default to actually evaluate). - default = if config.services.hyperhive.enable then forgeCfg.hostSwarmControllerTokenFile else null; + default = + if config.services.hyperhive.enable then deployCfg.forgejo.hostSwarmControllerTokenFile else null; defaultText = lib.literalExpression '' forge's own `hostSwarmControllerTokenFile` when this host runs hyperhive at all (forge has no separate enable), else null @@ -440,7 +441,7 @@ in `hive-forge/default.nix`, it has no `enable` of its own). Override explicitly if forge's actual token file ends up somewhere else — copy it out of forge's - {option}`services.hyperhive.swarm.forge.hostSwarmControllerTokenFile` + {option}`services.hyperhive.deploy.forgejo.hostSwarmControllerTokenFile` with whatever secret management this deployment already uses, the same shape `swarm.nix`'s `clientSecretFile` documents for its own cross-host case. `null` means no forge access — the diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 396d356a..9af2b083 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -72,6 +72,23 @@ let swarm.wireguard.privateKeyFile = "/etc/wireguard/hive.key"; }; + # Same shape for the forge, which SPLIT rather than moving whole: the five + # host-side options are set here through their pre-rename paths while the + # rest of `swarm.forge` stays put. All five are defined so that dropping any + # single shim entry fails the eval, not just the two the assertion reads. + forgeOldPath = hive { + swarm.forge.behindGateway = true; + swarm.forge.openFirewall = true; + swarm.forge.hostSwarmControllerTokenFile = "/etc/forge/sc.token"; + swarm.forge.sso.clientSecretFile = "/etc/forge/oidc.secret"; + swarm.forge.mirrors = [ + { + upstream = "https://example.invalid/tool"; + dest = "mirrors/tool"; + } + ]; + }; + baoPkcs11 = hive { deploy.bao.enable = true; deploy.bao.seal = "pkcs11"; @@ -205,6 +222,20 @@ let in wg.ips == [ "10.100.0.1/24" ] && wg.privateKeyFile == "/etc/wireguard/hive.key"; } + { + # Same reasoning one namespace over, plus the shape the mesh did not + # have: `mirrors` is a single option of a list-of-submodule type, so its + # one rename entry has to carry a whole compound value rather than a + # scalar. Both arms read a rendered effect — the host firewall and the + # env var c0re seeds mirrors from — not the option. + name = "a config written against the pre-rename forge paths still opens the firewall and seeds the mirror"; + ok = + let + ports = forgeOldPath.networking.firewall.allowedTCPPorts; + seeded = builtins.fromJSON forgeOldPath.systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS; + in + builtins.elem 3000 ports && builtins.any (m: m.dest == "mirrors/tool") seeded; + } { # The gateway's per-name issuer choice. If this ever collapses to a # constant, every swarm-service vhost serves a certificate its CA