Watch
0
0
Fork
You've already forked hyperhive
0

gateway: serve the hive dashboard on <hive>.hives.<swarm> behind authelia, drop htpasswd

The dashboard, its API, the swagger UI and the agent routes move to their
own vhost, `swarm.dashboardDomain` = `<hive>.hives.<swarm.domain>`, 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
This commit is contained in:
atlas 2026-10-15 20:19:23 +02:00 • committed by mara
commit 92559af8e7
47 changed files with 351 additions and 623 deletions

59
Cargo.lock generated
View file

@ -37,7 +37,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher 0.4.4", "cipher",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
@ -379,19 +379,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.1"
@ -448,16 +435,6 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bs58" name = "bs58"
version = "0.5.1" version = "0.5.1"
@ -500,7 +477,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [ dependencies = [
"cipher 0.4.4", "cipher",
] ]
[[package]] [[package]]
@ -540,7 +517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher 0.4.4", "cipher",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
@ -563,7 +540,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [ dependencies = [
"aead", "aead",
"chacha20 0.9.1", "chacha20 0.9.1",
"cipher 0.4.4", "cipher",
"poly1305", "poly1305",
"zeroize", "zeroize",
] ]
@ -589,20 +566,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.7",
"inout 0.1.4", "inout",
"zeroize", "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]] [[package]]
name = "clap" name = "clap"
version = "4.6.2" version = "4.6.2"
@ -869,7 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [ dependencies = [
"aead", "aead",
"cipher 0.4.4", "cipher",
"generic-array", "generic-array",
"poly1305", "poly1305",
"salsa20", "salsa20",
@ -883,7 +850,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [ dependencies = [
"cipher 0.4.4", "cipher",
] ]
[[package]] [[package]]
@ -1795,7 +1762,6 @@ dependencies = [
"async-nats", "async-nats",
"axum", "axum",
"base64", "base64",
"bcrypt",
"chrono", "chrono",
"clap", "clap",
"clap-markdown", "clap-markdown",
@ -2511,15 +2477,6 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@ -4336,7 +4293,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [ dependencies = [
"cipher 0.4.4", "cipher",
] ]
[[package]] [[package]]

View file

@ -80,7 +80,6 @@ anyhow = "1"
libc = "0.2" libc = "0.2"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
base64 = "0.22" base64 = "0.22"
bcrypt = "0.19"
chrono = { version = "0.4", default-features = false, features = [ chrono = { version = "0.4", default-features = false, features = [
"clock", "clock",
"serde", "serde",

View file

@ -135,17 +135,13 @@ Later config changes are PRs on `agent-configs/iris`; you merge them on the
forge, and the merge deploys them. → forge, and the merge deploys them. →
[`agent-lifecycle/config-changes.md`](../agent-lifecycle/config-changes.md#config-changes) [`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 The per-hive dashboard answers on `<hive>.hives.<swarm domain>`, behind
Basic auth, set `services.hyperhive.gateway.auth.enable = true` and add authelia: log in with the `admins` account you created above. The hive's
logins: own domain redirects there.
```bash → [`networking/gateway.md`](../networking/gateway.md#dashboard-access)
echo "hunter2" | hivectl gateway create-user mara --password-stdin
```
→ [`networking/gateway.md`](../networking/gateway.md#http-basic-auth)
## Day to day ## Day to day

View file

@ -22,20 +22,22 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo
| `<queue domain>/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 | | `<queue domain>/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.<swarm>`, `metrics.<swarm>`, `logs.<swarm>`, `otel.<swarm>`, `bao.<swarm>` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) | | `grafana.<swarm>`, `metrics.<swarm>`, `logs.<swarm>`, `otel.<swarm>`, `bao.<swarm>` | 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 `<name>.hives.<swarm>` (`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 | | URL | upstream | when |
| --- | --- | --- | | --- | --- | --- |
| `<hive>/` | dashboard dist (static, from `servedFrontend`) | always | | `<name>.hives.<swarm>/` | dashboard dist (static, from `servedFrontend`), behind authelia | always |
| `<hive>/api/`, `/health/` | hive-c0re (`7000`) | always | | `<name>.hives.<swarm>/api/` | hive-c0re (`7000`), behind authelia | always |
| `<hive>/api/docs/` | themed Swagger UI dist (static) | always | | `<name>.hives.<swarm>/health/` | hive-c0re (`7000`) | always |
| `<hive>/agent/<name>/` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) | | `<name>.hives.<swarm>/api/docs/` | themed Swagger UI dist (static), behind authelia | always |
| `<name>.hives.<swarm>/agent/<name>/` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) |
| `<hive>/.well-known/matrix/{client,server}` | inline JSON | `deploy.matrix.enable` | | `<hive>/.well-known/matrix/{client,server}` | inline JSON | `deploy.matrix.enable` |
| `<hive>/matrix/` | 301 → `chat.<swarm>/` | `deploy.matrix.enable` and `gatewayHost` set | | `<hive>/matrix/` | 301 → `chat.<swarm>/` | `deploy.matrix.enable` and `gatewayHost` set |
| `<hive>/` (every other path) | 301 → `<name>.hives.<swarm>`, 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. 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.<swarm>`; 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.<swarm>`; 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.<swarm>` 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)). ⚠️ `auth.<swarm>` 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)). 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 `<name>.hives.<swarm>` (`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 <name> --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 **What it gates:** `/`, `/api/` and `/api/docs/`. **Not gated:** `/health/` (for uptime monitors; status only) and the per-agent `/agent/<name>/` routes, which come from `agents.conf` and carry no `auth_request`.
hivectl gateway create-user alice --password-stdin # password on stdin
hivectl gateway delete-user bob
hivectl gateway list-users
```
```nix **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 `*.<swarm>` wildcard doesn't cover `<name>.hives.<swarm>`.
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 <pw>` 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/<name>/` 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.
## Firewall posture (host-level) ## 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`: `services.hyperhive.gateway.localHostsEntry = true` maps to `127.0.0.1` in the host's `/etc/hosts`:
- the hive domain; - the hive domain;
- every name a module on this host contributes to `gateway.localNames` — each swarm service this host runs adds its own (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`, the swarm UI's apex, …). - every name a module on this host contributes to `gateway.localNames` — the dashboard's `<name>.hives.<swarm>` where a hive runs, and each swarm service this host runs adds its own (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`, the swarm UI's apex, …).
`services.hyperhive.deploy.singleHostSwarm` turns it on. Leave it off with real DNS. `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`. 504 = /__hive_agent_unreachable` rewrites to `unreachable.html`.
`hive-gateway/error-pages.nix` renders every page (`notFound`, `unreachable`, `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, 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 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 from the frontend dist and render even when hive-c0re is down. A service

View file

@ -84,7 +84,9 @@ below. A hive without them reads
Every row also carries `last_seen_unix` and `age_seconds` if you want to 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 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 Set `services.hyperhive.swarm.controller.staleAfterSeconds` (default
`120`) **above the rate hives publish at**, or everything reads `stale` `120`) **above the rate hives publish at**, or everything reads `stale`

View file

@ -10,7 +10,7 @@ debug agent behavior.
## For the operator ## For the operator
- **[hivectl](hivectl.md)** — the curated guide: provisioning forge - **[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. lifecycle shortcuts, interactive agent shell access.
- **[hivectl-cli](hivectl-cli.md)** — the exhaustive, autogenerated - **[hivectl-cli](hivectl-cli.md)** — the exhaustive, autogenerated
flag-by-flag reference, kept in lockstep with the binary by CI. flag-by-flag reference, kept in lockstep with the binary by CI.

View file

@ -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 forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl matrix`↴](#hivectl-matrix) * [`hivectl matrix`↴](#hivectl-matrix)
* [`hivectl matrix invite`↴](#hivectl-matrix-invite) * [`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`↴](#hivectl-agent)
* [`hivectl agent restart`↴](#hivectl-agent-restart) * [`hivectl agent restart`↴](#hivectl-agent-restart)
* [`hivectl agent pause`↴](#hivectl-agent-pause) * [`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 * `forge` — Reconcile an agent's config between this hive and the forge
* `matrix` — matrix-tuwunel invites * `matrix` — matrix-tuwunel invites
* `gateway` — Gateway htpasswd user management
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `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 * `list-agents` — Show all managed agents with their status and technical state
* `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem * `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 <COMMAND>`
###### **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] <USERNAME>`
###### **Arguments:**
* `<USERNAME>` — Username to add or update
###### **Options:**
* `--password <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 <USERNAME>`
###### **Arguments:**
* `<USERNAME>` — Username to remove
## `hivectl gateway list-users`
List all gateway htpasswd usernames, one per line
**Usage:** `hivectl gateway list-users`
## `hivectl agent` ## `hivectl agent`
Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running. 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: Possible values:
- `home`: - `home`:
The operator dashboard (`https://<domain>/`) The operator dashboard (`https://<hive>.hives.<swarm domain>/`)
- `forge`: - `forge`:
The forge (Forgejo) web UI The forge (Forgejo) web UI
- `matrix`: - `matrix`:

View file

@ -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), Unlike the `hive-c0re` daemon subcommands (which go through the broker),
`hivectl` covers direct host-side administration: manual provisioning of `hivectl` covers direct host-side administration: manual provisioning of
matrix accounts, gateway htpasswd management, container matrix accounts, container lifecycle shortcuts, and interactive agent shell access.
lifecycle shortcuts, and interactive agent shell access.
This page is the curated guide. For the exhaustive flag-by-flag This page is the curated guide. For the exhaustive flag-by-flag
reference autogenerated from the binary's own command tree, see 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). invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op. 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 ## Agents
Container lifecycle shortcuts that go through the host admin socket. Container lifecycle shortcuts that go through the host admin socket.
@ -229,7 +209,7 @@ Requires the `hive-c0re` daemon to be running.
```bash ```bash
hivectl open # operator dashboard (same as `open home`) hivectl open # operator dashboard (same as `open home`)
hivectl open home # operator dashboard (https://<domain>/) hivectl open home # operator dashboard (https://<hive>.hives.<swarm domain>/)
hivectl open forge # the forge (Forgejo) web UI hivectl open forge # the forge (Forgejo) web UI
hivectl open matrix # the matrix GUI (fluffychat) hivectl open matrix # the matrix GUI (fluffychat)
``` ```

View file

@ -30,6 +30,7 @@ type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface HiveStatus { interface HiveStatus {
name: string; name: string;
domain: string | null; domain: string | null;
dashboard: string | null;
freshness: Freshness; freshness: Freshness;
last_seen_unix: number | null; last_seen_unix: number | null;
// `age_seconds` (a once-computed-at-fetch age) is part of the wire // `age_seconds` (a once-computed-at-fetch age) is part of the wire
@ -60,8 +61,8 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
key: "domain", key: "domain",
header: "domain", header: "domain",
render: (h) => render: (h) =>
h.domain ? ( h.domain && h.dashboard ? (
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer"> <a href={`https://${h.dashboard}/`} target="_blank" rel="noreferrer">
{h.domain} {h.domain}
</a> </a>
) : ( ) : (

View file

@ -18,7 +18,6 @@ async-nats.workspace = true
axum.workspace = true axum.workspace = true
chrono.workspace = true chrono.workspace = true
base64.workspace = true base64.workspace = true
bcrypt.workspace = true
reqwest.workspace = true reqwest.workspace = true
forgejo-api.workspace = true forgejo-api.workspace = true
url.workspace = true url.workspace = true

View file

@ -110,14 +110,14 @@ impl From<ContainerView> 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 /// domain isn't configured — same env var, same "empty string counts as
/// unset" filter `server.rs::hive_urls()` uses for `HiveUrls::home`, kept /// unset" filter `server.rs::hive_urls()` uses for `HiveUrls::home`, kept
/// in sync with it by reading the one env var directly rather than /// in sync with it by reading the one env var directly rather than
/// threading a resolved domain through `HiveEnv` for a value nothing else /// threading a resolved domain through `HiveEnv` for a value nothing else
/// needs pre-resolved. /// needs pre-resolved.
fn agent_url(name: &str) -> Option<String> { fn agent_url(name: &str) -> Option<String> {
let domain = std::env::var("HYPERHIVE_HIVE_DOMAIN") let domain = std::env::var("HYPERHIVE_DASHBOARD_DOMAIN")
.ok() .ok()
.filter(|v| !v.is_empty())?; .filter(|v| !v.is_empty())?;
Some(agent_url_for_domain(&domain, name)) 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 // No source for reminders on this side (see the `From` impl's doc
// comment) — always the stub, never left uninitialised. // comment) — always the stub, never left uninitialised.
assert_eq!(row.pending_reminders, 0); 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 // it isn't asserted here — mutating env vars in a test race with
// every other test in this binary. `agent_url_for_domain` below // every other test in this binary. `agent_url_for_domain` below
// covers the actual path-formatting logic without touching env. // covers the actual path-formatting logic without touching env.

View file

@ -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<Vec<String>> {
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 (`<path>.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<String> {
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<String> {
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<Vec<String>> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::lifecycle::MANAGER_NAME; 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) ────────────────────────────── // ── legacy mode (frontend_dir = None) ──────────────────────────────
#[test] #[test]

View file

@ -28,7 +28,7 @@ use std::path::{Path, PathBuf};
// `hive-host-sock` (the crate hivectl links instead of the whole daemon); // `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 // re-exported here so this module stays the daemon's single-source facade for
// its own callsites. // 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/<name>/`, a sibling of /// Name of the per-agent working dir under `agents/<name>/`, a sibling of
/// `state/`. Bound RW into the container at `/agents/<name>/work`. /// `state/`. Bound RW into the container at `/agents/<name>/work`.

View file

@ -193,15 +193,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::ReconcileConfigApply { agent, direction } => { HostRequest::ReconcileConfigApply { agent, direction } => {
crate::forge::reconcile_config_apply(agent.as_str(), *direction).await? 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::QuotaEnable => handle_quota_enable().await?,
HostRequest::QuotaLimit { name, limit } => { HostRequest::QuotaLimit { name, limit } => {
handle_quota_limit(name.as_str(), *limit).await? 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 env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let domain = env("HYPERHIVE_HIVE_DOMAIN"); let domain = env("HYPERHIVE_HIVE_DOMAIN");
hive_host_sock::HiveUrls { 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"), forge: env("HIVE_FORGE_PUBLIC_URL"),
matrix: env("HIVE_MATRIX_PUBLIC_URL"), matrix: env("HIVE_MATRIX_PUBLIC_URL"),
domain, domain,

View file

@ -46,14 +46,6 @@ pub fn agent_state_dir(name: &Ident) -> PathBuf {
PathBuf::from(AGENTS_ROOT).join(name.as_str()) 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 /// `/run/hive-agent` — per-agent runtime socket dir root (web UI unix
/// socket + bound marker), one subdir per agent. The gateway's nginx /// socket + bound marker), one subdir per agent. The gateway's nginx
/// `proxy_pass`es to `agent_web_socket(name)` directly; `hivectl` dials /// `proxy_pass`es to `agent_web_socket(name)` directly; `hivectl` dials
@ -261,21 +253,6 @@ pub enum HostRequest {
agent: Ident, agent: Ident,
direction: ReconcileDirection, 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 /// Turn on btrfs qgroup accounting on the agent-state filesystem, via
/// the privileged helper. Daemon-side equivalent of `hivectl /// the privileged helper. Daemon-side equivalent of `hivectl
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`]. /// quota-enable`. Returns advisory lines in [`HostResponse::messages`].
@ -416,7 +393,7 @@ pub struct HiveUrls {
/// Canonical hive domain (`services.hyperhive.domain`). /// Canonical hive domain (`services.hyperhive.domain`).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>, pub domain: Option<String>,
/// Operator dashboard root (`https://<domain>/`). /// Operator dashboard root (`https://<services.hyperhive.swarm.dashboardDomain>/`).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub home: Option<String>, pub home: Option<String>,
/// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — `None` when /// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — `None` when

View file

@ -72,9 +72,9 @@ pub struct AgentStatusRow {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub status_set_at: Option<DateTime<Utc>>, pub status_set_at: Option<DateTime<Utc>>,
/// This agent's own web UI, behind the gateway /// This agent's own web UI, behind the gateway
/// (`https://<hive-domain>/agent/<name>/`) — the same URL the operator /// (`https://<dashboard-domain>/agent/<name>/`) — the same URL the operator
/// dashboard's "open" action uses. `None` when the hive's domain isn't /// dashboard's "open" action uses. `None` when the dashboard's domain isn't
/// configured (`HYPERHIVE_HIVE_DOMAIN` unset), same condition /// configured (`HYPERHIVE_DASHBOARD_DOMAIN` unset), same condition
/// `HostRequest::Urls`'s `HiveUrls::home` is `None` under. Present here /// `HostRequest::Urls`'s `HiveUrls::home` is `None` under. Present here
/// (not just derivable from `HiveUrls`) so a client never has to build /// (not just derivable from `HiveUrls`) so a client never has to build
/// this path itself — see `hive-c0re/container_view.rs`'s `agent_url` /// this path itself — see `hive-c0re/container_view.rs`'s `agent_url`

View file

@ -28,7 +28,6 @@ dispatch:
- **`forge.rs`** — reconciles an agent's config against its forge - **`forge.rs`** — reconciles an agent's config against its forge
repo. repo.
- **`matrix.rs`** — invite a matrix user to the hive Space or a room. - **`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. - **`wg.rs`** — WireGuard mesh helpers.
- **`subvol.rs`** — btrfs state-subvolume ops. - **`subvol.rs`** — btrfs state-subvolume ops.
- **`quota.rs`**, **`util.rs`**, **`completions.rs`**, **`open.rs`** — - **`quota.rs`**, **`util.rs`**, **`completions.rs`**, **`open.rs`** —

View file

@ -45,13 +45,6 @@ pub enum Cmd {
#[command(subcommand)] #[command(subcommand)]
cmd: MatrixCmd, 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 /// Lifecycle actions on ONE managed agent container. Needs the
/// hive-c0re daemon running. /// hive-c0re daemon running.
/// ///
@ -178,7 +171,7 @@ pub enum Cmd {
/// Which hive web surface `hivectl open` targets. /// Which hive web surface `hivectl open` targets.
#[derive(Copy, Clone, Debug, clap::ValueEnum)] #[derive(Copy, Clone, Debug, clap::ValueEnum)]
pub enum OpenTarget { pub enum OpenTarget {
/// The operator dashboard (`https://<domain>/`). /// The operator dashboard (`https://<hive>.hives.<swarm domain>/`).
Home, Home,
/// The forge (Forgejo) web UI. /// The forge (Forgejo) web UI.
Forge, 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<String>,
/// 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)] #[derive(Subcommand)]
pub enum WgCmd { pub enum WgCmd {
/// Generate this hive's WireGuard key (if absent) and print its public /// Generate this hive's WireGuard key (if absent) and print its public

View file

@ -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
}

View file

@ -26,13 +26,11 @@ mod client;
/// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner / /// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner /
/// plain renderers), split out to keep this file manageable. /// plain renderers), split out to keep this file manageable.
mod dag_progress; mod dag_progress;
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd}; use cli::{Cli, Cmd, ForgeCmd, WgCmd};
mod completions; mod completions;
mod quota; mod quota;
mod util; mod util;
use completions::generate_completions; use completions::generate_completions;
mod gateway;
use gateway::{gateway_create_user, gateway_delete_user, gateway_list_users};
mod matrix; mod matrix;
use matrix::run_matrix_cmd; use matrix::run_matrix_cmd;
mod open; mod open;
@ -68,15 +66,6 @@ async fn main() -> Result<()> {
} => forge_reconcile_config(&socket, &agent, from, verbose).await, } => forge_reconcile_config(&socket, &agent, from, verbose).await,
}, },
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).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::Agent { name, cmd } => run_agent(&socket, &name, cmd).await,
Cmd::ListAgents { json } => agents_list(&socket, json).await, Cmd::ListAgents { json } => agents_list(&socket, json).await,
Cmd::QuotaEnable => quota::quota_enable(&socket).await, Cmd::QuotaEnable => quota::quota_enable(&socket).await,

View file

@ -22,7 +22,7 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> {
let (url, hint) = match target { let (url, hint) = match target {
OpenTarget::Home => ( OpenTarget::Home => (
urls.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 => ( OpenTarget::Forge => (
urls.forge, urls.forge,

View file

@ -1,6 +1,6 @@
//! Shared helpers used across hivectl's command handling: daemon //! Shared helpers used across hivectl's command handling: daemon
//! request/response plumbing (`daemon_request`, `render`, //! request/response plumbing (`daemon_request`, `render`,
//! `render_lifecycle`) and password input (`resolve_password`). //! `render_lifecycle`).
use std::path::Path; use std::path::Path;
@ -18,7 +18,7 @@ pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
/// Send a provisioning request to the daemon and print its result lines. /// Send a provisioning request to the daemon and print its result lines.
/// The daemon owns the provisioning logic; hivectl just relays the outcome, /// 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( pub(crate) async fn daemon_request(
socket: &Path, socket: &Path,
req: hive_host_sock::HostRequest, req: hive_host_sock::HostRequest,
@ -40,35 +40,6 @@ pub(crate) async fn daemon_request(
Ok(()) Ok(())
} }
/// Resolve the password the caller asked for, or return `None` to fall
/// back to a random throwaway. `--password <PW>` 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<Option<String>> {
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 /// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the
/// agent-lifecycle verbs that just relay a daemon result verbatim. /// agent-lifecycle verbs that just relay a daemon result verbatim.
pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> { pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> {

View file

@ -355,7 +355,7 @@ in
# - nix build worktrees live in PrivateTmp, not /tmp. # - nix build worktrees live in PrivateTmp, not /tmp.
# - /etc writes (bind-mount edits) go through hive-priv via the # - /etc writes (bind-mount edits) go through hive-priv via the
# privileged socket; /etc/hyperhive/serve.json is read-only. # 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 # /var/lib/hive-gateway/conf, deliberately outside StateDirectory
# (see hive-c0re/src/paths.rs::GATEWAY_CONF_DIR) so nginx never # (see hive-c0re/src/paths.rs::GATEWAY_CONF_DIR) so nginx never
# needs access to the rest of c0re's state — that separation # needs access to the rest of c0re's state — that separation

View file

@ -58,6 +58,11 @@ in
# required (asserted in hive-network.nix), so it's always set. # required (asserted in hive-network.nix), so it's always set.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; 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) { // lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName; HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
} }

View file

@ -115,6 +115,8 @@ let
nginxTree = import ./vhosts.nix { nginxTree = import ./vhosts.nix {
gwLib = cfg.lib; gwLib = cfg.lib;
inherit (config.services.hyperhive.swarm) dashboardDomain;
autheliaDomain = config.services.hyperhive.swarm.authelia.domain;
inherit inherit
lib lib
cfg cfg
@ -236,19 +238,25 @@ in
# /var/lib/hyperhive. # /var/lib/hyperhive.
"d /var/lib/hive-gateway 0755 root root - -" "d /var/lib/hive-gateway 0755 root root - -"
"d /var/lib/hive-gateway/conf 0755 hive-core hive-core - -" "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" "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 # Nothing reads or writes this path. A copy left on an existing host
# c0re has ever written: nginx names them, and an `include` of a # holds bcrypt password hashes at 0644; tmpfiles' `--remove` pass, on
# missing file is a fatal config error, not an empty one. tmpfiles # switch and at boot, deletes it.
# runs before services, which is the whole ordering guarantee — "r /var/lib/hive-gateway/conf/gateway.htpasswd"
# 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 - -"
]; ];
# 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 # ACME (Let's Encrypt) integration. nginx vhosts set
# `enableACME = true` via the vhost builder; this provides the # `enableACME = true` via the vhost builder; this provides the
# shared ACME config (acceptTerms + email). # shared ACME config (acceptTerms + email).

View file

@ -67,17 +67,4 @@ in
<p class="hint">Check the container: <code>journalctl -M swarm-authelia -u authelia-swarm</code>. This page recovers on reload once the provider is up.</p> <p class="hint">Check the container: <code>journalctl -M swarm-authelia -u authelia-swarm</code>. This page recovers on reload once the provider is up.</p>
''; '';
}; };
unauthorized = mkPage {
name = "unauthorized";
title = "unauthorized";
accent = "#f38ba8";
body = ''
<p>This hive is protected by HTTP Basic auth. Valid credentials are required.</p>
<p class="hint">Operator: add a user with <code>hivectl gateway create-user</code>:</p>
<pre>hivectl gateway create-user \
&lt;username&gt; --password-stdin</pre>
<p class="hint">Then reload your browser and enter the credentials when prompted.</p>
'';
};
} }

View file

@ -18,6 +18,12 @@ in
mode. Remove the setting; configure `tls.certDir` or `tls.acme` mode. Remove the setting; configure `tls.certDir` or `tls.acme`
to override the self-signed default. 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 <name> --group admins`.
'')
]; ];
options.services.hyperhive.gateway = { options.services.hyperhive.gateway = {
@ -169,7 +175,7 @@ in
readOnly = true; readOnly = true;
description = '' description = ''
Read-only: the gateway's styled static error pages, by name 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 Published so a service module can aim an `error_page` at one
instead of rendering its own — a service that built its own 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 { swaggerUiTheme = lib.mkOption {
type = lib.types.package; type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swagger-ui-theme"; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swagger-ui-theme";

View file

@ -22,7 +22,7 @@
svcCert, # swarm-services leaf, for names the hive leaf cannot carry svcCert, # swarm-services leaf, for names the hive leaf cannot carry
svcKey, svcKey,
swarmServiceDomains, # which names those are (../swarm.nix derives it) 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 caBundle, # hive TLS trust bundle on the host
}: }:
let let

View file

@ -1,8 +1,10 @@
# nginx virtual-host tree for the **hive's own** gateway surface: the `_` # nginx virtual-host tree for the **hive's own** gateway surface: the `_`
# default server, which answers unmatched `Host` with a bare 444, and the # default server, which answers unmatched `Host` with a bare 444; the
# vhost named for the hive domain, which carries the dashboard, per-agent # dashboard's vhost (`swarm.dashboardDomain`), which carries the dashboard
# routing, matrix discovery, the swagger UI and the legacy # behind authelia, per-agent routing and the swagger UI; and the vhost
# `<hive>/matrix/*` redirect. # named for the hive domain, which carries matrix discovery, the legacy
# `<hive>/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 # Scope: a swarm service declares its own vhost and dns name in its own
# module, built from the kit this file also consumes # module, built from the kit this file also consumes
@ -18,10 +20,12 @@
matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS
matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs
hyperhiveDomain, hyperhiveDomain,
dashboardDomain, # services.hyperhive.swarm.dashboardDomain
autheliaDomain, # services.hyperhive.swarm.authelia.domain
runsHive, # deploy.hive-controller.enable — whether hive-c0re runs here runsHive, # deploy.hive-controller.enable — whether hive-c0re runs here
dashboardDist, dashboardDist,
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css 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 gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option
}: }:
let let
@ -126,15 +130,19 @@ let
}; };
}; };
# Shared auth block — separate locations don't inherit auth_basic, so # Repeated verbatim by every operator-gated location (`/`, `/api/`,
# each dashboard location (`/`, `/api/`) needs it or that surface is # `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling
# unauthed. # locations, so a location without it is reachable with no session.
dashboardAuth = lib.optionalString cfg.auth.enable '' #
auth_basic "${cfg.auth.realm}"; # ⚠️ `auth_request` answers "is there a session", not "is this an
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd; # operator". authelia's `operatorsOnly` rule for this name
# `=401` keeps the status 401 so the login dialog shows; the # (../swarm-authelia.nix) is what refuses an agent's own account.
# internal page explains `hivectl gateway create-user`. dashboardAuth = ''
error_page 401 =401 /__hive_auth_unauthorized; 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 # Dashboard: nginx static-serves the dist, c0re is API-only. Routing
@ -167,8 +175,8 @@ let
''; '';
}; };
"/health/" = { "/health/" = {
# No dashboardAuth here: an external uptime monitor generally can't do # No dashboardAuth here: an external uptime monitor can't do an
# interactive HTTP Basic. The endpoints themselves are scoped to # interactive login. The endpoints themselves are scoped to
# status + warning kind/message (see hive-c0re/src/dashboard/ # status + warning kind/message (see hive-c0re/src/dashboard/
# health.rs) — no tokens, no agent detail — so exposing them # health.rs) — no tokens, no agent detail — so exposing them
# unauthenticated isn't a new secret surface. # 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 in
{ {
virtualHosts = { virtualHosts = {
@ -237,32 +268,26 @@ in
''; '';
}; };
} }
// lib.optionalAttrs runsHive { // lib.optionalAttrs (runsHive && dashboardDomain != null) {
# The hive domain's own vhost, reachable by NAME, so the catch-all can # The dashboard, reachable by its own NAME under the swarm domain, where
# reject everything else. Only where a hive runs: the dashboard, agent # authelia's session cookie reaches. Only where a hive runs: the
# routes and swagger UI proxy to hive-c0re, which runs only with the hive # dashboard, agent routes and swagger UI proxy to hive-c0re, which runs
# controller, and the certificate naming the hive domain is the hive # only with the hive controller, and the hive leaf carrying this name
# leaf, which exists only there. # exists only there.
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { #
# 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; listen = vhostListen;
locations = locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation;
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;
'';
};
};
# Per-agent location blocks, generated at runtime by # Per-agent location blocks, generated at runtime by
# hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf # hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf
# on the host — the same machine nginx runs on. nginx parses # on the host — the same machine nginx runs on. nginx parses
@ -275,5 +300,21 @@ in
include /var/lib/hive-gateway/conf/agents.conf; 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;
};
}; };
} }

View file

@ -63,15 +63,23 @@ let
# The role this hive's gateway leaf is issued through. ./swarm-bao.nix # 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 # 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 # narrows it to that hive's domain, `*.` that domain and its dashboard name.
# those two names and no others: a configured service name goes on the # So the leaf carries those names and no others: a configured service name
# swarm-services leaf below, and asking this role for one is a refusal. # goes on the swarm-services leaf below, and asking this role for one is a
# refusal.
hiveLeafRole = hiveLeafRole =
if hyperhiveCfg.hiveName == null then if hyperhiveCfg.hiveName == null then
null null
else else
"${baoDeploy.hiveLeafPkiRolePrefix}-${hyperhiveCfg.hiveName}"; "${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; showPath = p: if p == null then "unset" else p;
# Fails the unit, naming both options, when an identity is unset or a file # Fails the unit, naming both options, when an identity is unset or a file
@ -159,7 +167,7 @@ let
leafCoverage = '' leafCoverage = ''
svcleaf="$d/swarm-services.pem" svcleaf="$d/swarm-services.pem"
svcroot="$d/swarm-services-root.pem" svcroot="$d/swarm-services-root.pem"
hiveNames=${lib.escapeShellArg "${domain} *.${domain}"} hiveNames=${lib.escapeShellArg (lib.concatStringsSep " " hiveLeafNames)}
svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)} svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)}
# A host fronting none of the swarm's service names has no services # A host fronting none of the swarm's service names has no services
@ -526,7 +534,7 @@ in
if ! bao write -format=json \ if ! bao write -format=json \
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \ ${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \
common_name=${lib.escapeShellArg domain} \ common_name=${lib.escapeShellArg domain} \
alt_names=${lib.escapeShellArg "*.${domain}"} \ alt_names=${lib.escapeShellArg (lib.concatStringsSep "," (lib.tail hiveLeafNames))} \
> "$resp" 2>"$err"; then > "$resp" 2>"$err"; then
fail "the store refused to issue the gateway leaf: $(cat "$err")" fail "the store refused to issue the gateway leaf: $(cat "$err")"
fi fi

View file

@ -12,10 +12,9 @@
# `docs/swarm/sso.md` owns the role model — session provider always, OIDC # `docs/swarm/sso.md` owns the role model — session provider always, OIDC
# provider derived from the client list — and the secrets map. # provider derived from the client list — and the secrets map.
# #
# Per-service integration — putting authelia's `auth_request` in front # Per-service integration — the `auth_request` in front of a vhost —
# of the gateway's existing `auth_basic` locations — is deliberately NOT # lives in the module declaring that vhost. What lives here is the
# here. Standing an SSO provider up is reversible; cutting every # `access_control` rule each of them relies on.
# operator-facing vhost over to it is not.
{ {
pkgs, pkgs,
lib, lib,
@ -799,9 +798,8 @@ in
# STARTUP, so a mismatch is a container that refuses to boot rather # STARTUP, so a mismatch is a container that refuses to boot rather
# than a login that misbehaves. # than a login that misbehaves.
# #
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the # ⚠️ And deliberately NO `auth_request` here: it would put the login
# gateway's `auth_basic`; applying it to the SSO provider would put # page behind the login it serves.
# the login page behind the login mechanism it exists to replace.
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
listen = gatewayCfg.lib.listen; listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders; extraConfig = gatewayCfg.lib.securityHeaders;
@ -1215,7 +1213,13 @@ in
# one, and without these rules any session passes it. # one, and without these rules any session passes it.
++ lib.optionals (swarmDomain != null) (operatorsOnly { ++ lib.optionals (swarmDomain != null) (operatorsOnly {
domain = hyperhiveCfg.swarm.bao.ui.domain; 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 # 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 # setting both to the same host fails validation at startup
# rather than at first login. # rather than at first login.
# #
# ⚠️ Known and accepted consequence while a hive keeps a # Each hive's dashboard answers on `swarm.dashboardDomains`,
# domain outside the swarm's tree: this cookie is NOT sent # under this domain, so the cookie reaches it whatever the
# to that hive's own surfaces (its dashboard), so SSO # hive's own domain is.
# 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.
session.cookies = [ session.cookies = [
{ {
domain = cookieDomain; domain = cookieDomain;

View file

@ -789,6 +789,7 @@ let
name = "${baoDeploy.hiveLeafPkiRolePrefix}-${hiveName}"; name = "${baoDeploy.hiveLeafPkiRolePrefix}-${hiveName}";
cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}"; cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}";
inherit (hive) domain; inherit (hive) domain;
dashboard = hyperhiveCfg.swarm.dashboardDomains.${hiveName};
}) hyperhiveCfg.swarm.hives; }) hyperhiveCfg.swarm.hives;
# The PKI mount agent client certificates are issued from. Its root is # 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 # 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 # 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 = systemd.services.swarm-bao-hive-leaf-policy =
granterPolicyUnit "write the per-hive gateway-leaf pki roles, bao policies and cert-auth roles" granterPolicyUnit "write the per-hive gateway-leaf pki roles, bao policies and cert-auth roles"
( (
lib.concatMapStrings (obj: '' lib.concatMapStrings (obj: ''
bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${obj.name}"} \ 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_bare_domains=true \
allow_subdomains=false \ allow_subdomains=false \
allow_glob_domains=true \ allow_glob_domains=true \

View file

@ -795,6 +795,7 @@ in
lib.mapAttrsToList (name: h: { lib.mapAttrsToList (name: h: {
inherit name; inherit name;
inherit (h) domain; inherit (h) domain;
dashboard = config.services.hyperhive.swarm.dashboardDomains.${name};
}) config.services.hyperhive.swarm.hives }) config.services.hyperhive.swarm.hives
); );
# The merged links list — see `links`' description above for who # The merged links list — see `links`' description above for who

View file

@ -1,6 +1,6 @@
# The swarm-level web UI: a static bundle served by the gateway's nginx, # 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 # 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. # operator's view across hives.
# #
# Options only. The vhost itself is declared in hive-gateway/vhosts.nix # Options only. The vhost itself is declared in hive-gateway/vhosts.nix
@ -105,19 +105,20 @@ in
config = lib.mkIf deployCfg.swarm-controller.enable { config = lib.mkIf deployCfg.swarm-controller.enable {
assertions = [ assertions = [
{ {
# The `_` default server already answers for the hive domain # The hive domain has its own vhost (matrix discovery, the redirect
# (dashboard, per-agent routes). A second vhost claiming the same # to the dashboard). A second vhost claiming the same
# server_name is not an error to nginx — it picks one and logs a # server_name is not an error to nginx — it picks one and logs a
# conflict — so the failure would surface as "the dashboard is # conflict — so the failure would surface as "the swarm UI is
# sometimes the swarm UI", which is far harder to read than an # sometimes a redirect", which is far harder to read than an
# eval failure naming both options. # eval failure naming both options.
assertion = apex != hiveDomain; assertion = apex != hiveDomain;
message = '' message = ''
services.hyperhive.swarm.domain (${apex}) must differ from services.hyperhive.swarm.domain (${apex}) must differ from
services.hyperhive.domain (${hiveDomain}) — the hive domain is services.hyperhive.domain (${hiveDomain}) — the hive domain is
already served by the gateway's default vhost (dashboard + already served by the gateway's own vhost for it (matrix
agent routes), and two vhosts claiming one server_name discovery, the redirect to the dashboard), and two vhosts
silently resolve to whichever nginx picks. claiming one server_name silently resolve to whichever nginx
picks.
Set services.hyperhive.swarm.domain to a name distinct from Set services.hyperhive.swarm.domain to a name distinct from
this hive's. this hive's.
@ -154,8 +155,7 @@ in
} }
]; ];
# The swarm's front page, and the FIRST `auth_request` anywhere in # The swarm's front page.
# this gateway (everything else is `auth_basic` + htpasswd).
# #
# ⚠️ `auth_request` answers "is there a session", not "is this an # ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". The operator-only part is authelia's `access_control` # operator". The operator-only part is authelia's `access_control`

View file

@ -99,6 +99,9 @@ let
# `swarm.nats.domain` is deliberately absent: its `/metrics` vhost names # `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's own leaf instead (./swarm-nats.nix), so a host running only
# the queue fronts no services name and never waits for that leaf. # 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' = [ serviceDomains' = [
swarmCfg.forge.domain swarmCfg.forge.domain
swarmCfg.matrix.gatewayHost 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,
`<hive>.hives.<swarm.domain>`. 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 { options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
readOnly = true; readOnly = true;
@ -305,6 +333,19 @@ in
}; };
config = { config = {
# Total on a null `swarm.domain` for the reason `hives.<name>.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) ( services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
lib.unique (lib.filter (d: d != null && d != "") serviceDomains') lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
); );

View file

@ -1326,7 +1326,7 @@ let
# naming only its own hive: the policy text in the unit is exactly the # 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 # two `update` grants, and each hive's block names neither the other's
# role nor its subject. # 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 = ok =
let let
s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script; s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script;
@ -1341,7 +1341,7 @@ let
h2 = blockOf "h2" "h1"; h2 = blockOf "h2" "h1";
issues = issues =
h: dom: blk: 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_ip_sans=false" blk
&& lib.hasInfix "allow_localhost=false" blk && lib.hasInfix "allow_localhost=false" blk
&& lib.hasInfix "allow_any_name=false" blk && lib.hasInfix "allow_any_name=false" blk

View file

@ -386,6 +386,60 @@ let
&& lib.hasInfix "auth_request /__hive_authelia;" l.${longest}.extraConfig && lib.hasInfix "auth_request /__hive_authelia;" l.${longest}.extraConfig
&& !(lib.any (name: lib.hasPrefix "= /api/agents" name) (lib.attrNames l)); && !(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"; name = "a hive with matrix off serves no matrix discovery endpoint";
ok = ok =

View file

@ -609,7 +609,7 @@ let
in in
lib.hasInfix "pki/issue/swarm-hive-leaf-h1" leafUnit.script lib.hasInfix "pki/issue/swarm-hive-leaf-h1" leafUnit.script
&& lib.hasInfix "common_name=h1.t.local" 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 && 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_CERT == "/var/lib/swarm-bao-pki/hive-leaf.pem"
&& leafUnit.environment.BAO_CLIENT_KEY == "/var/lib/swarm-bao-pki/hive-leaf-key.pem" && leafUnit.environment.BAO_CLIENT_KEY == "/var/lib/swarm-bao-pki/hive-leaf-key.pem"

View file

@ -783,10 +783,10 @@ let
&& policy "user:nobody" "forge.t.local" "/" == "one_factor"; && policy "user:nobody" "forge.t.local" "/" == "one_factor";
} }
{ {
# Authelia on a host that runs no swarm controller, so the swarm UI's # Authelia on a host that runs no swarm controller and no hive, so the
# vhost is elsewhere. That vhost still asks this authelia, so the apex's # swarm UI's vhost and the dashboard's are elsewhere. Both still ask
# pair renders here regardless. # this authelia, so their pairs render here regardless.
name = "authelia confines the swarm UI to operators on a host without the swarm controller"; name = "authelia confines the swarm UI and each hive's dashboard to operators on a host running neither";
ok = ok =
let let
h = autheliaOnly.services.hyperhive; h = autheliaOnly.services.hyperhive;
@ -795,6 +795,7 @@ let
in in
h.deploy.authelia.enable h.deploy.authelia.enable
&& !h.deploy.swarm-controller.enable && !h.deploy.swarm-controller.enable
&& !h.deploy.hive-controller.enable
&& &&
lib.filter (r: r.domain == "t.local") rules == [ lib.filter (r: r.domain == "t.local") rules == [
{ {
@ -809,7 +810,9 @@ let
] ]
&& autheliaSubjectRulesClosed rules && autheliaSubjectRulesClosed rules
&& policy "user:nobody" "t.local" "/api/agents" == "deny" && 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` # Every subject-scoped rule the host renders, read in order. The `elem`

View file

@ -366,7 +366,7 @@ let
!(lib.any (t: lib.any (r: lib.hasInfix r t) hiveLeafRefs) texts) !(lib.any (t: lib.any (r: lib.hasInfix r t) hiveLeafRefs) texts)
&& m.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile == null && m.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile == null
&& noHiveSurface m; && 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, # hive's locations: c0re's `/health/`, the agent routes, swagger,
# or the hive domain's matrix discovery and `/matrix/` redirect. # or the hive domain's matrix discovery and `/matrix/` redirect.
noHiveSurface = noHiveSurface =
@ -375,6 +375,7 @@ let
vh = m.services.nginx.virtualHosts; vh = m.services.nginx.virtualHosts;
in in
!(vh ? "h1.t.local") !(vh ? "h1.t.local")
&& !(vh ? "h1.hives.t.local")
&& !(lib.any ( && !(lib.any (
v: v:
lib.any (l: (v.locations or { }) ? ${l}) [ lib.any (l: (v.locations or { }) ? ${l}) [
@ -464,15 +465,16 @@ let
&& !(store.services.nginx.virtualHosts ? "h1.t.local") && !(store.services.nginx.virtualHosts ? "h1.t.local")
&& lib.hasInfix "want_svc=1" store.systemd.services.swarm-services-cert.script && lib.hasInfix "want_svc=1" store.systemd.services.swarm-services-cert.script
# Control: a hive host with the same services keeps the hive leaf, # 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 && 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 && 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 && swarmServicesHere.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile != null
&& ( && (
let let
l = swarmServicesHere.services.nginx.virtualHosts."h1.t.local".locations; vh = swarmServicesHere.services.nginx.virtualHosts;
l = vh."h1.hives.t.local".locations;
in 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); && !(noHiveLeafAnywhere swarmServicesHere);
} }

View file

@ -483,6 +483,7 @@ pub(crate) mod tests {
hives: Arc::new(vec![crate::HiveEntry { hives: Arc::new(vec![crate::HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(), domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]), }]),
links: Arc::new(Vec::new()), links: Arc::new(Vec::new()),
status: Some(Arc::new(crate::status::StatusReader::new( status: Some(Arc::new(crate::status::StatusReader::new(

View file

@ -146,6 +146,7 @@ mod tests {
hives: std::sync::Arc::new(vec![super::super::HiveEntry { hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(), domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]), }]),
links: std::sync::Arc::new(Vec::new()), links: std::sync::Arc::new(Vec::new()),
status: None, status: None,

View file

@ -116,6 +116,7 @@ mod tests {
hives: std::sync::Arc::new(vec![super::super::HiveEntry { hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(), domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]), }]),
links: std::sync::Arc::new(Vec::new()), links: std::sync::Arc::new(Vec::new()),
status: None, status: None,

View file

@ -951,6 +951,7 @@ pub(crate) mod tests {
hives: std::sync::Arc::new(vec![super::super::HiveEntry { hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(), domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]), }]),
links: std::sync::Arc::new(Vec::new()), links: std::sync::Arc::new(Vec::new()),
status: None, status: None,

View file

@ -860,13 +860,15 @@ async fn health() -> &'static str {
/// One hive in the swarm's directory — the same `name`/`domain` pair /// One hive in the swarm's directory — the same `name`/`domain` pair
/// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares, /// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares,
/// carried across unchanged rather than reshaped, so this stays a direct /// plus that hive's `swarm.dashboardDomains` entry, carried across
/// mirror of the nix source of truth instead of a second vocabulary for /// unchanged rather than reshaped, so this stays a direct mirror of the
/// the same two fields. /// nix source of truth instead of a second vocabulary for the same fields.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
struct HiveEntry { struct HiveEntry {
name: String, name: String,
domain: String, domain: String,
/// The host the hive's dashboard answers on.
dashboard: String,
} }
#[derive(Clone)] #[derive(Clone)]
@ -3706,6 +3708,7 @@ mod tests {
hives: std::sync::Arc::new(vec![HiveEntry { hives: std::sync::Arc::new(vec![HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(), domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]), }]),
links: std::sync::Arc::new(Vec::new()), links: std::sync::Arc::new(Vec::new()),
status: None, status: None,
@ -4139,6 +4142,7 @@ mod tests {
hives.push(HiveEntry { hives.push(HiveEntry {
name: "sec0nd".to_owned(), name: "sec0nd".to_owned(),
domain: "sec0nd.example".to_owned(), domain: "sec0nd.example".to_owned(),
dashboard: "sec0nd.hives.example".to_owned(),
}); });
let state = super::AppState { let state = super::AppState {
hives: std::sync::Arc::new(hives), hives: std::sync::Arc::new(hives),
@ -5908,7 +5912,7 @@ mod tests {
unsafe { unsafe {
std::env::set_var( std::env::set_var(
HIVES_ENV, 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!( assert_eq!(
@ -5917,10 +5921,12 @@ mod tests {
HiveEntry { HiveEntry {
name: "pr1ma".to_string(), name: "pr1ma".to_string(),
domain: "pr1ma.example.com".to_string(), domain: "pr1ma.example.com".to_string(),
dashboard: "pr1ma.hives.example".to_string(),
}, },
HiveEntry { HiveEntry {
name: "umbra".to_string(), name: "umbra".to_string(),
domain: "umbra.example.com".to_string(), domain: "umbra.example.com".to_string(),
dashboard: "umbra.hives.example".to_string(),
}, },
] ]
); );

View file

@ -563,6 +563,7 @@ mod tests {
hives: std::sync::Arc::new(vec![super::super::HiveEntry { hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(), domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]), }]),
links: std::sync::Arc::new(Vec::new()), links: std::sync::Arc::new(Vec::new()),
status: None, status: None,

View file

@ -76,6 +76,8 @@ pub struct HiveStatus {
pub name: String, pub name: String,
/// From the roster; `None` for a hive the roster doesn't list. /// From the roster; `None` for a hive the roster doesn't list.
pub domain: Option<String>, pub domain: Option<String>,
/// The hive's dashboard host, from the roster; `None` as for `domain`.
pub dashboard: Option<String>,
pub freshness: Freshness, pub freshness: Freshness,
/// When the snapshot was stored, unix seconds. `None` when nothing /// When the snapshot was stored, unix seconds. `None` when nothing
/// has been. /// has been.
@ -285,6 +287,7 @@ pub(crate) fn render(
Some(offered) => row( Some(offered) => row(
hive.name.clone(), hive.name.clone(),
Some(hive.domain.clone()), Some(hive.domain.clone()),
Some(hive.dashboard.clone()),
offered, offered,
now, now,
stale_after, stale_after,
@ -292,6 +295,7 @@ pub(crate) fn render(
None => HiveStatus { None => HiveStatus {
name: hive.name.clone(), name: hive.name.clone(),
domain: Some(hive.domain.clone()), domain: Some(hive.domain.clone()),
dashboard: Some(hive.dashboard.clone()),
freshness: Freshness::NeverReported, freshness: Freshness::NeverReported,
last_seen_unix: None, last_seen_unix: None,
age_seconds: None, age_seconds: None,
@ -305,7 +309,7 @@ pub(crate) fn render(
.iter() .iter()
.filter(|(name, _)| !roster.iter().any(|hive| &&hive.name == name)) .filter(|(name, _)| !roster.iter().any(|hive| &&hive.name == name))
.map(|(name, offered)| { .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.freshness = Freshness::Unknown;
unknown unknown
}), }),
@ -319,6 +323,7 @@ pub(crate) fn render(
pub(crate) fn row( pub(crate) fn row(
name: String, name: String,
domain: Option<String>, domain: Option<String>,
dashboard: Option<String>,
offered: &Offered, offered: &Offered,
now: SystemTime, now: SystemTime,
stale_after: Duration, stale_after: Duration,
@ -335,6 +340,7 @@ pub(crate) fn row(
HiveStatus { HiveStatus {
name, name,
domain, domain,
dashboard,
freshness, freshness,
last_seen_unix: offered last_seen_unix: offered
.received_at .received_at
@ -358,10 +364,12 @@ mod tests {
HiveEntry { HiveEntry {
name: "pr1ma".to_owned(), name: "pr1ma".to_owned(),
domain: "pr1ma.example.com".to_owned(), domain: "pr1ma.example.com".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}, },
HiveEntry { HiveEntry {
name: "umbra".to_owned(), name: "umbra".to_owned(),
domain: "umbra.example.com".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[0].freshness, Freshness::Fresh);
assert_eq!(rows[1].name, "umbra"); assert_eq!(rows[1].name, "umbra");
assert_eq!(rows[1].freshness, Freshness::NeverReported); 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. /// An observation the daemon cannot explain is surfaced, not dropped.
@ -463,6 +476,7 @@ mod tests {
ghost.domain.is_none(), ghost.domain.is_none(),
"the roster is where a domain comes from, and this hive isn't in it" "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 /// Clock skew must not produce a negative age or a panic. A snapshot