diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 26e899ad..10c15f01 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -2,11 +2,7 @@ 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.*` (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 +attachments). Configured via `services.hyperhive.swarm.forge.*`. 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 338a019e..a5aa5c75 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`) | `deploy.forgejo.behindGateway` | +| `forge./` | `forge.` | forgejo (`3000`) | `forge.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 deploy.forgejo.behindGateway) +- `forge.` → `127.0.0.1` (when forge.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 `deploy.forgejo.behindGateway`); `external`-kind links are +tracked by `forge.behindGateway`); `external`-kind links are already absolute. See `docs/web-ui/dashboard.md::Container row` for the frontend-side derivation. @@ -333,12 +333,10 @@ 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 + 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 }; -# 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. @@ -359,7 +357,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 (`deploy.forgejo.behindGateway = true`) does +Forgejo served through the gateway (`forge.behindGateway = true`) does not need `openFirewall` — the gateway's own `openFirewall` option covers that path. @@ -375,8 +373,8 @@ covers most cases: | Shape | Auto-derived `ROOT_URL` | |---|---| -| `deploy.forgejo.behindGateway = true` | `https:///` (port suffix omitted when `gateway.httpsPort == 443`) | -| `deploy.forgejo.behindGateway = false` | `http://:/` | +| `behindGateway = true` | `https:///` (port suffix omitted when `gateway.httpsPort == 443`) | +| `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/networking/snapshot-store.md b/docs/networking/snapshot-store.md index 2c71e9b3..efe30c33 100644 --- a/docs/networking/snapshot-store.md +++ b/docs/networking/snapshot-store.md @@ -26,7 +26,7 @@ services.hyperhive.snapshotStore = { }; # The mesh is a hard requirement, and is asserted: -services.hyperhive.deploy.wireguard = { +services.hyperhive.swarm.wireguard = { enable = true; address = "10.100.0.9/24"; privateKeyFile = "/etc/wireguard/hive.key"; @@ -37,7 +37,7 @@ The store host is a swarm member like any other: it gets an entry in `services.hyperhive.swarm.hives`, the same directory every host holds. See [swarm/](../swarm/README.md) for the mesh itself. -Note that the mesh is gated on `deploy.wireguard.enable`, **not** on +Note that the mesh is gated on `swarm.wireguard.enable`, **not** on `c0re.enable` --- a store host runs no hive and would otherwise get no `wg-hive` interface at all. diff --git a/docs/scheduler/ci.md b/docs/scheduler/ci.md index 2640a155..6d1ced37 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 `deploy.forgejo.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 `forge.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.deploy.forgejo.mirrors`: +pull-mirror via `services.hyperhive.swarm.forge.mirrors`: ```nix -services.hyperhive.deploy.forgejo.mirrors = [ +services.hyperhive.swarm.forge.mirrors = [ { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; } { upstream = "https://github.com/example/tool"; dest = "mirrors/tool"; } ]; diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 2729f9d1..88ea7d3b 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -168,7 +168,7 @@ evaluates cleanly points at a real machine that isn't the one you meant. issue. Until then, federation needs CA-issued certs (ACME). See `docs/integrations/matrix.md` for federation firewall + TLS requirements. -3. **WireGuard mesh** (optional) — `deploy.wireguard.enable` reads each +3. **WireGuard mesh** (optional) — `swarm.wireguard.enable` reads each entry's `wireguardPublicKey`/`wireguardEndpoint`/`wireguardAddress` to configure `wg-hive`. See "WireGuard inter-hive mesh" below. @@ -210,7 +210,7 @@ wg pubkey < /etc/wireguard/hive.key # → share this with peer operators ```nix # hive A (pr1ma.example.com, mesh IP 10.100.0.1) services.hyperhive = { - deploy.wireguard = { + swarm.wireguard = { enable = true; privateKeyFile = "/etc/wireguard/hive.key"; address = "10.100.0.1/24"; @@ -237,7 +237,7 @@ services.hyperhive = { # hive B (edge.corp, mesh IP 10.100.0.2) services.hyperhive = { - deploy.wireguard = { + swarm.wireguard = { enable = true; privateKeyFile = "/etc/wireguard/hive.key"; address = "10.100.0.2/24"; diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index d02e51b8..7d1acf16 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -14,7 +14,7 @@ than a convenience. The rule is about what must stay secret, not about credentials generally. **Public material is a value**: a certificate, or a public nkey like -`deploy.nats.calloutUserPublicKey`, is published to every client that connects, +`swarm.nats.calloutUserPublicKey`, is published to every client that connects, so the store is a perfectly good place for it. **The generator and the reader are usually in different containers.** They share @@ -53,8 +53,8 @@ neither is a renaming of the other. | OIDC client secret, digest half | the same mint | `oidc-clients/.digest` | authelia's own half; merged at runtime via `settingsFiles` | | the swarm collector's copy of its OIDC secret | `swarm-otel-oidc-secret.service` copies it from authelia's tree, **when authelia runs on this host** | `/var/lib/swarm-otel-oidc/.secret` inside the `swarm-otel` container | operator provides the file and names it in `swarm.otel.clientSecretFile` — the collector need not share a host with authelia | | authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia | -| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you | -| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `deploy.nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `deploy.nats.calloutUserSeedFile` / `deploy.nats.calloutIssuerSeedFile` | +| wireguard private key | **the operator** — `wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you | +| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` | | the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this | | the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it is stated rather than inferred | n/a — only the host running the store seals anything | @@ -90,7 +90,7 @@ not the definition of the case. ### Minting the queue's callout nkeys -`deploy.nats.autoGenerateCallout` mints both keypairs on the host before the queue +`nats.autoGenerateCallout` mints both keypairs on the host before the queue starts. It is on by default only under `singleHostSwarm` — the one topology where the queue, its responder and the operator are the same person. On every other topology, mint them yourself: @@ -102,8 +102,8 @@ nk -inkey callout-user.seed -pubout # → calloutUserPublicKey nk -inkey issuer.seed -pubout # → calloutIssuerPublicKey ``` -Keep both seeds at `0600` and name them in `deploy.nats.calloutUserSeedFile` / -`deploy.nats.calloutIssuerSeedFile`. Possession of the **issuer** seed is the authority to +Keep both seeds at `0600` and name them in `calloutUserSeedFile` / +`calloutIssuerSeedFile`. Possession of the **issuer** seed is the authority to admit anyone to the queue, so it belongs wherever the responder runs and nowhere else. diff --git a/docs/swarm/services.md b/docs/swarm/services.md index d1ff3fb2..0208b596 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -116,7 +116,7 @@ See `retentionPeriod` below before leaving it at its default. | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `deploy.victoriametrics.retentionPeriod` | Default `5y`. Lower it once you have measured how fast this swarm actually fills a disk — the default is deliberately generous because too-short silently discards history you cannot get back. | | `swarm.grafana.oidc.role` | Default `Admin` for everyone who logs in. Lower to `Viewer`/`Editor` if the swarm grows operators who should not be able to reconfigure Grafana. | -| `deploy.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. | +| `swarm.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. | **Logging in.** Grafana is behind swarm SSO, so the accounts are the authelia ones — there is no separate Grafana password, and the local diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 9c9fb8df..c701e39e 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.deploy.forgejo.behindGateway = true`; `matrix` needs -`services.hyperhive.deploy.matrix.gui.enable = true`. In those cases the command +`services.hyperhive.swarm.forge.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/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 77fc50a2..116ae44a 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -627,7 +627,7 @@ A static matrix web client (default `pkgs.fluffychat-web` rebuilt with `--base-href /matrix/`, swappable via `services.hyperhive.swarm.matrix.gui.package`) served by the hive-gateway nginx container at `/matrix/` when -`services.hyperhive.deploy.matrix.gui.enable` is on (defaults to +`services.hyperhive.swarm.matrix.gui.enable` is on (defaults to `matrix.enable`). c0re signals availability via the `HIVE_MATRIX_GUI_ENABLED` env var → `state.matrix_gui_enabled` in `/api/state`; the gateway does the actual static serving. diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 32556ae3..5b7fc68b 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -75,7 +75,7 @@ pub(super) struct StateSnapshot { forge_present: bool, /// Whether the matrix GUI is reachable at `/matrix/`. Sourced from /// `HIVE_MATRIX_GUI_ENABLED` env var (set by the c0re NixOS module - /// when `services.hyperhive.deploy.matrix.gui.enable` is on). The gateway + /// when `services.hyperhive.swarm.matrix.gui.enable` is on). The gateway /// (`nix/host-modules/hive-gateway/vhosts.nix`) does the actual `/matrix/` static serving; /// this flag is just an availability signal for iris's dashboard /// chrome so the `M4TR1X →` tab doesn't flash when the GUI is off. @@ -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.swarm.forge.publicUrl` (defaults to - /// the gateway vhost URL when `deploy.forgejo.behindGateway = true`, + /// sets from `services.hyperhive.forge.publicUrl` (defaults to + /// the gateway vhost URL when `forge.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 4ebf7a56..c497cbf0 100644 --- a/hivectl/src/open.rs +++ b/hivectl/src/open.rs @@ -26,11 +26,11 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> { ), OpenTarget::Forge => ( urls.forge, - "the public forge URL needs `services.hyperhive.deploy.forgejo.behindGateway = true`", + "the public forge URL needs `services.hyperhive.forge.behindGateway = true`", ), OpenTarget::Matrix => ( urls.matrix, - "the matrix GUI URL needs `services.hyperhive.deploy.matrix.gui.enable = true`", + "the matrix GUI URL needs `services.hyperhive.swarm.matrix.gui.enable = true`", ), }; let url = url.with_context(|| format!("no URL available for this surface — {hint}"))?; diff --git a/hivectl/src/wg.rs b/hivectl/src/wg.rs index ab86431d..272e1e05 100644 --- a/hivectl/src/wg.rs +++ b/hivectl/src/wg.rs @@ -9,7 +9,7 @@ use anyhow::{Context as _, Result, bail}; use crate::util::query_hive_urls; /// Host path of this hive's WireGuard private key (matches the -/// `privateKeyFile` example in the deploy.wireguard nix options). +/// `privateKeyFile` example in the swarm.wireguard nix options). const WG_KEY_PATH: &str = "/etc/wireguard/hive.key"; /// The mesh interface name hive-c0re's nix module brings up. const WG_INTERFACE: &str = "wg-hive"; @@ -116,7 +116,7 @@ pub(crate) async fn wg_init(socket: &Path, address: Option<&str>) -> Result<()> println!("\nPublic key (share this with peer hives — they pass it to `hivectl wg peer`):"); println!(" {pubkey}"); println!("\nAdd to this hive's NixOS config:"); - println!(" services.hyperhive.deploy.wireguard = {{"); + println!(" services.hyperhive.swarm.wireguard = {{"); println!(" enable = true;"); println!(" privateKeyFile = \"{WG_KEY_PATH}\";"); println!(" address = \"{addr}\";"); @@ -238,7 +238,7 @@ pub(crate) fn wg_status() -> Result<()> { if !status.success() { bail!( "`wg show {WG_INTERFACE}` failed — is the mesh enabled + up? \ - (services.hyperhive.deploy.wireguard.enable = true, then deploy)" + (services.hyperhive.swarm.wireguard.enable = true, then deploy)" ); } Ok(()) diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index f1964fc9..a2551a1a 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -41,22 +41,6 @@ in [ "services" "hyperhive" "swarm" "grafana" "enable" ] [ "services" "hyperhive" "deploy" "grafana" "enable" ] ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "grafana" "socketDir" ] - [ "services" "hyperhive" "deploy" "grafana" "socketDir" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "grafana" "datasourceUrl" ] - [ "services" "hyperhive" "deploy" "grafana" "datasourceUrl" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "grafana" "logsDatasourceUrl" ] - [ "services" "hyperhive" "deploy" "grafana" "logsDatasourceUrl" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "grafana" "plugins" ] - [ "services" "hyperhive" "deploy" "grafana" "plugins" ] - ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "victoriametrics" "enable" ] [ "services" "hyperhive" "deploy" "victoriametrics" "enable" ] @@ -69,22 +53,6 @@ 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" ] @@ -130,100 +98,6 @@ in [ "services" "hyperhive" "deploy" "forgejo" "ci" "package" ] ) - # The queue's callout identity. What stays under `swarm.nats` is what the - # queue IS to every hive — the ports it answers on, the client id it is - # registered under, the responder package; these five are what the host - # running it decides. - # - # ⚠️ The two PUBLIC keys move with their seeds. A public key looks - # swarm-wide, and the user key genuinely is published to every client that - # connects — but peers RECEIVE it over the wire, they never configure it, - # and splitting a keypair across two namespaces is worse than either - # placement. `enable` moved earlier; its shim is the one above. - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "nats" "autoGenerateCallout" ] - [ "services" "hyperhive" "deploy" "nats" "autoGenerateCallout" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "nats" "calloutUserPublicKey" ] - [ "services" "hyperhive" "deploy" "nats" "calloutUserPublicKey" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "nats" "calloutIssuerPublicKey" ] - [ "services" "hyperhive" "deploy" "nats" "calloutIssuerPublicKey" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "nats" "calloutUserSeedFile" ] - [ "services" "hyperhive" "deploy" "nats" "calloutUserSeedFile" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "nats" "calloutIssuerSeedFile" ] - [ "services" "hyperhive" "deploy" "nats" "calloutIssuerSeedFile" ] - ) - - # 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" ] - ) - - # The homeserver, split the same way. `swarm.matrix` keeps what it IS to - # every hive — its package, the name it answers to, the ports and URLs it - # is reached on, the client id it is registered under. These six are what - # the host running it decides. ⚠️ `enable` is NOT here: it was renamed - # earlier and its shim lives in ./hive-matrix.nix, so that file is the one - # to check before assuming this table is the whole matrix story. - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "matrix" "openFirewall" ] - [ "services" "hyperhive" "deploy" "matrix" "openFirewall" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "matrix" "trustedServers" ] - [ "services" "hyperhive" "deploy" "matrix" "trustedServers" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "matrix" "maxRequestSize" ] - [ "services" "hyperhive" "deploy" "matrix" "maxRequestSize" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "matrix" "registrationTokenFile" ] - [ "services" "hyperhive" "deploy" "matrix" "registrationTokenFile" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "matrix" "gui" "enable" ] - [ "services" "hyperhive" "deploy" "matrix" "gui" "enable" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "matrix" "sso" "clientSecretFile" ] - [ "services" "hyperhive" "deploy" "matrix" "sso" "clientSecretFile" ] - ) - # 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 @@ -274,34 +148,6 @@ in [ "services" "hyperhive" "tls" "leafValidityDays" ] [ "services" "hyperhive" "deploy" "hive-controller" "tls" "leafValidityDays" ] ) - - # The WireGuard mesh, whole. Unlike every rename above this one moves a - # namespace rather than a toggle: nothing under it is a fact another hive - # reads. A peer needs this host's `wireguardEndpoint` — the roster entry - # in ./swarm.nix, which stays — and nothing about the interface this host - # brings up. `listenPort` moves for the same reason and is easy to read - # the other way: it is what this host *binds*, while the port a peer - # *dials* is the one inside `wireguardEndpoint`. - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "wireguard" "enable" ] - [ "services" "hyperhive" "deploy" "wireguard" "enable" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "wireguard" "privateKeyFile" ] - [ "services" "hyperhive" "deploy" "wireguard" "privateKeyFile" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "wireguard" "address" ] - [ "services" "hyperhive" "deploy" "wireguard" "address" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "wireguard" "listenPort" ] - [ "services" "hyperhive" "deploy" "wireguard" "listenPort" ] - ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "wireguard" "persistentKeepalive" ] - [ "services" "hyperhive" "deploy" "wireguard" "persistentKeepalive" ] - ) ]; # ⚠️ `deploy.forgejo` is declared in ./hive-ci.nix, not here, and it is the diff --git a/nix/host-modules/glue-matrix-bao-token.nix b/nix/host-modules/glue-matrix-bao-token.nix index 89f77cc2..fabdba5a 100644 --- a/nix/host-modules/glue-matrix-bao-token.nix +++ b/nix/host-modules/glue-matrix-bao-token.nix @@ -31,6 +31,7 @@ let hyperhiveCfg = config.services.hyperhive; deployCfg = hyperhiveCfg.deploy; baoCfg = hyperhiveCfg.swarm.bao; + matrixCfg = hyperhiveCfg.swarm.matrix; baoDeploy = deployCfg.bao; @@ -47,9 +48,8 @@ let # A literal, not an option — ./hive-matrix.nix names its container # `containers.hive-matrix` directly and declares no `machine` to derive it # from, which the trust-bundle call in that file already says out loud. - # ⚠️ A `swarm.matrix.machine` read parses fine and fails at module-system - # resolution, so this is the kind of mistake only reading the target module - # catches. + # ⚠️ `matrixCfg.machine` parses fine and fails at module-system resolution, + # so this is the kind of mistake only reading the target module catches. matrixMachine = "hive-matrix"; in { @@ -115,8 +115,8 @@ in fi umask 077 - printf '%s\n' "$token" > ${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)} - chmod 0600 ${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)} + printf '%s\n' "$token" > ${lib.escapeShellArg (toString matrixCfg.registrationTokenFile)} + chmod 0600 ${lib.escapeShellArg (toString matrixCfg.registrationTokenFile)} ''; }; }; diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 5a56cf7b..8539e6b4 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -200,7 +200,7 @@ in # one. HIVE_MATRIX_API_URL = config.services.hyperhive.swarm.matrix.apiUrl; } -// lib.optionalAttrs config.services.hyperhive.deploy.matrix.gui.enable { +// lib.optionalAttrs config.services.hyperhive.swarm.matrix.gui.enable { # Availability flags read by the dashboard's `/api/state`. # Matrix GUI lives entirely on the gateway nginx (matrix tab # only shows when both flags are on). Gateway routing detail: @@ -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 `deploy.forgejo.behindGateway = true` and `null` - # otherwise — see that option's doc for the "hide, don't guess" rationale. + # gateway vhost URL when `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; @@ -229,7 +229,7 @@ in // lib.optionalAttrs ( - config.services.hyperhive.deploy.matrix.gui.enable + config.services.hyperhive.swarm.matrix.gui.enable && config.services.hyperhive.swarm.matrix.gatewayHost != null ) { diff --git a/nix/host-modules/hive-ci.nix b/nix/host-modules/hive-ci.nix index 1e41285d..e52f8adb 100644 --- a/nix/host-modules/hive-ci.nix +++ b/nix/host-modules/hive-ci.nix @@ -7,7 +7,6 @@ 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; @@ -80,8 +79,8 @@ in example = true; description = '' Run a Forgejo Actions runner in a `hive-ci` nixos-container. - Grouped under `services.hyperhive.deploy.forgejo` because the runner - is tightly coupled to the forge instance it registers against. + Grouped under `services.hyperhive.swarm.forge` 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. @@ -154,17 +153,17 @@ in }; config = lib.mkIf cfg.enable { - # `deploy.forgejo.behindGateway = true` (the default) is required because - # the CI container uses private networking and reaches the forge through + # `forge.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 = forgeDeployCfg.behindGateway; + assertion = forgeCfg.behindGateway; message = '' services.hyperhive.deploy.forgejo.ci.enable requires - services.hyperhive.deploy.forgejo.behindGateway = true. + services.hyperhive.swarm.forge.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 @@ -217,8 +216,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 `deploy.forgejo.behindGateway = true` (asserted in the - # options block above). See docs/networking/network.md. + # Requires `forge.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 b89b5120..43bc9ea1 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 deployCfg.forgejo.behindGateway then + if cfg.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 = - deployCfg.forgejo.mirrors + cfg.mirrors ++ lib.optional ( - ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) deployCfg.forgejo.mirrors) + ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.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.deploy.forgejo.sso.clientSecretFile and + false, set services.hyperhive.swarm.forge.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 `deploy.forgejo.behindGateway = true` + gateway vhost server-name when `behindGateway = true` (sub-domain routing — see `docs/networking/gateway.md`). Defaults to `forge.''${services.hyperhive.swarm.domain}` — the @@ -201,10 +201,7 @@ in publicUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if config.services.hyperhive.enable && deployCfg.forgejo.behindGateway then - "https://${cfg.domain}" - else - null; + if config.services.hyperhive.enable && cfg.behindGateway then "https://${cfg.domain}" else null; defaultText = lib.literalExpression '' if behindGateway then "https://''${domain}" else null ''; @@ -215,7 +212,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 `deploy.forgejo.behindGateway = + Defaults to `https://''${cfg.domain}` when `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 @@ -224,7 +221,7 @@ in container port is only an accident away from wrong on any deployment that isn't plain localhost). - **Set this explicitly if `deploy.forgejo.behindGateway = false`** and the + **Set this explicitly if `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 @@ -247,60 +244,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: - - - `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; @@ -331,6 +274,29 @@ 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; @@ -346,8 +312,8 @@ in **Breaking change**: this used to default to `true`. If you relied on the old default for external reach, add - `services.hyperhive.deploy.forgejo.openFirewall = true;` to your - host config before rebuilding. + `services.hyperhive.swarm.forge.openFirewall = true;` to your host + config before rebuilding. ''; }; @@ -394,27 +360,45 @@ in ''; }; - # 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. + # 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`. + ''; + }; - 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. + 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. - 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. - ''; + 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. + ''; + }; }; hostSwarmControllerTokenFile = lib.mkOption { @@ -459,13 +443,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 deployCfg.forgejo.behindGateway cfg.domain; + services.hyperhive.gateway.localNames = lib.optional cfg.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 deployCfg.forgejo.behindGateway { + services.hyperhive.swarm.controller.links = lib.optional cfg.behindGateway { label = "Forge"; icon = "⚒"; url = "https://${cfg.domain}/"; @@ -482,17 +466,15 @@ 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 deployCfg.forgejo.behindGateway - { - forgejo = "https://${cfg.domain}/metrics"; - }; + services.hyperhive.swarm.otel.publishedScrapeTargets = lib.optionalAttrs cfg.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 deployCfg.forgejo.behindGateway { + services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway { "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; @@ -561,10 +543,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 = deployCfg.forgejo.sso.clientSecretFile != null; + assertion = cfg.sso.clientSecretFile != null; message = '' The forge's SSO login source requires - services.hyperhive.deploy.forgejo.sso.clientSecretFile — the path + services.hyperhive.swarm.forge.sso.clientSecretFile — the path (inside the forge container) holding the OIDC client secret's plaintext. @@ -618,7 +600,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.deploy.forgejo.mirrors[].dest must be exactly + Every services.hyperhive.swarm.forge.mirrors[].dest must be exactly "/" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors} ''; } @@ -636,7 +618,7 @@ in ]) ) effectiveMirrors; message = '' - services.hyperhive.deploy.forgejo.mirrors[].dest must not place a mirror + services.hyperhive.swarm.forge.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"). @@ -789,7 +771,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 = deployCfg.forgejo.behindGateway; + metrics.ENABLED = cfg.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 @@ -808,8 +790,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 = deployCfg.forgejo.behindGateway; - metrics.ENABLED_ISSUE_BY_REPOSITORY = deployCfg.forgejo.behindGateway; + metrics.ENABLED_ISSUE_BY_LABEL = cfg.behindGateway; + metrics.ENABLED_ISSUE_BY_REPOSITORY = cfg.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 @@ -1043,9 +1025,9 @@ in script = '' set -euo pipefail - secret=$(cat ${lib.escapeShellArg deployCfg.forgejo.sso.clientSecretFile}) + secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile}) if [ -z "$secret" ]; then - echo "empty OIDC client secret at ${deployCfg.forgejo.sso.clientSecretFile}" >&2 + echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2 exit 1 fi @@ -1222,7 +1204,7 @@ in # Same case, same reasoning: this host minted the secret, so it can # say where the forge will find it. - services.hyperhive.deploy.forgejo.sso.clientSecretFile = lib.mkIf ssoLocal ( + services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal ( lib.mkDefault forgeSecretPath ); @@ -1316,7 +1298,7 @@ in set -euo pipefail src=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${swarmControllerTokenPath}"} - dst=${lib.escapeShellArg deployCfg.forgejo.hostSwarmControllerTokenFile} + dst=${lib.escapeShellArg cfg.hostSwarmControllerTokenFile} for _ in $(seq 1 60); do [ -s "$src" ] && break @@ -1331,7 +1313,7 @@ in ''; }; - networking.firewall = lib.mkIf deployCfg.forgejo.openFirewall { + networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.httpPort cfg.sshPort diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index fe71e7f7..c3130905 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -22,7 +22,6 @@ let # same list rather than each deciding what "a swarm service" means. swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; matrixCfg = config.services.hyperhive.swarm.matrix; - matrixDeployCfg = config.services.hyperhive.deploy.matrix; networkCfg = config.services.hyperhive.network; # Every vhost claiming `default_server`, ours and the operator's @@ -105,7 +104,6 @@ let cfg errorPages matrixCfg - matrixDeployCfg hyperhiveDomain dashboardDist swaggerUiTheme diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 7c4b1ce9..0a9d2752 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -15,8 +15,7 @@ { lib, cfg, # services.hyperhive.gateway - matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS - matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs + matrixCfg, hyperhiveDomain, dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css @@ -45,8 +44,7 @@ let # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link # shim during the fluffychat sub-domain move). See `docs/networking/gateway.md`. matrixRedirectLocations = - lib.optionalAttrs - (matrixDeployCfg.enable && matrixDeployCfg.gui.enable && matrixCfg.gatewayHost != null) + lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) ( let target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; @@ -64,7 +62,7 @@ let # clients at `matrixCfg.gatewayHost` when set; falls back to direct # `:`. CORS `*` per matrix spec. The `m.server` # port-8448 carve-out is documented inline. See `docs/networking/gateway.md`. - wellKnownLocations = lib.optionalAttrs matrixDeployCfg.enable ( + wellKnownLocations = lib.optionalAttrs matrixCfg.enable ( let clientBaseUrl = if matrixCfg.gatewayHost != null then diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index fe6e39e0..cdeaf9ef 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -178,7 +178,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 flow on the next rebuild. Drop the line; if it was false, - set services.hyperhive.deploy.matrix.sso.clientSecretFile and + set services.hyperhive.swarm.matrix.sso.clientSecretFile and services.hyperhive.swarm.authelia.url as the assertions describe. '') ]; @@ -312,91 +312,6 @@ in ''; }; - allowEncryption = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Server-side switch for matrix end-to-end encryption — sets - tuwunel's `allow_encryption`. Off by default: on the hive-internal - homeserver the operator already controls the transport, so server - E2EE adds key-management overhead (cross-signing, device - verification, undecryptable-message recovery) without a clear - threat-model win for the common single-hive case. Turn on when - agents join encrypted rooms on external / federated homeservers, - or when the operator wants message contents opaque to the - homeserver admin. Independent of the agent matrix client, which - always supports decryption so it can read encrypted rooms it is - invited to regardless of this flag; this option only governs - whether THIS homeserver permits room encryption. - ''; - }; - - gui = { - # Whether this host serves it is `deploy.matrix.gui.enable`. - package = lib.mkOption { - type = lib.types.package; - default = fluffychat-web-fixed; - defaultText = lib.literalMD '' - `pkgs.fluffychat-web` with a `postInstall` patch that adds - the three files `flutter341.buildFlutterApplication` skips. - ''; - description = '' - Static web client dist served at `matrix./`. Override - to swap fluffychat for hydrogen-web, cinny, element-web, or - an out-of-tree dist — any replacement is mounted at the - sub-domain root with the upstream-default ``, - no sub-path gymnastics needed. - ''; - }; - }; - - # This homeserver always delegates login to the swarm's authelia, as - # an OIDC relying party — matrix SSO (`m.login.sso`), offered - # alongside password login. No toggle: a homeserver in a swarm is a - # client of that swarm's identity provider. - # - # ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which point - # the other way: those make this homeserver an *authorization server* - # for matrix clients. This family makes it a *client* of an external - # identity provider. The two share the protocol's name and answer - # opposite questions. - # - # This **adds** a way in. Password login keeps working: an identity - # provider that can take the homeserver offline when it hiccups is a - # worse homeserver than one with two ways in — which is also what - # makes always-on safe. Making authelia the *only* path is a - # separate, reversible switch (tuwunel's `login_with_password`), - # deliberately not folded in here. - # - # ⚠️ Matrix SSO lives **inside** the homeserver, never behind a - # forward-auth proxy: the client-server API is spoken by non-browser - # clients holding matrix access tokens — every agent's own - # `hive-matrix-daemon` — plus federation, and a proxy in front of - # `/_matrix/` breaks all of it. - sso = { - clientId = lib.mkOption { - type = lib.types.str; - default = "tuwunel"; - description = '' - OAuth2 client id this homeserver identifies itself with. Must - match the `id` of the corresponding entry in - `services.hyperhive.swarm.authelia.oidc.clients`. - - The secret it pairs with is a host path, so it lives at - `services.hyperhive.deploy.matrix.sso.clientSecretFile`. - ''; - }; - }; - }; - - # What stays above is what the homeserver IS from any hive's point of view: - # its package, the name it answers to, the ports and URLs it is reached on, - # and the client id it is registered under. What lives here is what the host - # running it decides — whether it is exposed, which peers it trusts, how large - # a request it accepts, and where its host-local secrets sit. Same rule as - # ./swarm-victorialogs.nix; `enable` already lives in ./deploy.nix, which also - # carries the renames. - options.services.hyperhive.deploy.matrix = { openFirewall = lib.mkOption { type = lib.types.bool; default = false; @@ -412,7 +327,7 @@ in **Breaking change**: this used to default to `true`. If you relied on the old default for external reach, add - `services.hyperhive.deploy.matrix.openFirewall = true;` to your host + `services.hyperhive.swarm.matrix.openFirewall = true;` to your host config before rebuilding. Note: federation (the matrix-spec well-known port 8448) is @@ -466,43 +381,112 @@ in ''; }; - gui.enable = lib.mkOption { + allowEncryption = lib.mkOption { type = lib.types.bool; - default = deployCfg.matrix.enable; - defaultText = lib.literalExpression "config.services.hyperhive.deploy.matrix.enable"; + default = false; description = '' - Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`. - Requires `swarm.matrix.gatewayHost != null` (default `matrix.` - when hive-domain set); the gateway itself always runs. When - off, the dashboard's `M4TR1X →` tab is hidden. See - `docs/networking/gateway.md` for the discovery flow that lets clients - auto-find the sub-domain. The client build itself is - `swarm.matrix.gui.package` — which client, as opposed to whether - this host serves it. + Server-side switch for matrix end-to-end encryption — sets + tuwunel's `allow_encryption`. Off by default: on the hive-internal + homeserver the operator already controls the transport, so server + E2EE adds key-management overhead (cross-signing, device + verification, undecryptable-message recovery) without a clear + threat-model win for the common single-hive case. Turn on when + agents join encrypted rooms on external / federated homeservers, + or when the operator wants message contents opaque to the + homeserver admin. Independent of the agent matrix client, which + always supports decryption so it can read encrypted rooms it is + invited to regardless of this flag; this option only governs + whether THIS homeserver permits room encryption. ''; }; - sso.clientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "/var/lib/tuwunel-oidc/tuwunel.secret"; - description = '' - Path **inside the matrix container** holding the client - secret's plaintext. + gui = { + enable = lib.mkOption { + type = lib.types.bool; + default = deployCfg.matrix.enable; + defaultText = lib.literalExpression "config.services.hyperhive.deploy.matrix.enable"; + description = '' + Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`. + Requires `matrix.gatewayHost != null` (default `matrix.` + when hive-domain set); the gateway itself always runs. When + off, the dashboard's `M4TR1X →` tab is hidden. See + `docs/networking/gateway.md` for the discovery flow that lets clients + auto-find the sub-domain. + ''; + }; - A path, never a value: an OIDC client secret has two holders - in two containers (authelia keeps a hash, this homeserver - needs the plaintext), and a literal written here would be - rendered into the world-readable nix store. + package = lib.mkOption { + type = lib.types.package; + default = fluffychat-web-fixed; + defaultText = lib.literalMD '' + `pkgs.fluffychat-web` with a `postInstall` patch that adds + the three files `flutter341.buildFlutterApplication` skips. + ''; + description = '' + Static web client dist served at `matrix./`. Override + to swap fluffychat for hydrogen-web, cinny, element-web, or + an out-of-tree dist — any replacement is mounted at the + sub-domain root with the upstream-default ``, + no sub-path gymnastics needed. + ''; + }; + }; - Deliberately no fallback — a homeserver that boots with SSO - half-configured is worse than one that fails to evaluate: - tuwunel reads OIDC from its config file rather than a database - row, so a malformed block can stop the server outright instead - of merely hiding a button. The id this pairs with is - `swarm.matrix.sso.clientId`, which is swarm-wide because it must - match authelia's register. - ''; + # This homeserver always delegates login to the swarm's authelia, as + # an OIDC relying party — matrix SSO (`m.login.sso`), offered + # alongside password login. No toggle: a homeserver in a swarm is a + # client of that swarm's identity provider. + # + # ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which point + # the other way: those make this homeserver an *authorization server* + # for matrix clients. This family makes it a *client* of an external + # identity provider. The two share the protocol's name and answer + # opposite questions. + # + # This **adds** a way in. Password login keeps working: an identity + # provider that can take the homeserver offline when it hiccups is a + # worse homeserver than one with two ways in — which is also what + # makes always-on safe. Making authelia the *only* path is a + # separate, reversible switch (tuwunel's `login_with_password`), + # deliberately not folded in here. + # + # ⚠️ Matrix SSO lives **inside** the homeserver, never behind a + # forward-auth proxy: the client-server API is spoken by non-browser + # clients holding matrix access tokens — every agent's own + # `hive-matrix-daemon` — plus federation, and a proxy in front of + # `/_matrix/` breaks all of it. + sso = { + clientId = lib.mkOption { + type = lib.types.str; + default = "tuwunel"; + description = '' + OAuth2 client id this homeserver identifies itself with. Must + match the `id` of the corresponding entry in + `services.hyperhive.swarm.authelia.oidc.clients`. + ''; + }; + + clientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/var/lib/tuwunel-oidc/tuwunel.secret"; + description = '' + Path **inside the matrix 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 homeserver + 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 + homeserver that boots with SSO half-configured is worse than + one that fails to evaluate: tuwunel reads OIDC from its + config file rather than a database row, so a malformed block + can stop the server outright instead of merely hiding a + button. + ''; + }; }; }; @@ -536,7 +520,7 @@ in # page). See `services.hyperhive.swarm.controller.links`'s # description. services.hyperhive.swarm.controller.links = - lib.optional (cfg.gatewayHost != null && deployCfg.matrix.gui.enable) + lib.optional (cfg.gatewayHost != null && cfg.gui.enable) { label = "Matrix"; icon = "💬"; @@ -550,7 +534,7 @@ in # contributes instead of replacing it. # # The dashboard needs no equivalent — it routes by path. - services.nginx.appendHttpConfig = lib.optionalString deployCfg.matrix.gui.enable '' + services.nginx.appendHttpConfig = lib.optionalString cfg.gui.enable '' map $http_accept $matrix_spa_target { default "/__matrix_spa_no_html_fallback"; "~*text/html" "/index.html"; @@ -585,7 +569,7 @@ in ''; }; } - // lib.optionalAttrs deployCfg.matrix.gui.enable { + // lib.optionalAttrs cfg.gui.enable { # fluffychat at sub-domain root, SPA-fallback via the # Accept-header `$matrix_spa_target` map above. "/" = { @@ -604,7 +588,7 @@ in ''; }; } - // lib.optionalAttrs (!deployCfg.matrix.gui.enable) { + // lib.optionalAttrs (!cfg.gui.enable) { "/" = { return = "404"; }; @@ -634,10 +618,10 @@ in # Fail at EVAL, not at boot. tuwunel reads its identity providers # from the config file, so a half-configured one does not hide a # login button — it can stop the homeserver from starting at all. - assertion = deployCfg.matrix.sso.clientSecretFile != null; + assertion = cfg.sso.clientSecretFile != null; message = '' This homeserver's SSO login flow requires - services.hyperhive.deploy.matrix.sso.clientSecretFile — the path + services.hyperhive.swarm.matrix.sso.clientSecretFile — the path (inside the matrix container) holding the OIDC client secret's plaintext. @@ -699,7 +683,7 @@ in # Same case, same reasoning: this host minted the secret, so it can say # where the homeserver will find it. - services.hyperhive.deploy.matrix.sso.clientSecretFile = lib.mkIf ssoLocal ( + services.hyperhive.swarm.matrix.sso.clientSecretFile = lib.mkIf ssoLocal ( lib.mkDefault matrixSecretPath ); @@ -745,7 +729,7 @@ in set -euo pipefail src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"} - dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString deployCfg.matrix.sso.clientSecretFile}"} + dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString cfg.sso.clientSecretFile}"} # authelia's container is up, but its first-boot generator may # still be minting. Bounded wait, then fail: a silent skip here @@ -827,7 +811,7 @@ in ); system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] '' - tokenFile=${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)} + tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)} if [ ! -s "$tokenFile" ]; then mkdir -p "$(dirname "$tokenFile")" head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile" @@ -849,8 +833,8 @@ in # Read-only bind of the host-managed registration token; tuwunel # reads it via systemd LoadCredential below (not directly). bindMounts = { - ${deployCfg.matrix.registrationTokenFile} = { - hostPath = deployCfg.matrix.registrationTokenFile; + ${cfg.registrationTokenFile} = { + hostPath = cfg.registrationTokenFile; isReadOnly = true; }; } @@ -939,11 +923,11 @@ in # `address` + `port` are upstream `listOf` — wrap singles. address = [ "0.0.0.0" ]; port = [ cfg.httpPort ]; - max_request_size = deployCfg.matrix.maxRequestSize; + max_request_size = cfg.maxRequestSize; # Federation enabled at the protocol level; empty # trustedServers keeps it effectively closed. allow_federation = true; - trusted_servers = deployCfg.matrix.trustedServers; + trusted_servers = cfg.trustedServers; # Token-gated registration. The absent # `yes_i_am_very_very_sure_…_open_registration_…` flag # keeps the server closed to anyone without the token. @@ -1011,18 +995,18 @@ in # host-side chown :tuwunel / GID-pin gymnastics needed. # See `man systemd.exec` → LoadCredential. systemd.services.tuwunel.serviceConfig.LoadCredential = [ - "registration_token:${toString deployCfg.matrix.registrationTokenFile}" + "registration_token:${toString cfg.registrationTokenFile}" # Same mechanism, second secret. tuwunel re-reads this file on # every OAuth exchange, not just at startup, so it has to # outlive the unit's start — a credentials path does. - "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" + "oidc_client_secret:${toString cfg.sso.clientSecretFile}" ]; environment.systemPackages = [ cfg.package ]; }; }; - networking.firewall = lib.mkIf deployCfg.matrix.openFirewall { + networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.httpPort ]; diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index afa94b7b..528f86de 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -78,16 +78,14 @@ in # the ⚠️ below gives about `swarm`. config.services.hyperhive.deploy.allSwarmServices = lib.mkDefault cfg.deploy.singleHostSwarm; - # The queue's auth-callout nkeys. Generating them is safe exactly - # when one operator owns both the queue and its responder, which is - # what this mode asserts. On any other topology the seeds have to - # reach whoever runs the responder, and minting them here would move - # that hand-off somewhere less visible rather than removing it. - config.services.hyperhive.deploy.nats.autoGenerateCallout = - lib.mkDefault cfg.deploy.singleHostSwarm; - config.services.hyperhive.swarm = { ca.autoConfigure = lib.mkDefault cfg.deploy.singleHostSwarm; + # The queue's auth-callout nkeys. Generating them is safe exactly + # when one operator owns both the queue and its responder, which is + # what this mode asserts. On any other topology the seeds have to + # reach whoever runs the responder, and minting them here would move + # that hand-off somewhere less visible rather than removing it. + nats.autoGenerateCallout = lib.mkDefault cfg.deploy.singleHostSwarm; # The controller's queue coordinates. Co-location is what makes these # derivable at all — loopback only reaches the queue when the queue is # here, and the minted client secret only exists on the host authelia @@ -112,6 +110,9 @@ 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 @@ -128,15 +129,4 @@ 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-authelia.nix b/nix/host-modules/swarm-authelia.nix index afb989f6..9684293f 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 deployCfg.forgejo.behindGateway metricsRule + lib.optional forgeCfg.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 fe1c505e..e46f2c2e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -32,6 +32,7 @@ let SWARMCTL_AUTHELIA_USERS_FILE = autheliaCfg.hostUsersFile; }; + natsCfg = config.services.hyperhive.swarm.nats; forgeCfg = config.services.hyperhive.swarm.forge; swarmDomain = config.services.hyperhive.swarm.domain; @@ -91,15 +92,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 `forgeTokenFile` resolves to a real - # file — which that option's own default already handles + # and NATS run, only with whether `cfg.forgeTokenFile` resolves to a real + # file — which `forgeTokenFile`'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 (deployCfg.swarm-controller.forgeTokenFile != null) { + forgeEnv = lib.optionalAttrs (cfg.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 +138,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 (deployCfg.swarm-controller.authBridgeUrl != null) { - SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl; + authBridgeEnv = lib.optionalAttrs (cfg.authBridgeUrl != null) { + SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl; }; # The swarm's own display name, for `GET /api/swarm` (swarm-ui's chrome). @@ -268,6 +269,27 @@ 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; @@ -319,6 +341,27 @@ 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 { @@ -368,44 +411,10 @@ 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 - # its split namespace carries one — the module activates on the general + # `services.hyperhive.swarm.forge` has no `enable` of its own to + # check — 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 @@ -414,8 +423,7 @@ 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 deployCfg.forgejo.hostSwarmControllerTokenFile else null; + default = if config.services.hyperhive.enable then forgeCfg.hostSwarmControllerTokenFile else null; defaultText = lib.literalExpression '' forge's own `hostSwarmControllerTokenFile` when this host runs hyperhive at all (forge has no separate enable), else null @@ -432,7 +440,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.deploy.forgejo.hostSwarmControllerTokenFile` + {option}`services.hyperhive.swarm.forge.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 @@ -457,37 +465,13 @@ in No new credential to configure: the bearer token presented to the bridge is minted from THIS daemon's own existing queue OIDC - identity — its endpoints stay in `swarm.controller.queue`, its secret is - `queue.clientSecretFile` below — "one identity per principal" + identity (`queue.*` above) — "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) { @@ -571,10 +555,10 @@ in ''; } { - assertion = deployCfg.swarm-controller.queue.clientSecretFile != ""; + assertion = cfg.queue.clientSecretFile != ""; message = '' - services.hyperhive.deploy.swarm-controller.queue.clientSecretFile - is unset. + services.hyperhive.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 @@ -609,11 +593,9 @@ 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:${deployCfg.swarm-controller.queue.clientSecretFile}" + "queue-client.secret:${cfg.queue.clientSecretFile}" ] - ++ lib.optional ( - deployCfg.swarm-controller.forgeTokenFile != null - ) "forge-token:${deployCfg.swarm-controller.forgeTokenFile}"; + ++ lib.optional (cfg.forgeTokenFile != null) "forge-token:${cfg.forgeTokenFile}"; # The placeholder default that makes the above non-fatal. # `LoadCredential=` takes priority over `SetCredential=`, so this is @@ -703,7 +685,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 = deployCfg.swarm-controller.socketPath; + SWARM_CONTROLLER_SOCKET = cfg.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 @@ -768,7 +750,7 @@ in description = "watch the swarm controller's queue credential"; wantedBy = [ "multi-user.target" ]; pathConfig = { - PathChanged = deployCfg.swarm-controller.queue.clientSecretFile; + PathChanged = cfg.queue.clientSecretFile; Unit = "swarm-controller-credential.service"; }; }; diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 3ec186e0..5c083c86 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -92,7 +92,7 @@ let # the OIDC secret above, whose other reader is authelia's container. secretKeyPath = "/var/lib/grafana-secret/secret_key"; - socketPath = "${deployCfg.grafana.socketDir}/grafana.sock"; + socketPath = "${cfg.socketDir}/grafana.sock"; # Both static NixOS ids, and both checked rather than assumed: nginx has # `ids.gids.nginx = 60`, Grafana has `ids.uids.grafana = 196` — but there is @@ -111,12 +111,8 @@ in # `enable` moved to `services.hyperhive.deploy.grafana.enable` — see # ./deploy.nix. Whether this host runs the swarm's Grafana is a # deployment decision, and `swarm.*` has to be identical on every host. - # What stays here is what the service IS to every hive: its package, the - # name it is served under, the port its `/metrics` is re-served on, and - # the OIDC client it is registered as. What the host running it decides — - # where its datasources point, which plugins are in its store path, and - # the socket directory it shares with nginx — is below, under - # `deploy.grafana`. + # What stays here is what the service IS: its package, domain, and + # wiring. options.services.hyperhive.swarm.grafana = { package = lib.mkOption { type = lib.types.package; @@ -152,6 +148,25 @@ in ''; }; + socketDir = lib.mkOption { + type = lib.types.str; + default = "/run/swarm-grafana"; + description = '' + Directory holding the unix socket Grafana serves on, shared between + the host (where nginx runs) and the container (where Grafana runs). + + ⚠️ **Grafana takes no TCP port at all, and that is the point.** Every + swarm service container shares the host's network namespace, so a + port is a swarm-wide resource that two modules can silently both + claim — which is exactly what happened: Grafana defaulted to + upstream's 3000, so does the forge, and `grafana.` + served the forge with no bind error and nothing in any log. + + A socket has a path, and a path collision is a build-time conflict + rather than a runtime coin toss. + ''; + }; + metricsPort = lib.mkOption { type = lib.types.port; default = 9095; @@ -160,7 +175,7 @@ in `/metrics`, and nothing else, so the swarm's collector can scrape it. ⚠️ **This is nginx's port, not Grafana's.** Grafana still claims none — - see {option}`services.hyperhive.deploy.grafana.socketDir` for why that + see {option}`services.hyperhive.swarm.grafana.socketDir` for why that matters. A prometheus scrape target is a `host:port`, and it cannot address a unix socket; rather than undo the socket decision, the one endpoint a scraper needs gets a listener of its own. @@ -179,6 +194,34 @@ in ''; }; + datasourceUrl = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:${toString vmCfg.port}"; + defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victoriametrics.port}"''; + description = '' + Where the provisioned datasource points. Defaults to the metrics + store on this host, which is the only place it can be: that store + binds loopback, so a Grafana somewhere else could not reach it + anyway. Set explicitly if a deployment fronts VictoriaMetrics with + something that does listen wider. + ''; + }; + + logsDatasourceUrl = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:${toString vlCfg.port}"; + defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victorialogs.port}"''; + description = '' + Where the provisioned logs datasource points. Same reasoning as + {option}`services.hyperhive.swarm.grafana.datasourceUrl`: the store + binds loopback, so a Grafana elsewhere could not reach it anyway. + + Provisioned unconditionally, like the metrics datasource — the store + being off is a deployment choice rather than a reason to withhold the + connection, and an operator whose logs live elsewhere sets this. + ''; + }; + oidc = { clientId = lib.mkOption { type = lib.types.str; @@ -214,65 +257,6 @@ in }; }; - }; - - # What stays above is what the service IS to every hive. What the host - # running it decides is here: where its datasources point, which plugins sit - # in its store path, and the directory it shares a socket with nginx - # through. `enable` already lives in ./deploy.nix, which also carries the - # renames. - # - # ⚠️ Both datasource URLs are wiring and still move. A URL's scope is the - # scope of what it ADDRESSES, not the fact that it is a URL: both stores - # bind loopback, so these can only ever mean "on this host". - options.services.hyperhive.deploy.grafana = { - socketDir = lib.mkOption { - type = lib.types.str; - default = "/run/swarm-grafana"; - description = '' - Directory holding the unix socket Grafana serves on, shared between - the host (where nginx runs) and the container (where Grafana runs). - - ⚠️ **Grafana takes no TCP port at all, and that is the point.** Every - swarm service container shares the host's network namespace, so a - port is a swarm-wide resource that two modules can silently both - claim — which is exactly what happened: Grafana defaulted to - upstream's 3000, so does the forge, and `grafana.` - served the forge with no bind error and nothing in any log. - - A socket has a path, and a path collision is a build-time conflict - rather than a runtime coin toss. - ''; - }; - - datasourceUrl = lib.mkOption { - type = lib.types.str; - default = "http://127.0.0.1:${toString vmCfg.port}"; - defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victoriametrics.port}"''; - description = '' - Where the provisioned datasource points. Defaults to the metrics - store on this host, which is the only place it can be: that store - binds loopback, so a Grafana somewhere else could not reach it - anyway. Set explicitly if a deployment fronts VictoriaMetrics with - something that does listen wider. - ''; - }; - - logsDatasourceUrl = lib.mkOption { - type = lib.types.str; - default = "http://127.0.0.1:${toString vlCfg.port}"; - defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victorialogs.port}"''; - description = '' - Where the provisioned logs datasource points. Same reasoning as - {option}`services.hyperhive.deploy.grafana.datasourceUrl`: the store - binds loopback, so a Grafana elsewhere could not reach it anyway. - - Provisioned unconditionally, like the metrics datasource — the store - being off is a deployment choice rather than a reason to withhold the - connection, and an operator whose logs live elsewhere sets this. - ''; - }; - plugins = lib.mkOption { type = lib.types.listOf lib.types.package; default = [ @@ -444,7 +428,7 @@ in # because the host has no `grafana` account — Grafana lives in the # container, and only the number crosses that boundary. systemd.tmpfiles.rules = [ - "d ${deployCfg.grafana.socketDir} 0750 ${toString grafanaUid} ${toString nginxGid} - -" + "d ${cfg.socketDir} 0750 ${toString grafanaUid} ${toString nginxGid} - -" ]; # The secret delivery. It runs on the HOST because that is the only place @@ -529,8 +513,8 @@ in # ownership) and nspawn mounts it in — the same shape the per-agent # `/run/hive-agent//web.sock` already uses. bindMounts = { - ${deployCfg.grafana.socketDir} = { - hostPath = deployCfg.grafana.socketDir; + ${cfg.socketDir} = { + hostPath = cfg.socketDir; isReadOnly = false; }; } @@ -652,7 +636,7 @@ in # hive with no plugins, manual installation is impossible anyway" # — reasoned only about plugins a PERSON installs. Upstream # installs some itself, and those are the ones that went missing. - declarativePlugins = deployCfg.grafana.plugins; + declarativePlugins = cfg.plugins; settings = { server = { @@ -732,7 +716,7 @@ in name = "VictoriaMetrics"; type = "prometheus"; uid = datasourceUid; - url = deployCfg.grafana.datasourceUrl; + url = cfg.datasourceUrl; access = "proxy"; isDefault = true; } @@ -744,7 +728,7 @@ in # fails at use, far from anything naming this file. type = "victoriametrics-logs-datasource"; uid = logsDatasourceUid; - url = deployCfg.grafana.logsDatasourceUrl; + url = cfg.logsDatasourceUrl; access = "proxy"; # Exactly one datasource may claim this, and metrics has it: # two defaults is a coin toss over which one an untyped panel diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 605c5c4a..989522a6 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -55,17 +55,14 @@ let # In auto mode the seeds are minted on this host before the container # starts, so they are configured by construction. responderConfigured = - deployCfg.nats.autoGenerateCallout - || (deployCfg.nats.calloutUserSeedFile != "" && deployCfg.nats.calloutIssuerSeedFile != ""); + cfg.autoGenerateCallout || (cfg.calloutUserSeedFile != "" && cfg.calloutIssuerSeedFile != ""); clientSecretSource = "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret"; introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection"; # Where the responder's seeds actually come from. One name for two # origins, so everything downstream stops caring which mode it is in. - userSeedFile = - if deployCfg.nats.autoGenerateCallout then autoUserSeed else deployCfg.nats.calloutUserSeedFile; - issuerSeedFile = - if deployCfg.nats.autoGenerateCallout then autoIssuerSeed else deployCfg.nats.calloutIssuerSeedFile; + userSeedFile = if cfg.autoGenerateCallout then autoUserSeed else cfg.calloutUserSeedFile; + issuerSeedFile = if cfg.autoGenerateCallout then autoIssuerSeed else cfg.calloutIssuerSeedFile; # Seeds stay on the host at 0600 and never enter the container or the # store: only the responder needs them, and it reads them by @@ -284,32 +281,6 @@ in ''; }; - authPackage = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-nats-auth"; - description = '' - The auth-callout responder package. - - ⚠️ Named `authPackage`, not `package`, on purpose: this module - deliberately has **no** `package` option for the server itself - (see the note above — upstream's `services.nats` resolves - `pkgs.nats-server` on its own), so a bare `package` here would - read as "the NATS package" and mean something else entirely. - ''; - }; - }; - - # What stays above is what the queue IS to every hive: the ports it answers - # on, the client id it is registered under, the responder package. The - # callout identity below is what the host running it decides — whether it - # mints its own keypairs, and where the seeds sit. `enable` already lives in - # ./deploy.nix, which also carries the renames. - # - # ⚠️ The two PUBLIC keys move with their seeds rather than staying: peers - # receive the user key over the wire when they connect (docs/swarm/secrets.md), - # they never configure it, and splitting a keypair across two namespaces is - # worse than either placement. - options.services.hyperhive.deploy.nats = { autoGenerateCallout = lib.mkOption { type = lib.types.bool; default = false; @@ -353,7 +324,7 @@ in — which is what makes a hive with no responder genuinely closed rather than merely gated. - Required when `deploy.nats.enable` is set. + Required when `enable` is set. ''; }; @@ -371,13 +342,27 @@ in 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 `deploy.nats.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 + 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. ''; }; + authPackage = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-nats-auth"; + description = '' + The auth-callout responder package. + + ⚠️ Named `authPackage`, not `package`, on purpose: this module + deliberately has **no** `package` option for the server itself + (see the note above — upstream's `services.nats` resolves + `pkgs.nats-server` on its own), so a bare `package` here would + read as "the NATS package" and mean something else entirely. + ''; + }; + calloutUserSeedFile = lib.mkOption { type = lib.types.str; default = ""; @@ -427,12 +412,11 @@ in # 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 = deployCfg.nats.autoGenerateCallout || deployCfg.nats.calloutIssuerPublicKey != ""; + assertion = cfg.autoGenerateCallout || cfg.calloutIssuerPublicKey != ""; message = '' services.hyperhive.deploy.nats.enable requires - services.hyperhive.deploy.nats.calloutIssuerPublicKey — the - public half of the account nkey that signs user JWTs for this - queue. + 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 @@ -445,12 +429,11 @@ in # failure is the only place to catch that: the rendered config is # valid, the server starts, and the hole is invisible until # somebody connects. - assertion = deployCfg.nats.autoGenerateCallout || deployCfg.nats.calloutUserPublicKey != ""; + assertion = cfg.autoGenerateCallout || cfg.calloutUserPublicKey != ""; message = '' services.hyperhive.deploy.nats.enable requires - services.hyperhive.deploy.nats.calloutUserPublicKey — the - public half of the user nkey the auth-callout responder - authenticates as. + nats.calloutUserPublicKey — the public half of the user nkey + the auth-callout responder authenticates as. It is exempt from callout approval by design, which is exactly why it needs its own credential: a `users` entry with a name @@ -474,11 +457,11 @@ in # boolean is two places for a future edit to land in only one. assertion = responderConfigured; message = '' - services.hyperhive.deploy.nats has callout public keys but no + services.hyperhive.swarm.nats has callout public keys but no seed files: - deploy.nats.calloutUserSeedFile = "${deployCfg.nats.calloutUserSeedFile}" - deploy.nats.calloutIssuerSeedFile = "${deployCfg.nats.calloutIssuerSeedFile}" + nats.calloutUserSeedFile = "${cfg.calloutUserSeedFile}" + nats.calloutIssuerSeedFile = "${cfg.calloutIssuerSeedFile}" Each seed is the private half of the public key already set here — the server verifies with the public half, the responder @@ -486,8 +469,8 @@ in this queue with an auth-callout nobody answers, which refuses every client rather than degrading. - Set both seed paths, or set deploy.nats.autoGenerateCallout = true - to have this host mint all four. + Set both seed paths, or set nats.autoGenerateCallout = true to + have this host mint all four. ''; } { @@ -625,12 +608,12 @@ in # on fails the BUILD of every all-local hive. Upstream's own # description names the case: disable it when the config # includes other files. The check moves to server start. - validateConfig = !deployCfg.nats.autoGenerateCallout; + validateConfig = !cfg.autoGenerateCallout; settings = calloutBlocks { - userKey = deployCfg.nats.calloutUserPublicKey; - issuerKey = deployCfg.nats.calloutIssuerPublicKey; + userKey = cfg.calloutUserPublicKey; + issuerKey = cfg.calloutIssuerPublicKey; } // { # The monitoring endpoint, which is what the exporter below @@ -688,7 +671,7 @@ in # # `mkForce`: upstream defines ExecStart inside an `mkMerge`, so a # plain override conflicts rather than wins. - systemd.services.nats.serviceConfig.ExecStart = lib.mkIf deployCfg.nats.autoGenerateCallout ( + systemd.services.nats.serviceConfig.ExecStart = lib.mkIf cfg.autoGenerateCallout ( lib.mkForce "${pkgs.nats-server}/bin/nats-server -c ${runtimeWrapper}" ); @@ -774,7 +757,7 @@ in # nothing to deliver, and a copy that silently succeeds with a stale # or absent seed is worse than not running. after = - lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service" + lib.optional cfg.autoGenerateCallout "swarm-nats-callout-keys.service" # The third credential does not come from the generator above — it is # minted by authelia's FIRST BOOT, inside its own container. Ordering # after that container is necessary and NOT sufficient: the container @@ -782,7 +765,7 @@ in # has finished. The wait in the script is what actually closes it; # this only stops us spinning for the full timeout on every boot. ++ lib.optional deployCfg.authelia.enable "container@${autheliaCfg.machine}.service"; - requires = lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service"; + requires = lib.optional cfg.autoGenerateCallout "swarm-nats-callout-keys.service"; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -837,7 +820,7 @@ in # a separate unit that needs the user seed: generating it inside would # trap it there and require a secret-export path back out — the exact # mechanism this is meant to avoid inventing. Only public halves cross. - systemd.services.swarm-nats-callout-keys = lib.mkIf deployCfg.nats.autoGenerateCallout { + systemd.services.swarm-nats-callout-keys = lib.mkIf cfg.autoGenerateCallout { description = "mint the swarm queue's auth-callout nkeys"; before = [ "container@swarm-nats.service" ]; wantedBy = [ "container@swarm-nats.service" ]; diff --git a/nix/host-modules/swarm-snapshot-store.nix b/nix/host-modules/swarm-snapshot-store.nix index 4ad556ff..24694ab9 100644 --- a/nix/host-modules/swarm-snapshot-store.nix +++ b/nix/host-modules/swarm-snapshot-store.nix @@ -26,9 +26,9 @@ }: let cfg = config.services.hyperhive.snapshotStore; - wgCfg = config.services.hyperhive.deploy.wireguard; + wgCfg = config.services.hyperhive.swarm.wireguard; - # `deploy.wireguard.address` carries a prefix ("10.100.0.1/24") because + # `swarm.wireguard.address` carries a prefix ("10.100.0.1/24") because # it feeds `networking.wireguard.interfaces.wg-hive.ips`. A listen # address must be the bare IP, so strip it. meshAddress = lib.head (lib.splitString "/" wgCfg.address); @@ -103,7 +103,7 @@ in mesh. Off by default --- it is a distinct deployment role, not part of a hive. - Requires `services.hyperhive.deploy.wireguard.enable`: the mesh + Requires `services.hyperhive.swarm.wireguard.enable`: the mesh is both the transport and the authentication, so there is no meaningful configuration without it. ''; @@ -138,7 +138,7 @@ in assertion = wgCfg.enable; message = '' services.hyperhive.snapshotStore.enable requires - services.hyperhive.deploy.wireguard.enable --- the mesh is the + services.hyperhive.swarm.wireguard.enable --- the mesh is the store's transport AND its authentication (cryptokey routing binds a peer's source address to its public key). Without it there is nothing to bind the listener to and no way to tell @@ -149,7 +149,7 @@ in assertion = wgCfg.address != ""; message = '' services.hyperhive.snapshotStore.enable requires - services.hyperhive.deploy.wireguard.address to be set --- the + services.hyperhive.swarm.wireguard.address to be set --- the receiver binds to this host's mesh address, and refuses to fall back to a wildcard. ''; diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index dd389558..08068f55 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -17,6 +17,7 @@ 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 @@ -210,7 +211,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:${deployCfg.swarm-controller.socketPath}:"; + proxyPass = "http://unix:${controllerCfg.socketPath}:"; extraConfig = swarmAuthRequest; }; # ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`, @@ -232,7 +233,7 @@ in # a future `/webhook/` does not inherit the # bypass by living under a shared prefix. "/webhook/forge/" = { - proxyPass = "http://unix:${deployCfg.swarm-controller.socketPath}:"; + proxyPass = "http://unix:${controllerCfg.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/host-modules/swarm-wireguard.nix b/nix/host-modules/swarm-wireguard.nix index 7b2e1694..e6d982c7 100644 --- a/nix/host-modules/swarm-wireguard.nix +++ b/nix/host-modules/swarm-wireguard.nix @@ -8,14 +8,6 @@ # The two stay coupled by data, not by structure: the per-peer # `wireguard*` fields live on the peer submodule in ./swarm.nix, since # that is where a peer is described, and this module reads them. -# -# Everything declared here is `deploy.*`, not `swarm.*`: by ./deploy.nix's -# rule, `swarm.*` is what a hive needs to be a *client* of the swarm, and -# none of this is. A peer needs this host's `wireguardEndpoint` (the roster -# entry in ./swarm.nix); what interface this host brings up, on which -# address, with which key, is nobody else's business. `listenPort` included -# — it is what this host binds, while the port a peer dials is the one -# inside `wireguardEndpoint`. { lib, config, @@ -27,7 +19,7 @@ # `wireguardPublicKey` declared. Peers reachable over the mesh are # preferred for inter-hive traffic (no public TLS round-trip needed); # peers without a public key still work via normal HTTPS. - options.services.hyperhive.deploy.wireguard = { + options.services.hyperhive.swarm.wireguard = { enable = lib.mkOption { type = lib.types.bool; default = false; @@ -47,7 +39,7 @@ Path to the host's WireGuard private key file. The file must be readable by root and should have mode 0400. Generate with `wg genkey > /etc/wireguard/hive.key`. Required when - `deploy.wireguard.enable = true`. + `swarm.wireguard.enable = true`. ''; }; @@ -91,21 +83,21 @@ # at all. Nothing below is c0re-specific; the peer data c0re consumes # (HIVE_PEER_CA_PATHS) is rendered in ./hive-c0re and stays gated # there. - config = lib.mkIf config.services.hyperhive.deploy.wireguard.enable { + config = lib.mkIf config.services.hyperhive.swarm.wireguard.enable { assertions = [ { - assertion = config.services.hyperhive.deploy.wireguard.privateKeyFile != null; + assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; message = '' - services.hyperhive.deploy.wireguard.enable requires - services.hyperhive.deploy.wireguard.privateKeyFile to be set. + services.hyperhive.swarm.wireguard.enable requires + services.hyperhive.swarm.wireguard.privateKeyFile to be set. Generate a key: wg genkey > /etc/wireguard/hive.key ''; } { - assertion = config.services.hyperhive.deploy.wireguard.address != ""; + assertion = config.services.hyperhive.swarm.wireguard.address != ""; message = '' - services.hyperhive.deploy.wireguard.enable requires - services.hyperhive.deploy.wireguard.address to be set + services.hyperhive.swarm.wireguard.enable requires + services.hyperhive.swarm.wireguard.address to be set (e.g. "10.100.0.1/24"). ''; } @@ -115,7 +107,7 @@ # connects to each peer that has `wireguardPublicKey` set. networking.wireguard.interfaces = let - wgCfg = config.services.hyperhive.deploy.wireguard; + wgCfg = config.services.hyperhive.swarm.wireguard; # `peerHives` is `swarm.hives` minus this hive (../swarm.nix) — # a mesh that included our own entry would configure a tunnel to # ourselves. @@ -147,7 +139,7 @@ # Open the WireGuard UDP port on the host firewall (host-level # networking — not inside containers). networking.firewall.allowedUDPPorts = [ - config.services.hyperhive.deploy.wireguard.listenPort + config.services.hyperhive.swarm.wireguard.listenPort ]; }; } diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index bf0a73cc..0b1016b4 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -144,7 +144,7 @@ in example = "base64pubkey="; description = '' WireGuard public key for this hive's host. Required when - `services.hyperhive.deploy.wireguard.enable = true` and + `services.hyperhive.swarm.wireguard.enable = true` and you want this hive reachable over the mesh. Null = TLS- only peering (public internet, no mesh tunnel). ''; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 29e23a1b..49678d98 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -62,78 +62,6 @@ let bare = hive { }; withCi = hive { deploy.forgejo.ci.enable = true; }; - # A host configured against the pre-rename option path. `mkRenamedOptionModule` - # is the only thing carrying it, and nothing else in this suite would notice - # if it were dropped: the new path evaluates fine on its own, so a missing - # shim reads as a clean tree and breaks every existing operator config. - wireguardOldPath = hive { - swarm.wireguard.enable = true; - swarm.wireguard.address = "10.100.0.1/24"; - 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"; - } - ]; - }; - - # The homeserver's turn to split. All six host-side options are set through - # their pre-rename paths — including `gui.enable`, whose value is deliberately - # the opposite of its default so the definition has to actually land. All six - # so that dropping any single shim entry fails the eval, not just the two the - # assertion reads. - matrixOldPath = hive { - deploy.matrix.enable = true; - swarm.matrix.openFirewall = true; - swarm.matrix.trustedServers = [ "matrix.example.invalid" ]; - swarm.matrix.maxRequestSize = 31457280; - swarm.matrix.registrationTokenFile = "/etc/matrix/register.token"; - swarm.matrix.gui.enable = false; - swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret"; - }; - - # The queue's callout identity, fourth split slice. `autoGenerateCallout` is - # left FALSE on purpose: that is what makes the seed paths the thing deciding - # `responderConfigured`, so the assertion below is about the seeds rather - # than about the auto-mint branch. All five old paths are defined, so - # dropping any single shim entry fails the eval, not just the arms read. - natsOldPath = hive { - deploy.nats.enable = true; - swarm.nats.autoGenerateCallout = false; - swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; - 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"; - swarm.grafana.datasourceUrl = "http://127.0.0.1:19999"; - swarm.grafana.logsDatasourceUrl = "http://127.0.0.1:19998"; - swarm.grafana.plugins = [ ]; - }; - baoPkcs11 = hive { deploy.bao.enable = true; deploy.bao.seal = "pkcs11"; @@ -255,87 +183,6 @@ let name = "the all-local mode turns the swarm controller on"; ok = allLocal.services.hyperhive.deploy.swarm-controller.enable; } - { - # The mesh moved namespace wholesale, so an existing config sets paths - # that no longer exist. Reading the *rendered interface* rather than the - # option: a rename that resolved but stopped reaching the module would - # satisfy an option-level check and still bring up no tunnel. - name = "a config written against the pre-rename wireguard path still configures the interface"; - ok = - let - wg = wireguardOldPath.networking.wireguard.interfaces.wg-hive; - 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; - } - { - # Third split, and the one whose readers were hardest to see: the - # registration token is read only through a `let` alias in another - # module, so no full path names it anywhere. Both arms read a rendered - # effect — the host firewall and the container's bind-mount table — so a - # rename that resolves but stops reaching the module still fails. - name = "a config written against the pre-rename matrix paths still opens the port and mounts the token"; - ok = - let - ports = matrixOldPath.networking.firewall.allowedTCPPorts; - httpPort = matrixOldPath.services.hyperhive.swarm.matrix.httpPort; - in - builtins.elem httpPort ports - && matrixOldPath.containers.hive-matrix.bindMounts ? "/etc/matrix/register.token"; - } - { - # Reads the DELIVERY UNIT, not the options: `responderConfigured` gates - # whether it exists at all, and the seed path is interpolated into its - # script. A rename that resolved but stopped reaching the module would - # leave the responder with no credentials and this case would catch it. - name = "a config written against the pre-rename nats paths still delivers the responder's seeds"; - ok = - let - units = natsOldPath.systemd.services; - in - units ? swarm-nats-auth-secrets - && lib.hasInfix "/run/secrets/nats-user.seed" units.swarm-nats-auth-secrets.script; - } - { - # Reads the host's tmpfiles rules, not the options: the socket directory - # nginx and the container share is created there, so a rename that - # resolved but stopped reaching the module would leave the gateway - # 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 - ) grafanaOldPath.systemd.tmpfiles.rules; - } { # The gateway's per-name issuer choice. If this ever collapses to a # constant, every swarm-service vhost serves a certificate its CA diff --git a/nix/packages/default.nix b/nix/packages/default.nix index d3241997..9f673613 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -93,7 +93,7 @@ let # Completions come from the binary's own `completions ` verb # (the single source of truth, so they never drift from the actual # verbs). The wireguard-tools wrap makes `hivectl wg` subcommands - # work before `deploy.wireguard.enable` is set (wg init is the very + # work before `swarm.wireguard.enable` is set (wg init is the very # first setup step). Completion generation runs before wrapProgram # since wrapProgram renames the real binary. hivectlPkg = diff --git a/swarm-controller/README.md b/swarm-controller/README.md index 745ee52b..b9900d14 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.deploy.swarm-controller.socketPath`, default +The socket path is `services.hyperhive.swarm.controller.socketPath`, default `/run/swarm-controller/controller.sock`, exported to the process as `SWARM_CONTROLLER_SOCKET`. diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs index a849419c..103ef8d0 100644 --- a/swarm-nats-auth/src/main.rs +++ b/swarm-nats-auth/src/main.rs @@ -46,12 +46,12 @@ struct Args { nats_url: String, /// Path to the seed of the callout-exempt user this responder connects as. - /// Its public half is `services.hyperhive.deploy.nats.calloutUserPublicKey`. + /// Its public half is `services.hyperhive.swarm.nats.calloutUserPublicKey`. #[arg(long)] user_seed_file: PathBuf, /// Path to the account signing seed used to sign issued user JWTs. Its - /// public half is `services.hyperhive.deploy.nats.calloutIssuerPublicKey`. + /// public half is `services.hyperhive.swarm.nats.calloutIssuerPublicKey`. #[arg(long)] issuer_seed_file: PathBuf,