From 92559af8e71d205bb47fd586e8a42ae0c5757a97 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 15 Oct 2026 20:19:23 +0200 Subject: [PATCH] gateway: serve the hive dashboard on .hives. behind authelia, drop htpasswd The dashboard, its API, the swagger UI and the agent routes move to their own vhost, `swarm.dashboardDomain` = `.hives.`, under the swarm domain so authelia's session cookie reaches it. `/`, `/api/` and `/api/docs/` ask authelia through `auth_request`, and authelia's `operatorsOnly` rule for each hive's dashboard name lets only the admins group in. The hive domain keeps matrix discovery and redirects every other path to the new name. The name rides as a SAN on the hive's own gateway leaf: the per-hive bao role allows it, and hive-tls asks for it and checks coverage. HTTP Basic auth is gone: the htpasswd writer and its test, the `hivectl gateway` verbs and host-socket requests, the `gateway.auth` options (now a removed-option error), the unauthorized page and the bcrypt dependency. tmpfiles deletes a leftover gateway.htpasswd, which held bcrypt hashes at 0644. c0re hands out dashboard and agent URLs on the new name (HYPERHIVE_DASHBOARD_DOMAIN), and the swarm controller's hive roster carries each hive's dashboard name for the swarm UI's hive list. Closes #3469 --- Cargo.lock | 59 ++------ Cargo.toml | 1 - docs/getting-started/setup.md | 14 +- docs/networking/gateway.md | 43 ++---- docs/swarm/README.md | 4 +- docs/tools/README.md | 2 +- docs/tools/hivectl-cli.md | 62 +------- docs/tools/hivectl.md | 24 +--- .../packages/swarm-ui/src/pages/HivesPage.tsx | 5 +- hive-c0re/Cargo.toml | 1 - hive-c0re/src/container_view.rs | 6 +- hive-c0re/src/gateway_nginx.rs | 132 ------------------ hive-c0re/src/paths.rs | 2 +- hive-c0re/src/server.rs | 11 +- hive-host-sock/src/lib.rs | 25 +--- hive-sh4re/src/container.rs | 6 +- hivectl/README.md | 1 - hivectl/src/cli.rs | 37 +---- hivectl/src/gateway.rs | 50 ------- hivectl/src/main.rs | 13 +- hivectl/src/open.rs | 2 +- hivectl/src/util.rs | 33 +---- nix/host-modules/hive-c0re/default.nix | 2 +- nix/host-modules/hive-c0re/environment.nix | 5 + nix/host-modules/hive-gateway/default.nix | 28 ++-- nix/host-modules/hive-gateway/error-pages.nix | 13 -- nix/host-modules/hive-gateway/options.nix | 34 +---- nix/host-modules/hive-gateway/vhost-lib.nix | 2 +- nix/host-modules/hive-gateway/vhosts.nix | 123 ++++++++++------ nix/host-modules/hive-tls.nix | 18 ++- nix/host-modules/swarm-authelia.nix | 30 ++-- nix/host-modules/swarm-bao.nix | 6 +- nix/host-modules/swarm-controller.nix | 1 + nix/host-modules/swarm-ui.nix | 20 +-- nix/host-modules/swarm.nix | 41 ++++++ nix/module-eval/bao-grants.nix | 4 +- nix/module-eval/core-toggle.nix | 54 +++++++ nix/module-eval/hive-tls.nix | 2 +- nix/module-eval/swarm-otel-identity.nix | 13 +- nix/module-eval/swarm-services-switch.nix | 10 +- swarm-controller/src/agent_limits.rs | 1 + swarm-controller/src/forge_account.rs | 1 + swarm-controller/src/github_account.rs | 1 + swarm-controller/src/linked_accounts.rs | 1 + swarm-controller/src/main.rs | 14 +- swarm-controller/src/matrix_account.rs | 1 + swarm-controller/src/status.rs | 16 ++- 47 files changed, 351 insertions(+), 623 deletions(-) delete mode 100644 hivectl/src/gateway.rs diff --git a/Cargo.lock b/Cargo.lock index 0efbc92e..d6aaf983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures 0.2.17", ] @@ -379,19 +379,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bcrypt" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f3c067aa24dd4ed5c79cf222a38f260c8f23d3b82a062fba3f28c6fe563b753" -dependencies = [ - "base64", - "blowfish", - "getrandom 0.4.3", - "subtle", - "zeroize", -] - [[package]] name = "bitflags" version = "2.13.1" @@ -448,16 +435,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blowfish" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" -dependencies = [ - "byteorder", - "cipher 0.5.2", -] - [[package]] name = "bs58" version = "0.5.1" @@ -500,7 +477,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -540,7 +517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures 0.2.17", ] @@ -563,7 +540,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20 0.9.1", - "cipher 0.4.4", + "cipher", "poly1305", "zeroize", ] @@ -589,20 +566,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout 0.1.4", + "inout", "zeroize", ] -[[package]] -name = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "crypto-common 0.2.2", - "inout 0.2.2", -] - [[package]] name = "clap" version = "4.6.2" @@ -869,7 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" dependencies = [ "aead", - "cipher 0.4.4", + "cipher", "generic-array", "poly1305", "salsa20", @@ -883,7 +850,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -1795,7 +1762,6 @@ dependencies = [ "async-nats", "axum", "base64", - "bcrypt", "chrono", "clap", "clap-markdown", @@ -2511,15 +2477,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -4336,7 +4293,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8f9f1a9b..39dc1a02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,7 +80,6 @@ anyhow = "1" libc = "0.2" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" -bcrypt = "0.19" chrono = { version = "0.4", default-features = false, features = [ "clock", "serde", diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 86866108..11b3fb72 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -135,17 +135,13 @@ Later config changes are PRs on `agent-configs/iris`; you merge them on the forge, and the merge deploys them. → [`agent-lifecycle/config-changes.md`](../agent-lifecycle/config-changes.md#config-changes) -## Optional · Lock the hive dashboard +## The hive dashboard -The per-hive dashboard has no SSO in front of it. To put it behind HTTP -Basic auth, set `services.hyperhive.gateway.auth.enable = true` and add -logins: +The per-hive dashboard answers on `.hives.`, behind +authelia: log in with the `admins` account you created above. The hive's +own domain redirects there. -```bash -echo "hunter2" | hivectl gateway create-user mara --password-stdin -``` - -→ [`networking/gateway.md`](../networking/gateway.md#http-basic-auth) +→ [`networking/gateway.md`](../networking/gateway.md#dashboard-access) ## Day to day diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index aaca83bb..7851968b 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -22,20 +22,22 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo | `/metrics` | the queue exporter's `/metrics` on loopback, behind an authelia `auth_request`; every other path, `/` included, returns `403`, so `/varz` and `/connz` are never reachable | `swarm-nats.nix`, the queue's host | | `grafana.`, `metrics.`, `logs.`, `otel.`, `bao.` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) | -**The hive's own vhost**, named for the hive domain. It exists only on a host that runs a hive (`deploy.hive-controller.enable`); a host without a hive has none of these rows: +**The hive's own vhosts**: the dashboard's, named `.hives.` (`swarm.dashboardDomain`), and one named for the hive domain. They exist only on a host that runs a hive (`deploy.hive-controller.enable`); a host without a hive has none of these rows: | URL | upstream | when | | --- | --- | --- | -| `/` | dashboard dist (static, from `servedFrontend`) | always | -| `/api/`, `/health/` | hive-c0re (`7000`) | always | -| `/api/docs/` | themed Swagger UI dist (static) | always | -| `/agent//` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) | +| `.hives./` | dashboard dist (static, from `servedFrontend`), behind authelia | always | +| `.hives./api/` | hive-c0re (`7000`), behind authelia | always | +| `.hives./health/` | hive-c0re (`7000`) | always | +| `.hives./api/docs/` | themed Swagger UI dist (static), behind authelia | always | +| `.hives./agent//` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) | | `/.well-known/matrix/{client,server}` | inline JSON | `deploy.matrix.enable` | | `/matrix/` | 301 → `chat./` | `deploy.matrix.enable` and `gatewayHost` set | +| `/` (every other path) | 301 → `.hives.`, same path | always | The catch-all `_` vhost answers any other `Host` with `444` (connection closed, no response). It's `mkDefault`, so to make your own vhost the default server, set `services.nginx.virtualHosts."_".default = false;` — an eval assertion names both when two claim it. -Only the host that **runs** authelia declares `auth.`; a hive that merely uses SSO knows `swarm.authelia.url` but doesn't answer for that name. The server name must be exactly `swarm.authelia.domain` — authelia checks that `authelia_url` sits inside its session cookie domain at startup, and refuses to boot otherwise. The vhost carries no Basic auth (that would put the login page behind the login it replaces) and passes `X-Forwarded-{Proto,Host,Uri,For}`, because authelia decides by the original request. +Only the host that **runs** authelia declares `auth.`; a hive that merely uses SSO knows `swarm.authelia.url` but doesn't answer for that name. The server name must be exactly `swarm.authelia.domain` — authelia checks that `authelia_url` sits inside its session cookie domain at startup, and refuses to boot otherwise. The vhost carries no `auth_request` (that would put the login page behind the login it serves) and passes `X-Forwarded-{Proto,Host,Uri,For}`, because authelia decides by the original request. ⚠️ `auth.` answering with the **sso unavailable** page means authelia isn't answering at all — check `journalctl -M swarm-authelia -u authelia-swarm`. While the user set is empty, a disabled placeholder user keeps authelia's user store non-empty, so it boots fine and serves a login page that refuses everyone; add the first account with `swarmctl user add …` ([`setup.md`](../getting-started/setup.md)). @@ -109,30 +111,15 @@ nginx reads the directory directly. Keep the key readable by nginx: Give the gateway the real cert (`tls.certDir` or `tls.acme`) so it serves proper TLS itself, or front it over a unix socket rather than a plain-http TCP port. `.well-known/matrix/*` responses always advertise `https` ([Discovery flow](#discovery-flow-matrix)). -## HTTP Basic auth +## Dashboard access -Optional: Basic auth on the hive's dashboard. Add a login first, then enable it — the htpasswd file exists from first boot, and an empty one refuses everyone. +The dashboard answers on `.hives.` (`swarm.dashboardDomain`, derived from the hive name and `swarm.domain`), behind an authelia `auth_request`. Log in with your authelia account; only accounts in the `admins` group get in, the same rule as the swarm UI. Add one with `swarmctl user add --group admins` ([`swarm/sso.md`](../swarm/sso.md)). -_On the hive host:_ +The name sits under the swarm domain so authelia's session cookie reaches it, whatever the hive's own domain is. Any path on the hive domain other than matrix discovery redirects there, so old bookmarks still land. -```sh -hivectl gateway create-user alice --password-stdin # password on stdin -hivectl gateway delete-user bob -hivectl gateway list-users -``` +**What it gates:** `/`, `/api/` and `/api/docs/`. **Not gated:** `/health/` (for uptime monitors; status only) and the per-agent `/agent//` routes, which come from `agents.conf` and carry no `auth_request`. -```nix -services.hyperhive.gateway.auth = { - enable = true; - # realm = "hyperhive"; # default; must not contain `"` or `$` -}; -``` - -`hivectl` asks hive-c0re over the host admin socket, and the daemon writes `/var/lib/hive-gateway/conf/gateway.htpasswd` itself, bcrypt (cost 12) with `$2y$` hashes nginx reads natively. `--password ` also works but lands in shell history. - -**What it gates:** `/`, `/api/` and `/api/docs/` on the hive vhost. **Not gated:** `/health/` (for uptime monitors; status only), `/.well-known/matrix/*`, and the per-agent `/agent//` routes, which come from `agents.conf` and inherit no auth from `/`. - -A failed or missing login gets `401` with a styled `unauthorized.html` naming the `hivectl` command to run, so browsers still show the login dialog first. +**Certificate:** in self-signed mode the name is a SAN on the hive's own leaf, issued through its own store role, so it needs nothing extra. With `tls.acme` it needs a public DNS record like any other vhost. With `tls.certDir` your certificate must name it; a `*.` wildcard doesn't cover `.hives.`. ## Firewall posture (host-level) @@ -211,7 +198,7 @@ When enabled, every vhost adds `Strict-Transport-Security: max-age=…[; include `services.hyperhive.gateway.localHostsEntry = true` maps to `127.0.0.1` in the host's `/etc/hosts`: - the hive domain; -- every name a module on this host contributes to `gateway.localNames` — each swarm service this host runs adds its own (`forge.`, `chat.`, `auth.`, the swarm UI's apex, …). +- every name a module on this host contributes to `gateway.localNames` — the dashboard's `.hives.` where a hive runs, and each swarm service this host runs adds its own (`forge.`, `chat.`, `auth.`, the swarm UI's apex, …). `services.hyperhive.deploy.singleHostSwarm` turns it on. Leave it off with real DNS. @@ -412,7 +399,7 @@ HTML pages instead of nginx's default error chrome: 504 = /__hive_agent_unreachable` rewrites to `unreachable.html`. `hive-gateway/error-pages.nix` renders every page (`notFound`, `unreachable`, -`unauthorized`, `ssoUnavailable`) from one template with `pkgs.writeText`; +`ssoUnavailable`) from one template with `pkgs.writeText`; nginx serves each through an `internal` location with `alias` to the file, so only nginx's own error handling can reach them. They depend on nothing from the frontend dist and render even when hive-c0re is down. A service diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 0186b996..a276f305 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -84,7 +84,9 @@ below. A hive without them reads Every row also carries `last_seen_unix` and `age_seconds` if you want to apply your own threshold. The timestamp is the one the queue recorded on -arrival, not one the hive put in its own payload. +arrival, not one the hive put in its own payload. A roster hive's row +carries `dashboard`, the host its dashboard answers on; the swarm UI's +hive list links there. Set `services.hyperhive.swarm.controller.staleAfterSeconds` (default `120`) **above the rate hives publish at**, or everything reads `stale` diff --git a/docs/tools/README.md b/docs/tools/README.md index db74e7e2..d70bbcef 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -10,7 +10,7 @@ debug agent behavior. ## For the operator - **[hivectl](hivectl.md)** — the curated guide: provisioning forge - and matrix accounts, gateway htpasswd management, container + and matrix accounts, container lifecycle shortcuts, interactive agent shell access. - **[hivectl-cli](hivectl-cli.md)** — the exhaustive, autogenerated flag-by-flag reference, kept in lockstep with the binary by CI. diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 480bf2d9..4138fd6d 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -9,10 +9,6 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config) * [`hivectl matrix`↴](#hivectl-matrix) * [`hivectl matrix invite`↴](#hivectl-matrix-invite) -* [`hivectl gateway`↴](#hivectl-gateway) -* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user) -* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-user) -* [`hivectl gateway list-users`↴](#hivectl-gateway-list-users) * [`hivectl agent`↴](#hivectl-agent) * [`hivectl agent restart`↴](#hivectl-agent-restart) * [`hivectl agent pause`↴](#hivectl-agent-pause) @@ -57,7 +53,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `forge` — Reconcile an agent's config between this hive and the forge * `matrix` — matrix-tuwunel invites -* `gateway` — Gateway htpasswd user management * `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `list-agents` — Show all managed agents with their status and technical state * `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem @@ -147,61 +142,6 @@ Invite a matrix user to the hive Space, or a specific room with `--room`. Idempo -## `hivectl gateway` - -Gateway htpasswd user management. - -Add, remove, or list users for the gateway's HTTP Basic auth. - -**Usage:** `hivectl gateway ` - -###### **Subcommands:** - -* `create-user` — Add a user or update an existing user's password in the gateway htpasswd -* `delete-user` — Remove a user from the gateway htpasswd -* `list-users` — List all gateway htpasswd usernames, one per line - - - -## `hivectl gateway create-user` - -Add a user or update an existing user's password in the gateway htpasswd. - -Use `--password-stdin` to keep the password out of shell history. - -**Usage:** `hivectl gateway create-user [OPTIONS] ` - -###### **Arguments:** - -* `` — Username to add or update - -###### **Options:** - -* `--password ` — Set the password inline. WARNING: visible in shell history and process listings — prefer `--password-stdin` for sensitive input. Mutually exclusive with `--password-stdin` -* `--password-stdin` — Read the password from stdin (single line, trailing newline stripped). Mutually exclusive with `--password` - - - -## `hivectl gateway delete-user` - -Remove a user from the gateway htpasswd - -**Usage:** `hivectl gateway delete-user ` - -###### **Arguments:** - -* `` — Username to remove - - - -## `hivectl gateway list-users` - -List all gateway htpasswd usernames, one per line - -**Usage:** `hivectl gateway list-users` - - - ## `hivectl agent` Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running. @@ -644,7 +584,7 @@ Resolves the URL from the running daemon so custom forge / matrix domains work. Possible values: - `home`: - The operator dashboard (`https:///`) + The operator dashboard (`https://.hives./`) - `forge`: The forge (Forgejo) web UI - `matrix`: diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 7629c072..70551e49 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -11,8 +11,7 @@ Available via the `hive-c0re` package in the host NixOS config. Unlike the `hive-c0re` daemon subcommands (which go through the broker), `hivectl` covers direct host-side administration: manual provisioning of -matrix accounts, gateway htpasswd management, container -lifecycle shortcuts, and interactive agent shell access. +matrix accounts, container lifecycle shortcuts, and interactive agent shell access. This page is the curated guide. For the exhaustive flag-by-flag reference autogenerated from the binary's own command tree, see @@ -65,25 +64,6 @@ group; nobody has built that sync yet. invite power (it owns the hive Space, so that case always works). Idempotent — already-member / already-invited is a no-op. -## Gateway - -Manage users in the gateway's HTTP Basic auth htpasswd file -(`services.hyperhive.gateway.auth`). `hivectl` sends the request over the -host admin socket; the `hive-c0re` daemon owns the htpasswd file at its -canonical path (`/var/lib/hive-gateway/conf/gateway.htpasswd`) and -performs the write. - -```bash -hivectl gateway create-user alice --password-stdin # add (or update) user; read password from stdin -hivectl gateway create-user bob --password hunter2 # add user inline (visible in shell history) -hivectl gateway delete-user bob # remove user -hivectl gateway list-users # list all usernames, one per line -``` - -The daemon hashes passwords with BCrypt (cost 12). It creates the file -if it doesn't exist. Re-running `create-user` with the same -username updates the password hash in place. - ## Agents Container lifecycle shortcuts that go through the host admin socket. @@ -229,7 +209,7 @@ Requires the `hive-c0re` daemon to be running. ```bash hivectl open # operator dashboard (same as `open home`) -hivectl open home # operator dashboard (https:///) +hivectl open home # operator dashboard (https://.hives./) hivectl open forge # the forge (Forgejo) web UI hivectl open matrix # the matrix GUI (fluffychat) ``` diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx index 235dac15..ccc39e20 100644 --- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx @@ -30,6 +30,7 @@ type Freshness = "fresh" | "stale" | "never_reported" | "unknown"; interface HiveStatus { name: string; domain: string | null; + dashboard: string | null; freshness: Freshness; last_seen_unix: number | null; // `age_seconds` (a once-computed-at-fetch age) is part of the wire @@ -60,8 +61,8 @@ const COLUMNS: TableColumn[] = [ key: "domain", header: "domain", render: (h) => - h.domain ? ( - + h.domain && h.dashboard ? ( + {h.domain} ) : ( diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 59ee8731..02e4d2e5 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -18,7 +18,6 @@ async-nats.workspace = true axum.workspace = true chrono.workspace = true base64.workspace = true -bcrypt.workspace = true reqwest.workspace = true forgejo-api.workspace = true url.workspace = true diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 67e98753..be230f7f 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -110,14 +110,14 @@ impl From for hive_sh4re::container::AgentStatusRow { } } -/// This agent's own web UI, behind the gateway. `None` when the hive's +/// This agent's own web UI, behind the gateway. `None` when the dashboard's /// domain isn't configured — same env var, same "empty string counts as /// unset" filter `server.rs::hive_urls()` uses for `HiveUrls::home`, kept /// in sync with it by reading the one env var directly rather than /// threading a resolved domain through `HiveEnv` for a value nothing else /// needs pre-resolved. fn agent_url(name: &str) -> Option { - let domain = std::env::var("HYPERHIVE_HIVE_DOMAIN") + let domain = std::env::var("HYPERHIVE_DASHBOARD_DOMAIN") .ok() .filter(|v| !v.is_empty())?; Some(agent_url_for_domain(&domain, name)) @@ -565,7 +565,7 @@ mod tests { // No source for reminders on this side (see the `From` impl's doc // comment) — always the stub, never left uninitialised. assert_eq!(row.pending_reminders, 0); - // `url` depends on process-global env (`HYPERHIVE_HIVE_DOMAIN`), so + // `url` depends on process-global env (`HYPERHIVE_DASHBOARD_DOMAIN`), so // it isn't asserted here — mutating env vars in a test race with // every other test in this binary. `agent_url_for_domain` below // covers the actual path-formatting logic without touching env. diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index 4a4b4ca0..40ffd856 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -293,143 +293,11 @@ async fn reload_gateway_nginx() { } } -// ── Gateway HTTP-Basic (htpasswd) user management ────────────────────────── -// The daemon owns the write (hivectl drives it over the host socket via the -// `Gateway*User` requests, so hivectl never touches the credential file). -// Keyed on the canonical `paths::GATEWAY_HTPASSWD` — a socket client doesn't -// pick the path. - -fn htpasswd_path() -> std::path::PathBuf { - std::path::PathBuf::from(crate::paths::GATEWAY_HTPASSWD) -} - -/// Read the htpasswd file into lines, or an empty list if it doesn't exist. -fn htpasswd_read() -> Result> { - let path = htpasswd_path(); - if !path.exists() { - return Ok(vec![]); - } - let content = std::fs::read_to_string(&path) - .with_context(|| format!("read htpasswd file {}", path.display()))?; - Ok(content.lines().map(str::to_owned).collect()) -} - -/// Write lines back atomically (`.tmp` then rename), with a trailing -/// newline. -fn htpasswd_write(lines: &[String]) -> Result<()> { - htpasswd_write_at(&htpasswd_path(), lines) -} - -fn htpasswd_write_at(path: &std::path::Path, lines: &[String]) -> Result<()> { - let tmp = path.with_extension("htpasswd.tmp"); - let content = if lines.is_empty() { - String::new() - } else { - let mut s = lines.join("\n"); - s.push('\n'); - s - }; - let mut f = std::fs::File::create(&tmp) - .with_context(|| format!("create htpasswd tmp {}", tmp.display()))?; - std::io::Write::write_all(&mut f, content.as_bytes()) - .and_then(|()| f.sync_all()) - .with_context(|| format!("write htpasswd tmp {}", tmp.display()))?; - // Mode set on the TMP file for the same reason as in `write()`: the - // rename carries it, and nginx reads this file as a different user. - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)) - .with_context(|| format!("chmod htpasswd tmp {}", tmp.display()))?; - std::fs::rename(&tmp, path) - .with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?; - Ok(()) -} - -fn validate_htpasswd_username(username: &str) -> Result<()> { - if username.is_empty() { - anyhow::bail!("username must not be empty"); - } - if username.contains(':') { - anyhow::bail!("username must not contain ':' (htpasswd field separator)"); - } - if username.chars().any(char::is_control) { - anyhow::bail!("username must not contain control characters"); - } - Ok(()) -} - -/// Add or update a gateway HTTP-Basic user, bcrypt-hashing `password` -/// (cost 12). Returns the confirmation line for the operator. -pub fn create_user(username: &str, password: &str) -> Result { - validate_htpasswd_username(username)?; - let raw_hash = bcrypt::hash(password, 12).context("bcrypt hash")?; - // nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The prefixes - // are algorithmically identical; remap so nginx accepts the hash. - let hash = raw_hash.replacen("$2b$", "$2y$", 1); - let entry = format!("{username}:{hash}"); - let mut lines = htpasswd_read()?; - let prefix = format!("{username}:"); - let msg = if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) { - lines[pos] = entry; - format!("gateway: updated password for '{username}'") - } else { - lines.push(entry); - format!("gateway: added user '{username}'") - }; - htpasswd_write(&lines)?; - Ok(msg) -} - -/// Remove a gateway HTTP-Basic user. Errors when the user isn't present so a -/// no-op is detectable. -pub fn delete_user(username: &str) -> Result { - let mut lines = htpasswd_read()?; - let prefix = format!("{username}:"); - let before = lines.len(); - lines.retain(|l| !l.starts_with(&prefix)); - if lines.len() == before { - anyhow::bail!("gateway: user '{username}' not found"); - } - htpasswd_write(&lines)?; - Ok(format!("gateway: removed user '{username}'")) -} - -/// List the gateway HTTP-Basic usernames (skips blank / comment lines). -pub fn list_users() -> Result> { - Ok(htpasswd_read()? - .iter() - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .filter_map(|l| l.split_once(':').map(|(name, _)| name.to_owned())) - .collect()) -} - #[cfg(test)] mod tests { use super::*; use crate::lifecycle::MANAGER_NAME; - /// The htpasswd file is published `0644` even when the temp file it is - /// written through already exists at `0600` (opening an existing file - /// keeps its mode), holds the lines newline-terminated, and leaves no - /// temp file beside it. - #[test] - fn htpasswd_is_published_0644_over_a_private_leftover_tmp() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("htpasswd"); - let tmp = path.with_extension("htpasswd.tmp"); - std::fs::write(&tmp, "leftover\n").expect("seed tmp"); - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).expect("chmod"); - - htpasswd_write_at(&path, &["a:x".to_owned(), "b:y".to_owned()]).expect("write"); - - let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777; - assert_eq!(mode, 0o644); - assert_eq!(std::fs::read_to_string(&path).expect("read"), "a:x\nb:y\n"); - let entries: Vec<_> = std::fs::read_dir(dir.path()) - .expect("readdir") - .map(|e| e.expect("entry").file_name()) - .collect(); - assert_eq!(entries, [std::ffi::OsString::from("htpasswd")]); - } - // ── legacy mode (frontend_dir = None) ────────────────────────────── #[test] diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 7660ae26..d4aea385 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; // `hive-host-sock` (the crate hivectl links instead of the whole daemon); // re-exported here so this module stays the daemon's single-source facade for // its own callsites. -pub use hive_host_sock::{AGENTS_ROOT, GATEWAY_HTPASSWD, HOST_SOCKET, agent_state_dir}; +pub use hive_host_sock::{AGENTS_ROOT, HOST_SOCKET, agent_state_dir}; /// Name of the per-agent working dir under `agents//`, a sibling of /// `state/`. Bound RW into the container at `/agents//work`. diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index db4b4aad..314d0257 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -193,15 +193,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostRequest::ReconcileConfigApply { agent, direction } => { crate::forge::reconcile_config_apply(agent.as_str(), *direction).await? } - HostRequest::GatewayCreateUser { username, password } => { - HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?]) - } - HostRequest::GatewayDeleteUser { username } => { - HostResponse::messages(vec![crate::gateway_nginx::delete_user(username)?]) - } - HostRequest::GatewayListUsers => { - HostResponse::messages(crate::gateway_nginx::list_users()?) - } HostRequest::QuotaEnable => handle_quota_enable().await?, HostRequest::QuotaLimit { name, limit } => { handle_quota_limit(name.as_str(), *limit).await? @@ -779,7 +770,7 @@ fn hive_urls() -> hive_host_sock::HiveUrls { let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); let domain = env("HYPERHIVE_HIVE_DOMAIN"); hive_host_sock::HiveUrls { - home: domain.as_ref().map(|d| format!("https://{d}/")), + home: env("HYPERHIVE_DASHBOARD_DOMAIN").map(|d| format!("https://{d}/")), forge: env("HIVE_FORGE_PUBLIC_URL"), matrix: env("HIVE_MATRIX_PUBLIC_URL"), domain, diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 4adc8233..31efe349 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -46,14 +46,6 @@ pub fn agent_state_dir(name: &Ident) -> PathBuf { PathBuf::from(AGENTS_ROOT).join(name.as_str()) } -/// nginx basic-auth credential store for the operator dashboard vhost. -/// `hivectl`'s `--htpasswd-file` clap default. -/// -/// Under the gateway's own state dir rather than c0re's, so that nginx -/// can read it without being given access to everything else c0re keeps. -// nix: read by the gateway's nginx on the host (hive-gateway/) — must match. -pub const GATEWAY_HTPASSWD: &str = "/var/lib/hive-gateway/conf/gateway.htpasswd"; - /// `/run/hive-agent` — per-agent runtime socket dir root (web UI unix /// socket + bound marker), one subdir per agent. The gateway's nginx /// `proxy_pass`es to `agent_web_socket(name)` directly; `hivectl` dials @@ -261,21 +253,6 @@ pub enum HostRequest { agent: Ident, direction: ReconcileDirection, }, - /// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file - /// (`paths::GATEWAY_HTPASSWD`). Daemon-side equivalent of `hivectl gateway - /// create-user`: the daemon bcrypt-hashes `password` (cost 12, remapped to - /// the `$2y$` prefix nginx accepts) and writes the entry, so hivectl never - /// touches the file. `password` is read client-side (inline flag or stdin). - /// Returns a confirmation line in [`HostResponse::messages`]. - GatewayCreateUser { username: String, password: String }, - /// Remove a gateway HTTP-Basic user from the daemon's htpasswd file. - /// Daemon-side equivalent of `hivectl gateway delete-user`. Errors if the - /// user isn't present so a no-op is detectable. - GatewayDeleteUser { username: String }, - /// List the gateway HTTP-Basic usernames in the daemon's htpasswd file. - /// Daemon-side equivalent of `hivectl gateway list-users`; the usernames - /// come back in [`HostResponse::messages`], one per line. - GatewayListUsers, /// Turn on btrfs qgroup accounting on the agent-state filesystem, via /// the privileged helper. Daemon-side equivalent of `hivectl /// quota-enable`. Returns advisory lines in [`HostResponse::messages`]. @@ -416,7 +393,7 @@ pub struct HiveUrls { /// Canonical hive domain (`services.hyperhive.domain`). #[serde(default, skip_serializing_if = "Option::is_none")] pub domain: Option, - /// Operator dashboard root (`https:///`). + /// Operator dashboard root (`https:///`). #[serde(default, skip_serializing_if = "Option::is_none")] pub home: Option, /// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — `None` when diff --git a/hive-sh4re/src/container.rs b/hive-sh4re/src/container.rs index f928ad8b..d35f87a7 100644 --- a/hive-sh4re/src/container.rs +++ b/hive-sh4re/src/container.rs @@ -72,9 +72,9 @@ pub struct AgentStatusRow { #[serde(default, skip_serializing_if = "Option::is_none")] pub status_set_at: Option>, /// This agent's own web UI, behind the gateway - /// (`https:///agent//`) — the same URL the operator - /// dashboard's "open" action uses. `None` when the hive's domain isn't - /// configured (`HYPERHIVE_HIVE_DOMAIN` unset), same condition + /// (`https:///agent//`) — the same URL the operator + /// dashboard's "open" action uses. `None` when the dashboard's domain isn't + /// configured (`HYPERHIVE_DASHBOARD_DOMAIN` unset), same condition /// `HostRequest::Urls`'s `HiveUrls::home` is `None` under. Present here /// (not just derivable from `HiveUrls`) so a client never has to build /// this path itself — see `hive-c0re/container_view.rs`'s `agent_url` diff --git a/hivectl/README.md b/hivectl/README.md index 8628f2e7..7fdec9f4 100644 --- a/hivectl/README.md +++ b/hivectl/README.md @@ -28,7 +28,6 @@ dispatch: - **`forge.rs`** — reconciles an agent's config against its forge repo. - **`matrix.rs`** — invite a matrix user to the hive Space or a room. -- **`gateway.rs`** — gateway Basic-auth user management (htpasswd). - **`wg.rs`** — WireGuard mesh helpers. - **`subvol.rs`** — btrfs state-subvolume ops. - **`quota.rs`**, **`util.rs`**, **`completions.rs`**, **`open.rs`** — diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index b407d745..704147fb 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -45,13 +45,6 @@ pub enum Cmd { #[command(subcommand)] cmd: MatrixCmd, }, - /// Gateway htpasswd user management. - /// - /// Add, remove, or list users for the gateway's HTTP Basic auth. - Gateway { - #[command(subcommand)] - cmd: GatewayCmd, - }, /// Lifecycle actions on ONE managed agent container. Needs the /// hive-c0re daemon running. /// @@ -178,7 +171,7 @@ pub enum Cmd { /// Which hive web surface `hivectl open` targets. #[derive(Copy, Clone, Debug, clap::ValueEnum)] pub enum OpenTarget { - /// The operator dashboard (`https:///`). + /// The operator dashboard (`https://.hives./`). Home, /// The forge (Forgejo) web UI. Forge, @@ -279,34 +272,6 @@ pub enum MatrixCmd { }, } -#[derive(Subcommand)] -pub enum GatewayCmd { - /// Add a user or update an existing user's password in the gateway - /// htpasswd. - /// - /// Use `--password-stdin` to keep the password out of shell history. - CreateUser { - /// Username to add or update. - username: String, - /// Set the password inline. WARNING: visible in shell history and - /// process listings — prefer `--password-stdin` for sensitive input. - /// Mutually exclusive with `--password-stdin`. - #[arg(long, conflicts_with = "password_stdin")] - password: Option, - /// Read the password from stdin (single line, trailing newline - /// stripped). Mutually exclusive with `--password`. - #[arg(long)] - password_stdin: bool, - }, - /// Remove a user from the gateway htpasswd. - DeleteUser { - /// Username to remove. - username: String, - }, - /// List all gateway htpasswd usernames, one per line. - ListUsers, -} - #[derive(Subcommand)] pub enum WgCmd { /// Generate this hive's WireGuard key (if absent) and print its public diff --git a/hivectl/src/gateway.rs b/hivectl/src/gateway.rs deleted file mode 100644 index 4a02be97..00000000 --- a/hivectl/src/gateway.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! `hivectl gateway` — htpasswd user management for the gateway's HTTP -//! Basic auth. The daemon owns the bcrypt hash + htpasswd write (see the -//! `Gateway*User` `HostRequest`s); hivectl resolves the password -//! client-side and relays the request over the host admin socket. - -use std::path::Path; - -use anyhow::Result; - -use crate::util::{daemon_request, resolve_password}; - -pub(crate) async fn gateway_create_user( - socket: &Path, - username: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { - let pw = resolve_password(password, password_stdin)?.ok_or_else(|| { - anyhow::anyhow!("a password is required — pass --password or --password-stdin") - })?; - daemon_request( - socket, - hive_host_sock::HostRequest::GatewayCreateUser { - username: username.to_owned(), - password: pw, - }, - "gateway", - ) - .await -} - -pub(crate) async fn gateway_delete_user(socket: &Path, username: &str) -> Result<()> { - daemon_request( - socket, - hive_host_sock::HostRequest::GatewayDeleteUser { - username: username.to_owned(), - }, - "gateway", - ) - .await -} - -pub(crate) async fn gateway_list_users(socket: &Path) -> Result<()> { - daemon_request( - socket, - hive_host_sock::HostRequest::GatewayListUsers, - "gateway", - ) - .await -} diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index 2580e5e9..bce1cc99 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -26,13 +26,11 @@ mod client; /// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner / /// plain renderers), split out to keep this file manageable. mod dag_progress; -use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd}; +use cli::{Cli, Cmd, ForgeCmd, WgCmd}; mod completions; mod quota; mod util; use completions::generate_completions; -mod gateway; -use gateway::{gateway_create_user, gateway_delete_user, gateway_list_users}; mod matrix; use matrix::run_matrix_cmd; mod open; @@ -68,15 +66,6 @@ async fn main() -> Result<()> { } => forge_reconcile_config(&socket, &agent, from, verbose).await, }, Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await, - Cmd::Gateway { cmd } => match cmd { - GatewayCmd::CreateUser { - username, - password, - password_stdin, - } => gateway_create_user(&socket, &username, password.as_deref(), password_stdin).await, - GatewayCmd::DeleteUser { username } => gateway_delete_user(&socket, &username).await, - GatewayCmd::ListUsers => gateway_list_users(&socket).await, - }, Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await, Cmd::ListAgents { json } => agents_list(&socket, json).await, Cmd::QuotaEnable => quota::quota_enable(&socket).await, diff --git a/hivectl/src/open.rs b/hivectl/src/open.rs index d25c9f52..0298d35d 100644 --- a/hivectl/src/open.rs +++ b/hivectl/src/open.rs @@ -22,7 +22,7 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> { let (url, hint) = match target { OpenTarget::Home => ( urls.home, - "the dashboard URL needs `services.hyperhive.domain` to be set", + "the dashboard URL needs `services.hyperhive.swarm.domain` set and this hive in `swarm.hives`", ), OpenTarget::Forge => ( urls.forge, diff --git a/hivectl/src/util.rs b/hivectl/src/util.rs index e9cdb0ef..628f4819 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -1,6 +1,6 @@ //! Shared helpers used across hivectl's command handling: daemon //! request/response plumbing (`daemon_request`, `render`, -//! `render_lifecycle`) and password input (`resolve_password`). +//! `render_lifecycle`). use std::path::Path; @@ -18,7 +18,7 @@ pub(crate) fn parse_ident(name: &str) -> Result { /// Send a provisioning request to the daemon and print its result lines. /// The daemon owns the provisioning logic; hivectl just relays the outcome, -/// prefixing any error with `label` (e.g. `forge` / `gateway`). +/// prefixing any error with `label` (e.g. `forge` / `quota`). pub(crate) async fn daemon_request( socket: &Path, req: hive_host_sock::HostRequest, @@ -40,35 +40,6 @@ pub(crate) async fn daemon_request( Ok(()) } -/// Resolve the password the caller asked for, or return `None` to fall -/// back to a random throwaway. `--password ` wins outright; -/// `--password-stdin` reads one line off stdin (trailing newline -/// stripped). Empty stdin is treated as an error so the caller doesn't -/// silently provision an empty-password account. -pub(crate) fn resolve_password( - password: Option<&str>, - password_stdin: bool, -) -> Result> { - if let Some(p) = password { - return Ok(Some(p.to_owned())); - } - if password_stdin { - use std::io::BufRead as _; - let stdin = std::io::stdin(); - let mut line = String::new(); - stdin - .lock() - .read_line(&mut line) - .context("read password from stdin")?; - let trimmed = line.trim_end_matches(['\r', '\n']).to_owned(); - if trimmed.is_empty() { - bail!("--password-stdin: empty input"); - } - return Ok(Some(trimmed)); - } - Ok(None) -} - /// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the /// agent-lifecycle verbs that just relay a daemon result verbatim. pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> { diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 82990e34..0a88c3b7 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -355,7 +355,7 @@ in # - nix build worktrees live in PrivateTmp, not /tmp. # - /etc writes (bind-mount edits) go through hive-priv via the # privileged socket; /etc/hyperhive/serve.json is read-only. - # - the gateway's nginx include + htpasswd live under + # - the gateway's nginx include lives under # /var/lib/hive-gateway/conf, deliberately outside StateDirectory # (see hive-c0re/src/paths.rs::GATEWAY_CONF_DIR) so nginx never # needs access to the rest of c0re's state — that separation diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 7dce9fa8..2696bf66 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -58,6 +58,11 @@ in # required (asserted in hive-network.nix), so it's always set. HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; } +// lib.optionalAttrs (config.services.hyperhive.swarm.dashboardDomain != null) { + # The host the dashboard and the agent UIs answer on, for the URLs c0re + # hands out (`hivectl open home`, each agent's UI link). + HYPERHIVE_DASHBOARD_DOMAIN = config.services.hyperhive.swarm.dashboardDomain; +} // lib.optionalAttrs (config.services.hyperhive.hiveName != null) { HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName; } diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index a5dadd5b..1a3321e2 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -115,6 +115,8 @@ let nginxTree = import ./vhosts.nix { gwLib = cfg.lib; + inherit (config.services.hyperhive.swarm) dashboardDomain; + autheliaDomain = config.services.hyperhive.swarm.authelia.domain; inherit lib cfg @@ -236,19 +238,25 @@ in # /var/lib/hyperhive. "d /var/lib/hive-gateway 0755 root root - -" "d /var/lib/hive-gateway/conf 0755 hive-core hive-core - -" + # Pre-created so nginx's config test can open it before c0re has + # ever written: nginx names it, and an `include` of a missing file is + # a fatal config error, not an empty one. tmpfiles runs before + # services, which is the whole ordering guarantee — content arrives + # when c0re writes and reloads, which it does on every topology + # change. `f` = create-if-absent, never overwrite. "f /var/lib/hive-gateway/conf/agents.conf 0644 hive-core hive-core - # Generated by hive-c0re — do not edit.\n" - # Pre-create both files so nginx's config test can open them before - # c0re has ever written: nginx names them, and an `include` of a - # missing file is a fatal config error, not an empty one. tmpfiles - # runs before services, which is the whole ordering guarantee — - # content arrives when c0re writes and reloads, which it does on - # every topology change. `f` = create-if-absent, never overwrite. - # - # An empty htpasswd causes all auth checks to return 401 (no valid - # credentials), which is the correct no-users behaviour. - "f /var/lib/hive-gateway/conf/gateway.htpasswd 0644 hive-core hive-core - -" + # Nothing reads or writes this path. A copy left on an existing host + # holds bcrypt password hashes at 0644; tmpfiles' `--remove` pass, on + # switch and at boot, deletes it. + "r /var/lib/hive-gateway/conf/gateway.htpasswd" ]; + # The dashboard's name, for agents on the bridge and (with + # `localHostsEntry`) the host's own browser. + services.hyperhive.gateway.localNames = lib.optional ( + runsHive && config.services.hyperhive.swarm.dashboardDomain != null + ) config.services.hyperhive.swarm.dashboardDomain; + # ACME (Let's Encrypt) integration. nginx vhosts set # `enableACME = true` via the vhost builder; this provides the # shared ACME config (acceptTerms + email). diff --git a/nix/host-modules/hive-gateway/error-pages.nix b/nix/host-modules/hive-gateway/error-pages.nix index a1142083..9c791ff7 100644 --- a/nix/host-modules/hive-gateway/error-pages.nix +++ b/nix/host-modules/hive-gateway/error-pages.nix @@ -67,17 +67,4 @@ in

Check the container: journalctl -M swarm-authelia -u authelia-swarm. This page recovers on reload once the provider is up.

''; }; - - unauthorized = mkPage { - name = "unauthorized"; - title = "unauthorized"; - accent = "#f38ba8"; - body = '' -

This hive is protected by HTTP Basic auth. Valid credentials are required.

-

Operator: add a user with hivectl gateway create-user:

-
hivectl gateway create-user \
-      <username> --password-stdin
-

Then reload your browser and enter the credentials when prompted.

- ''; - }; } diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 415bff85..c67c2908 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -18,6 +18,12 @@ in mode. Remove the setting; configure `tls.certDir` or `tls.acme` to override the self-signed default. '') + (lib.mkRemovedOptionModule [ "services" "hyperhive" "gateway" "auth" ] '' + The dashboard is served on services.hyperhive.swarm.dashboardDomain + behind authelia, and only accounts in authelia's `admins` group can + open it. There is no HTTP Basic auth and no htpasswd file. Remove + the setting; add an operator with `swarmctl user add --group admins`. + '') ]; options.services.hyperhive.gateway = { @@ -169,7 +175,7 @@ in readOnly = true; description = '' Read-only: the gateway's styled static error pages, by name - (`notFound`, `unreachable`, `unauthorized`, `ssoUnavailable`). + (`notFound`, `unreachable`, `ssoUnavailable`). Published so a service module can aim an `error_page` at one instead of rendering its own — a service that built its own @@ -343,32 +349,6 @@ in }; }; - auth = { - enable = lib.mkEnableOption '' - HTTP basic auth on the gateway using an htpasswd file. When - enabled, every request to the gateway's main vhost requires a - valid username and password. nginx's built-in `auth_basic` - module validates credentials against - `/var/lib/hive-gateway/conf/gateway.htpasswd`. Off by default. - - Manage users with `hivectl gateway create-user`, `delete-user`, - and `list-users` — see `hivectl gateway --help` for usage. - The htpasswd file is created automatically when auth is enabled; - add at least one user before enabling to avoid locking everyone out. - ''; - - realm = lib.mkOption { - type = lib.types.strMatching "[^\"$]*"; - default = "hyperhive"; - example = "my-hive"; - description = '' - HTTP Basic auth `realm` value sent in the `WWW-Authenticate` - header when credentials are absent or rejected. Must not - contain `"` or `$` (nginx string metacharacters). - ''; - }; - }; - swaggerUiTheme = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swagger-ui-theme"; diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix index 4971b786..b9cd9952 100644 --- a/nix/host-modules/hive-gateway/vhost-lib.nix +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -22,7 +22,7 @@ svcCert, # swarm-services leaf, for names the hive leaf cannot carry svcKey, swarmServiceDomains, # which names those are (../swarm.nix derives it) - errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } + errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable } caBundle, # hive TLS trust bundle on the host }: let diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 03661eb0..8cbf56fe 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -1,8 +1,10 @@ # nginx virtual-host tree for the **hive's own** gateway surface: the `_` -# default server, which answers unmatched `Host` with a bare 444, and the -# vhost named for the hive domain, which carries the dashboard, per-agent -# routing, matrix discovery, the swagger UI and the legacy -# `/matrix/*` redirect. +# default server, which answers unmatched `Host` with a bare 444; the +# dashboard's vhost (`swarm.dashboardDomain`), which carries the dashboard +# behind authelia, per-agent routing and the swagger UI; and the vhost +# named for the hive domain, which carries matrix discovery, the legacy +# `/matrix/*` redirect and a redirect of everything else to the +# dashboard's name. # # Scope: a swarm service declares its own vhost and dns name in its own # module, built from the kit this file also consumes @@ -18,10 +20,12 @@ matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs hyperhiveDomain, + dashboardDomain, # services.hyperhive.swarm.dashboardDomain + autheliaDomain, # services.hyperhive.swarm.authelia.domain runsHive, # deploy.hive-controller.enable — whether hive-c0re runs here dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css - errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } + errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable } gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option }: let @@ -126,15 +130,19 @@ let }; }; - # Shared auth block — separate locations don't inherit auth_basic, so - # each dashboard location (`/`, `/api/`) needs it or that surface is - # unauthed. - dashboardAuth = lib.optionalString cfg.auth.enable '' - auth_basic "${cfg.auth.realm}"; - auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd; - # `=401` keeps the status 401 so the login dialog shows; the - # internal page explains `hivectl gateway create-user`. - error_page 401 =401 /__hive_auth_unauthorized; + # Repeated verbatim by every operator-gated location (`/`, `/api/`, + # `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling + # locations, so a location without it is reachable with no session. + # + # ⚠️ `auth_request` answers "is there a session", not "is this an + # operator". authelia's `operatorsOnly` rule for this name + # (../swarm-authelia.nix) is what refuses an agent's own account. + dashboardAuth = '' + auth_request /__hive_authelia; + # Captured BEFORE the error_page jump: inside the 401 handler + # `$request_uri` is the internal one. + auth_request_set $target_url $scheme://$http_host$request_uri; + error_page 401 =302 https://${autheliaDomain}/?rd=$target_url; ''; # Dashboard: nginx static-serves the dist, c0re is API-only. Routing @@ -167,8 +175,8 @@ let ''; }; "/health/" = { - # No dashboardAuth here: an external uptime monitor generally can't do - # interactive HTTP Basic. The endpoints themselves are scoped to + # No dashboardAuth here: an external uptime monitor can't do an + # interactive login. The endpoints themselves are scoped to # status + warning kind/message (see hive-c0re/src/dashboard/ # health.rs) — no tokens, no agent detail — so exposing them # unauthenticated isn't a new secret surface. @@ -206,6 +214,29 @@ let ''; }; }; + + # The subrequest `dashboardAuth` makes, the same as the swarm UI's + # (../swarm-ui.nix), which documents the header set. + autheliaLocation = { + "= /__hive_authelia" = { + proxyPass = "https://${autheliaDomain}/api/authz/auth-request"; + # nixpkgs appends its OWN `Host $host` after extraConfig, which would + # override verifiedProxyTo's — see the comment on verifiedProxyTo in + # ./vhost-lib.nix. + recommendedProxySettings = false; + extraConfig = '' + internal; + ${gwLib.verifiedProxyTo autheliaDomain} + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; + }; in { virtualHosts = { @@ -237,32 +268,26 @@ in ''; }; } - // lib.optionalAttrs runsHive { - # The hive domain's own vhost, reachable by NAME, so the catch-all can - # reject everything else. Only where a hive runs: the dashboard, agent - # routes and swagger UI proxy to hive-c0re, which runs only with the hive - # controller, and the certificate naming the hive domain is the hive - # leaf, which exists only there. - ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { + // lib.optionalAttrs (runsHive && dashboardDomain != null) { + # The dashboard, reachable by its own NAME under the swarm domain, where + # authelia's session cookie reaches. Only where a hive runs: the + # dashboard, agent routes and swagger UI proxy to hive-c0re, which runs + # only with the hive controller, and the hive leaf carrying this name + # exists only there. + # + # The agent routes live here and not on the hive domain because the + # agent page calls the dashboard's `/api/` on its own origin + # (frontend/packages/agent/src/lib/dashboardBase.ts), and the dashboard + # links to them by path. They carry no `auth_request`. + # + # `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers the + # subrequest for an `http://` target with 400, which `auth_request` + # cannot read. `removeAttrs` because nixos asserts on a vhost declaring + # both. + ${dashboardDomain} = (builtins.removeAttrs (vhostTlsFor dashboardDomain) [ "addSSL" ]) // { + forceSSL = true; listen = vhostListen; - locations = - matrixRedirectLocations - // wellKnownLocations - // agentLocations - // dashboardProxyLocation - // swaggerUiLocations - // lib.optionalAttrs cfg.auth.enable { - # Internal-only target for the dashboard's 401 error_page. - # `internal` prevents direct client access; `alias` serves - # the pre-built HTML from the Nix store. - "= /__hive_auth_unauthorized" = { - extraConfig = '' - internal; - alias ${errorPages.unauthorized}; - default_type text/html; - ''; - }; - }; + locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation; # Per-agent location blocks, generated at runtime by # hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf # on the host — the same machine nginx runs on. nginx parses @@ -275,5 +300,21 @@ in include /var/lib/hive-gateway/conf/agents.conf; ''; }; + + # The hive domain's own vhost: matrix discovery, which federation + # reads off the server name, and a redirect of every other path to the + # dashboard's name, so a bookmarked dashboard or agent link still lands. + ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { + listen = vhostListen; + locations = + matrixRedirectLocations + // wellKnownLocations + // { + "/".extraConfig = '' + return 301 ${publicScheme}://${dashboardDomain}${publicPortSuffix}$request_uri; + ''; + }; + extraConfig = securityHeaders; + }; }; } diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 8db3b95e..8fe21ffc 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -63,15 +63,23 @@ let # The role this hive's gateway leaf is issued through. ./swarm-bao.nix # writes one per hive in the swarm directory, under the same prefix, and - # narrows it to that hive's domain and `*.` that domain. So the leaf carries - # those two names and no others: a configured service name goes on the - # swarm-services leaf below, and asking this role for one is a refusal. + # narrows it to that hive's domain, `*.` that domain and its dashboard name. + # So the leaf carries those names and no others: a configured service name + # goes on the swarm-services leaf below, and asking this role for one is a + # refusal. hiveLeafRole = if hyperhiveCfg.hiveName == null then null else "${baoDeploy.hiveLeafPkiRolePrefix}-${hyperhiveCfg.hiveName}"; + # Everything after the first rides as a SAN. + hiveLeafNames = [ + domain + "*.${domain}" + ] + ++ lib.optional (hyperhiveCfg.swarm.dashboardDomain != null) hyperhiveCfg.swarm.dashboardDomain; + showPath = p: if p == null then "unset" else p; # Fails the unit, naming both options, when an identity is unset or a file @@ -159,7 +167,7 @@ let leafCoverage = '' svcleaf="$d/swarm-services.pem" svcroot="$d/swarm-services-root.pem" - hiveNames=${lib.escapeShellArg "${domain} *.${domain}"} + hiveNames=${lib.escapeShellArg (lib.concatStringsSep " " hiveLeafNames)} svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)} # A host fronting none of the swarm's service names has no services @@ -526,7 +534,7 @@ in if ! bao write -format=json \ ${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \ common_name=${lib.escapeShellArg domain} \ - alt_names=${lib.escapeShellArg "*.${domain}"} \ + alt_names=${lib.escapeShellArg (lib.concatStringsSep "," (lib.tail hiveLeafNames))} \ > "$resp" 2>"$err"; then fail "the store refused to issue the gateway leaf: $(cat "$err")" fi diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index fc4698ea..43e26110 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -12,10 +12,9 @@ # `docs/swarm/sso.md` owns the role model — session provider always, OIDC # provider derived from the client list — and the secrets map. # -# Per-service integration — putting authelia's `auth_request` in front -# of the gateway's existing `auth_basic` locations — is deliberately NOT -# here. Standing an SSO provider up is reversible; cutting every -# operator-facing vhost over to it is not. +# Per-service integration — the `auth_request` in front of a vhost — +# lives in the module declaring that vhost. What lives here is the +# `access_control` rule each of them relies on. { pkgs, lib, @@ -799,9 +798,8 @@ in # STARTUP, so a mismatch is a container that refuses to boot rather # than a login that misbehaves. # - # ⚠️ And deliberately NO `dashboardAuth` here. That block is the - # gateway's `auth_basic`; applying it to the SSO provider would put - # the login page behind the login mechanism it exists to replace. + # ⚠️ And deliberately NO `auth_request` here: it would put the login + # page behind the login it serves. services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; @@ -1215,7 +1213,13 @@ in # one, and without these rules any session passes it. ++ lib.optionals (swarmDomain != null) (operatorsOnly { domain = hyperhiveCfg.swarm.bao.ui.domain; - }); + }) + # Every hive's dashboard, each on its own hive's host. + ++ lib.optionals (swarmDomain != null) ( + lib.concatMap (domain: operatorsOnly { inherit domain; }) ( + lib.attrValues hyperhiveCfg.swarm.dashboardDomains + ) + ); }; # The cookie domain is the SWARM's domain, NOT authelia's @@ -1227,13 +1231,9 @@ in # setting both to the same host fails validation at startup # rather than at first login. # - # ⚠️ Known and accepted consequence while a hive keeps a - # domain outside the swarm's tree: this cookie is NOT sent - # to that hive's own surfaces (its dashboard), so SSO - # covers the swarm's services and not the hive's. It - # resolves when the hive domain moves under the swarm - # domain; until then it is a scope limit, not a bug to - # chase. + # Each hive's dashboard answers on `swarm.dashboardDomains`, + # under this domain, so the cookie reaches it whatever the + # hive's own domain is. session.cookies = [ { domain = cookieDomain; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 77b788ef..0754e0e5 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -789,6 +789,7 @@ let name = "${baoDeploy.hiveLeafPkiRolePrefix}-${hiveName}"; cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}"; inherit (hive) domain; + dashboard = hyperhiveCfg.swarm.dashboardDomains.${hiveName}; }) hyperhiveCfg.swarm.hives; # The PKI mount agent client certificates are issued from. Its root is @@ -3453,14 +3454,15 @@ in # A SIXTH sibling, the nats unit's shape once per hive: each hive's # gateway-leaf role, its policy and its login role. The role allows the - # hive's domain and `*.` that domain, and no IP SANs or localhost. + # hive's domain, `*.` that domain and the hive's dashboard name, and no + # IP SANs or localhost. systemd.services.swarm-bao-hive-leaf-policy = granterPolicyUnit "write the per-hive gateway-leaf pki roles, bao policies and cert-auth roles" ( lib.concatMapStrings (obj: '' bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${obj.name}"} \ - allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain}"} \ + allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain},${obj.dashboard}"} \ allow_bare_domains=true \ allow_subdomains=false \ allow_glob_domains=true \ diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 68c5b864..1cef50ef 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -795,6 +795,7 @@ in lib.mapAttrsToList (name: h: { inherit name; inherit (h) domain; + dashboard = config.services.hyperhive.swarm.dashboardDomains.${name}; }) config.services.hyperhive.swarm.hives ); # The merged links list — see `links`' description above for who diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index d5ed39e1..2572cc86 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -1,6 +1,6 @@ # The swarm-level web UI: a static bundle served by the gateway's nginx, # behind authelia. Distinct from the per-hive dashboard (hive-c0re's, on -# the hive domain) — this one answers for the swarm apex and is the +# `swarm.dashboardDomain`) — this one answers for the swarm apex and is the # operator's view across hives. # # Options only. The vhost itself is declared in hive-gateway/vhosts.nix @@ -105,19 +105,20 @@ in config = lib.mkIf deployCfg.swarm-controller.enable { assertions = [ { - # The `_` default server already answers for the hive domain - # (dashboard, per-agent routes). A second vhost claiming the same + # The hive domain has its own vhost (matrix discovery, the redirect + # to the dashboard). A second vhost claiming the same # server_name is not an error to nginx — it picks one and logs a - # conflict — so the failure would surface as "the dashboard is - # sometimes the swarm UI", which is far harder to read than an + # conflict — so the failure would surface as "the swarm UI is + # sometimes a redirect", which is far harder to read than an # eval failure naming both options. assertion = apex != hiveDomain; message = '' services.hyperhive.swarm.domain (${apex}) must differ from services.hyperhive.domain (${hiveDomain}) — the hive domain is - already served by the gateway's default vhost (dashboard + - agent routes), and two vhosts claiming one server_name - silently resolve to whichever nginx picks. + already served by the gateway's own vhost for it (matrix + discovery, the redirect to the dashboard), and two vhosts + claiming one server_name silently resolve to whichever nginx + picks. Set services.hyperhive.swarm.domain to a name distinct from this hive's. @@ -154,8 +155,7 @@ in } ]; - # The swarm's front page, and the FIRST `auth_request` anywhere in - # this gateway (everything else is `auth_basic` + htpasswd). + # The swarm's front page. # # ⚠️ `auth_request` answers "is there a session", not "is this an # operator". The operator-only part is authelia's `access_control` diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index e3fa09df..f6019499 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -99,6 +99,9 @@ let # `swarm.nats.domain` is deliberately absent: its `/metrics` vhost names # the queue's own leaf instead (./swarm-nats.nix), so a host running only # the queue fronts no services name and never waits for that leaf. + # + # So are the hives' `dashboardDomains`: each is a SAN on its own hive's + # leaf, which that hive issues with the identity it already holds. serviceDomains' = [ swarmCfg.forge.domain swarmCfg.matrix.gatewayHost @@ -264,6 +267,31 @@ in ''; }; + options.services.hyperhive.swarm.dashboardDomains = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + readOnly = true; + internal = true; + description = '' + Read-only: each hive's dashboard hostname, keyed by hive name, + `.hives.`. Under the swarm domain so authelia's + session cookie reaches it, whatever the hive's own domain is. + + Derived for every hive in `hives`, not only this one: the store's + host writes each hive's leaf role from it, and authelia's host writes + the operator-only rule for all of them. + ''; + }; + + options.services.hyperhive.swarm.dashboardDomain = lib.mkOption { + type = lib.types.nullOr lib.types.str; + readOnly = true; + internal = true; + description = '' + Read-only: this hive's entry in `dashboardDomains`, or null where + `hiveName` has no entry in `hives` (asserted below). + ''; + }; + options.services.hyperhive.swarm.serviceDomains = lib.mkOption { type = lib.types.listOf lib.types.str; readOnly = true; @@ -305,6 +333,19 @@ in }; config = { + # Total on a null `swarm.domain` for the reason `hives..domain`'s + # default gives. + services.hyperhive.swarm.dashboardDomains = lib.mapAttrs ( + name: _: + if swarmCfg.domain == null then "${name}.hives.invalid" else "${name}.hives.${swarmCfg.domain}" + ) swarmCfg.hives; + + services.hyperhive.swarm.dashboardDomain = + if cfg.hiveName != null && swarmCfg.dashboardDomains ? ${cfg.hiveName} then + swarmCfg.dashboardDomains.${cfg.hiveName} + else + null; + services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) ( lib.unique (lib.filter (d: d != null && d != "") serviceDomains') ); diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 8481a4ac..a171f7fc 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1326,7 +1326,7 @@ let # naming only its own hive: the policy text in the unit is exactly the # two `update` grants, and each hive's block names neither the other's # role nor its subject. - name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain"; + name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain and dashboard"; ok = let s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script; @@ -1341,7 +1341,7 @@ let h2 = blockOf "h2" "h1"; issues = h: dom: blk: - lib.hasInfix "allowed_domains='${dom},*.${dom}'" blk + lib.hasInfix "allowed_domains='${dom},*.${dom},${h}.hives.t.local'" blk && lib.hasInfix "allow_ip_sans=false" blk && lib.hasInfix "allow_localhost=false" blk && lib.hasInfix "allow_any_name=false" blk diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 0c518ab3..86738ad7 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -386,6 +386,60 @@ let && lib.hasInfix "auth_request /__hive_authelia;" l.${longest}.extraConfig && !(lib.any (name: lib.hasPrefix "= /api/agents" name) (lib.attrNames l)); } + { + # `auth_request` does not inherit across sibling locations, so each + # gated one is read on its own. `/health/` is the control: the one + # c0re route that must stay open, so a pass means the block is + # per-location rather than everywhere. + name = "the dashboard answers on its own name behind authelia, and the hive domain redirects there"; + ok = + let + vh = allLocal.services.nginx.virtualHosts; + l = vh."h1.hives.t.local".locations; + gated = loc: lib.hasInfix "auth_request /__hive_authelia;" loc.extraConfig; + in + allLocal.services.hyperhive.swarm.dashboardDomain == "h1.hives.t.local" + && vh."h1.hives.t.local".forceSSL + && lib.all (p: gated l.${p}) [ + "/" + "/api/" + "/api/docs/" + ] + && lib.hasInfix "error_page 401 =302 https://auth.t.local/?rd=$target_url;" l."/".extraConfig + && !(lib.hasInfix "auth_request" (l."/health/".extraConfig or "")) + && l."= /__hive_authelia".proxyPass == "https://auth.t.local/api/authz/auth-request" + && lib.hasInfix "agents.conf" vh."h1.hives.t.local".extraConfig + && + lib.hasInfix "return 301 https://h1.hives.t.local$request_uri;" + vh."h1.t.local".locations."/".extraConfig + && !(vh."h1.t.local".locations ? "/api/") + && lib.elem "h1.hives.t.local" allLocal.services.hyperhive.gateway.localNames + && allLocal.systemd.services.hive-c0re.environment.HYPERHIVE_DASHBOARD_DOMAIN == "h1.hives.t.local"; + } + { + # The file held bcrypt hashes at 0644; a host carrying one from before + # gets it deleted, and nothing names it for nginx. + name = "the gateway reads no htpasswd, and tmpfiles removes a leftover one"; + ok = + lib.elem "r /var/lib/hive-gateway/conf/gateway.htpasswd" allLocal.systemd.tmpfiles.rules + && !(lib.any ( + v: + lib.any (t: lib.hasInfix "auth_basic" t) ( + [ (v.extraConfig or "") ] ++ map (loc: loc.extraConfig or "") (lib.attrValues (v.locations or { })) + ) + ) (lib.attrValues allLocal.services.nginx.virtualHosts)); + } + { + name = "the swarm UI's hive roster carries each hive's dashboard name"; + ok = + builtins.fromJSON allLocal.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_HIVES == [ + { + name = "h1"; + domain = "h1.t.local"; + dashboard = "h1.hives.t.local"; + } + ]; + } { name = "a hive with matrix off serves no matrix discovery endpoint"; ok = diff --git a/nix/module-eval/hive-tls.nix b/nix/module-eval/hive-tls.nix index 051bc27e..fa122f80 100644 --- a/nix/module-eval/hive-tls.nix +++ b/nix/module-eval/hive-tls.nix @@ -609,7 +609,7 @@ let in lib.hasInfix "pki/issue/swarm-hive-leaf-h1" leafUnit.script && lib.hasInfix "common_name=h1.t.local" leafUnit.script - && lib.hasInfix "alt_names='*.h1.t.local'" leafUnit.script + && lib.hasInfix "alt_names='*.h1.t.local,h1.hives.t.local'" leafUnit.script && lib.hasInfix "bao login -method=cert -token-only" leafUnit.script && leafUnit.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/hive-leaf.pem" && leafUnit.environment.BAO_CLIENT_KEY == "/var/lib/swarm-bao-pki/hive-leaf-key.pem" diff --git a/nix/module-eval/swarm-otel-identity.nix b/nix/module-eval/swarm-otel-identity.nix index b1207d80..1b5cde81 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -783,10 +783,10 @@ let && policy "user:nobody" "forge.t.local" "/" == "one_factor"; } { - # Authelia on a host that runs no swarm controller, so the swarm UI's - # vhost is elsewhere. That vhost still asks this authelia, so the apex's - # pair renders here regardless. - name = "authelia confines the swarm UI to operators on a host without the swarm controller"; + # Authelia on a host that runs no swarm controller and no hive, so the + # swarm UI's vhost and the dashboard's are elsewhere. Both still ask + # this authelia, so their pairs render here regardless. + name = "authelia confines the swarm UI and each hive's dashboard to operators on a host running neither"; ok = let h = autheliaOnly.services.hyperhive; @@ -795,6 +795,7 @@ let in h.deploy.authelia.enable && !h.deploy.swarm-controller.enable + && !h.deploy.hive-controller.enable && lib.filter (r: r.domain == "t.local") rules == [ { @@ -809,7 +810,9 @@ let ] && autheliaSubjectRulesClosed rules && policy "user:nobody" "t.local" "/api/agents" == "deny" - && policy "group:admins" "t.local" "/api/agents" == "one_factor"; + && policy "group:admins" "t.local" "/api/agents" == "one_factor" + && policy "user:nobody" "h1.hives.t.local" "/api/" == "deny" + && policy "group:admins" "h1.hives.t.local" "/api/" == "one_factor"; } { # Every subject-scoped rule the host renders, read in order. The `elem` diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index ebcbe12e..70406a53 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -366,7 +366,7 @@ let !(lib.any (t: lib.any (r: lib.hasInfix r t) hiveLeafRefs) texts) && m.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile == null && noHiveSurface m; - # No hive-domain vhost, and no vhost on the host carrying the + # No hive-domain or dashboard vhost, and no vhost on the host carrying the # hive's locations: c0re's `/health/`, the agent routes, swagger, # or the hive domain's matrix discovery and `/matrix/` redirect. noHiveSurface = @@ -375,6 +375,7 @@ let vh = m.services.nginx.virtualHosts; in !(vh ? "h1.t.local") + && !(vh ? "h1.hives.t.local") && !(lib.any ( v: lib.any (l: (v.locations or { }) ? ${l}) [ @@ -464,15 +465,16 @@ let && !(store.services.nginx.virtualHosts ? "h1.t.local") && lib.hasInfix "want_svc=1" store.systemd.services.swarm-services-cert.script # Control: a hive host with the same services keeps the hive leaf, - # its unit and identity, and the hive-domain vhost. + # its unit and identity, the dashboard's vhost and the hive-domain one. && hs ? hive-tls-resign && lib.hasInfix "install -m 0644 /var/lib/hive-tls/gateway.pem /var/lib/hive-gateway/tls/cert.pem" hs.hive-gateway-self-signed-cert.script && swarmServicesHere.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile != null && ( let - l = swarmServicesHere.services.nginx.virtualHosts."h1.t.local".locations; + vh = swarmServicesHere.services.nginx.virtualHosts; + l = vh."h1.hives.t.local".locations; in - l ? "/api/" && l ? "/health/" && l ? "= /.well-known/matrix/client" + l ? "/api/" && l ? "/health/" && vh."h1.t.local".locations ? "= /.well-known/matrix/client" ) && !(noHiveLeafAnywhere swarmServicesHere); } diff --git a/swarm-controller/src/agent_limits.rs b/swarm-controller/src/agent_limits.rs index 832bd21b..d22096ca 100644 --- a/swarm-controller/src/agent_limits.rs +++ b/swarm-controller/src/agent_limits.rs @@ -483,6 +483,7 @@ pub(crate) mod tests { hives: Arc::new(vec![crate::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }]), links: Arc::new(Vec::new()), status: Some(Arc::new(crate::status::StatusReader::new( diff --git a/swarm-controller/src/forge_account.rs b/swarm-controller/src/forge_account.rs index aa3d19f3..f1262c63 100644 --- a/swarm-controller/src/forge_account.rs +++ b/swarm-controller/src/forge_account.rs @@ -146,6 +146,7 @@ mod tests { hives: std::sync::Arc::new(vec![super::super::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, diff --git a/swarm-controller/src/github_account.rs b/swarm-controller/src/github_account.rs index 794cb370..857fe3cd 100644 --- a/swarm-controller/src/github_account.rs +++ b/swarm-controller/src/github_account.rs @@ -116,6 +116,7 @@ mod tests { hives: std::sync::Arc::new(vec![super::super::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index c454b73d..10c05544 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -951,6 +951,7 @@ pub(crate) mod tests { hives: std::sync::Arc::new(vec![super::super::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index a3fa16d2..aa4f5b71 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -860,13 +860,15 @@ async fn health() -> &'static str { /// One hive in the swarm's directory — the same `name`/`domain` pair /// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares, -/// carried across unchanged rather than reshaped, so this stays a direct -/// mirror of the nix source of truth instead of a second vocabulary for -/// the same two fields. +/// plus that hive's `swarm.dashboardDomains` entry, carried across +/// unchanged rather than reshaped, so this stays a direct mirror of the +/// nix source of truth instead of a second vocabulary for the same fields. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] struct HiveEntry { name: String, domain: String, + /// The host the hive's dashboard answers on. + dashboard: String, } #[derive(Clone)] @@ -3706,6 +3708,7 @@ mod tests { hives: std::sync::Arc::new(vec![HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, @@ -4139,6 +4142,7 @@ mod tests { hives.push(HiveEntry { name: "sec0nd".to_owned(), domain: "sec0nd.example".to_owned(), + dashboard: "sec0nd.hives.example".to_owned(), }); let state = super::AppState { hives: std::sync::Arc::new(hives), @@ -5908,7 +5912,7 @@ mod tests { unsafe { std::env::set_var( HIVES_ENV, - r#"[{"name":"pr1ma","domain":"pr1ma.example.com"},{"name":"umbra","domain":"umbra.example.com"}]"#, + r#"[{"name":"pr1ma","domain":"pr1ma.example.com","dashboard":"pr1ma.hives.example"},{"name":"umbra","domain":"umbra.example.com","dashboard":"umbra.hives.example"}]"#, ); } assert_eq!( @@ -5917,10 +5921,12 @@ mod tests { HiveEntry { name: "pr1ma".to_string(), domain: "pr1ma.example.com".to_string(), + dashboard: "pr1ma.hives.example".to_string(), }, HiveEntry { name: "umbra".to_string(), domain: "umbra.example.com".to_string(), + dashboard: "umbra.hives.example".to_string(), }, ] ); diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 3b469411..955f3933 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -563,6 +563,7 @@ mod tests { hives: std::sync::Arc::new(vec![super::super::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, diff --git a/swarm-controller/src/status.rs b/swarm-controller/src/status.rs index 92e89f78..98c398c0 100644 --- a/swarm-controller/src/status.rs +++ b/swarm-controller/src/status.rs @@ -76,6 +76,8 @@ pub struct HiveStatus { pub name: String, /// From the roster; `None` for a hive the roster doesn't list. pub domain: Option, + /// The hive's dashboard host, from the roster; `None` as for `domain`. + pub dashboard: Option, pub freshness: Freshness, /// When the snapshot was stored, unix seconds. `None` when nothing /// has been. @@ -285,6 +287,7 @@ pub(crate) fn render( Some(offered) => row( hive.name.clone(), Some(hive.domain.clone()), + Some(hive.dashboard.clone()), offered, now, stale_after, @@ -292,6 +295,7 @@ pub(crate) fn render( None => HiveStatus { name: hive.name.clone(), domain: Some(hive.domain.clone()), + dashboard: Some(hive.dashboard.clone()), freshness: Freshness::NeverReported, last_seen_unix: None, age_seconds: None, @@ -305,7 +309,7 @@ pub(crate) fn render( .iter() .filter(|(name, _)| !roster.iter().any(|hive| &&hive.name == name)) .map(|(name, offered)| { - let mut unknown = row(name.clone(), None, offered, now, stale_after); + let mut unknown = row(name.clone(), None, None, offered, now, stale_after); unknown.freshness = Freshness::Unknown; unknown }), @@ -319,6 +323,7 @@ pub(crate) fn render( pub(crate) fn row( name: String, domain: Option, + dashboard: Option, offered: &Offered, now: SystemTime, stale_after: Duration, @@ -335,6 +340,7 @@ pub(crate) fn row( HiveStatus { name, domain, + dashboard, freshness, last_seen_unix: offered .received_at @@ -358,10 +364,12 @@ mod tests { HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example.com".to_owned(), + dashboard: "pr1ma.hives.example".to_owned(), }, HiveEntry { name: "umbra".to_owned(), domain: "umbra.example.com".to_owned(), + dashboard: "umbra.hives.example".to_owned(), }, ] } @@ -444,6 +452,11 @@ mod tests { assert_eq!(rows[0].freshness, Freshness::Fresh); assert_eq!(rows[1].name, "umbra"); assert_eq!(rows[1].freshness, Freshness::NeverReported); + assert_eq!( + [rows[0].dashboard.as_deref(), rows[1].dashboard.as_deref()], + [Some("pr1ma.hives.example"), Some("umbra.hives.example")], + "a roster hive's row links its dashboard whether or not it reported" + ); } /// An observation the daemon cannot explain is surfaced, not dropped. @@ -463,6 +476,7 @@ mod tests { ghost.domain.is_none(), "the roster is where a domain comes from, and this hive isn't in it" ); + assert!(ghost.dashboard.is_none()); } /// Clock skew must not produce a negative age or a panic. A snapshot