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

View file

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

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. →
[`agent-lifecycle/config-changes.md`](../agent-lifecycle/config-changes.md#config-changes)
## Optional · Lock the hive dashboard
## The hive dashboard
The per-hive dashboard has no SSO in front of it. To put it behind HTTP
Basic auth, set `services.hyperhive.gateway.auth.enable = true` and add
logins:
The per-hive dashboard answers on `<hive>.hives.<swarm domain>`, behind
authelia: log in with the `admins` account you created above. The hive's
own domain redirects there.
```bash
echo "hunter2" | hivectl gateway create-user mara --password-stdin
```
→ [`networking/gateway.md`](../networking/gateway.md#http-basic-auth)
→ [`networking/gateway.md`](../networking/gateway.md#dashboard-access)
## Day to day

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 |
| `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 |
| --- | --- | --- |
| `<hive>/` | dashboard dist (static, from `servedFrontend`) | always |
| `<hive>/api/`, `/health/` | hive-c0re (`7000`) | always |
| `<hive>/api/docs/` | themed Swagger UI dist (static) | always |
| `<hive>/agent/<name>/` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) |
| `<name>.hives.<swarm>/` | dashboard dist (static, from `servedFrontend`), behind authelia | always |
| `<name>.hives.<swarm>/api/` | hive-c0re (`7000`), behind authelia | always |
| `<name>.hives.<swarm>/health/` | hive-c0re (`7000`) | always |
| `<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>/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.
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)).
@ -109,30 +111,15 @@ nginx reads the directory directly. Keep the key readable by nginx:
Give the gateway the real cert (`tls.certDir` or `tls.acme`) so it serves proper TLS itself, or front it over a unix socket rather than a plain-http TCP port. `.well-known/matrix/*` responses always advertise `https` ([Discovery flow](#discovery-flow-matrix)).
## HTTP Basic auth
## Dashboard access
Optional: Basic auth on the hive's dashboard. Add a login first, then enable it — the htpasswd file exists from first boot, and an empty one refuses everyone.
The dashboard answers on `<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
hivectl gateway create-user alice --password-stdin # password on stdin
hivectl gateway delete-user bob
hivectl gateway list-users
```
**What it gates:** `/`, `/api/` and `/api/docs/`. **Not gated:** `/health/` (for uptime monitors; status only) and the per-agent `/agent/<name>/` routes, which come from `agents.conf` and carry no `auth_request`.
```nix
services.hyperhive.gateway.auth = {
enable = true;
# realm = "hyperhive"; # default; must not contain `"` or `$`
};
```
`hivectl` asks hive-c0re over the host admin socket, and the daemon writes `/var/lib/hive-gateway/conf/gateway.htpasswd` itself, bcrypt (cost 12) with `$2y$` hashes nginx reads natively. `--password <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.
**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>`.
## Firewall posture (host-level)
@ -211,7 +198,7 @@ When enabled, every vhost adds `Strict-Transport-Security: max-age=…[; include
`services.hyperhive.gateway.localHostsEntry = true` maps to `127.0.0.1` in the host's `/etc/hosts`:
- the hive domain;
- every name a module on this host contributes to `gateway.localNames` — each swarm service this host runs adds its own (`forge.<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.
@ -412,7 +399,7 @@ HTML pages instead of nginx's default error chrome:
504 = /__hive_agent_unreachable` rewrites to `unreachable.html`.
`hive-gateway/error-pages.nix` renders every page (`notFound`, `unreachable`,
`unauthorized`, `ssoUnavailable`) from one template with `pkgs.writeText`;
`ssoUnavailable`) from one template with `pkgs.writeText`;
nginx serves each through an `internal` location with `alias` to the file,
so only nginx's own error handling can reach them. They depend on nothing
from the frontend dist and render even when hive-c0re is down. A service

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
apply your own threshold. The timestamp is the one the queue recorded on
arrival, not one the hive put in its own payload.
arrival, not one the hive put in its own payload. A roster hive's row
carries `dashboard`, the host its dashboard answers on; the swarm UI's
hive list links there.
Set `services.hyperhive.swarm.controller.staleAfterSeconds` (default
`120`) **above the rate hives publish at**, or everything reads `stale`

View file

@ -10,7 +10,7 @@ debug agent behavior.
## For the operator
- **[hivectl](hivectl.md)** — the curated guide: provisioning forge
and matrix accounts, gateway htpasswd management, container
and matrix accounts, container
lifecycle shortcuts, interactive agent shell access.
- **[hivectl-cli](hivectl-cli.md)** — the exhaustive, autogenerated
flag-by-flag reference, kept in lockstep with the binary by CI.

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 matrix`↴](#hivectl-matrix)
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
* [`hivectl gateway`↴](#hivectl-gateway)
* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user)
* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-user)
* [`hivectl gateway list-users`↴](#hivectl-gateway-list-users)
* [`hivectl agent`↴](#hivectl-agent)
* [`hivectl agent restart`↴](#hivectl-agent-restart)
* [`hivectl agent pause`↴](#hivectl-agent-pause)
@ -57,7 +53,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
* `forge` — Reconcile an agent's config between this hive and the forge
* `matrix` — matrix-tuwunel invites
* `gateway` — Gateway htpasswd user management
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
* `list-agents` — Show all managed agents with their status and technical state
* `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem
@ -147,61 +142,6 @@ Invite a matrix user to the hive Space, or a specific room with `--room`. Idempo
## `hivectl gateway`
Gateway htpasswd user management.
Add, remove, or list users for the gateway's HTTP Basic auth.
**Usage:** `hivectl gateway <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`
Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running.
@ -644,7 +584,7 @@ Resolves the URL from the running daemon so custom forge / matrix domains work.
Possible values:
- `home`:
The operator dashboard (`https://<domain>/`)
The operator dashboard (`https://<hive>.hives.<swarm domain>/`)
- `forge`:
The forge (Forgejo) web UI
- `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),
`hivectl` covers direct host-side administration: manual provisioning of
matrix accounts, gateway htpasswd management, container
lifecycle shortcuts, and interactive agent shell access.
matrix accounts, container lifecycle shortcuts, and interactive agent shell access.
This page is the curated guide. For the exhaustive flag-by-flag
reference autogenerated from the binary's own command tree, see
@ -65,25 +64,6 @@ group; nobody has built that sync yet.
invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op.
## Gateway
Manage users in the gateway's HTTP Basic auth htpasswd file
(`services.hyperhive.gateway.auth`). `hivectl` sends the request over the
host admin socket; the `hive-c0re` daemon owns the htpasswd file at its
canonical path (`/var/lib/hive-gateway/conf/gateway.htpasswd`) and
performs the write.
```bash
hivectl gateway create-user alice --password-stdin # add (or update) user; read password from stdin
hivectl gateway create-user bob --password hunter2 # add user inline (visible in shell history)
hivectl gateway delete-user bob # remove user
hivectl gateway list-users # list all usernames, one per line
```
The daemon hashes passwords with BCrypt (cost 12). It creates the file
if it doesn't exist. Re-running `create-user` with the same
username updates the password hash in place.
## Agents
Container lifecycle shortcuts that go through the host admin socket.
@ -229,7 +209,7 @@ Requires the `hive-c0re` daemon to be running.
```bash
hivectl open # operator dashboard (same as `open home`)
hivectl open home # operator dashboard (https://<domain>/)
hivectl open home # operator dashboard (https://<hive>.hives.<swarm domain>/)
hivectl open forge # the forge (Forgejo) web UI
hivectl open matrix # the matrix GUI (fluffychat)
```

View file

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

View file

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

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

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)]
mod tests {
use super::*;
use crate::lifecycle::MANAGER_NAME;
/// The htpasswd file is published `0644` even when the temp file it is
/// written through already exists at `0600` (opening an existing file
/// keeps its mode), holds the lines newline-terminated, and leaves no
/// temp file beside it.
#[test]
fn htpasswd_is_published_0644_over_a_private_leftover_tmp() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("htpasswd");
let tmp = path.with_extension("htpasswd.tmp");
std::fs::write(&tmp, "leftover\n").expect("seed tmp");
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).expect("chmod");
htpasswd_write_at(&path, &["a:x".to_owned(), "b:y".to_owned()]).expect("write");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o644);
assert_eq!(std::fs::read_to_string(&path).expect("read"), "a:x\nb:y\n");
let entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.map(|e| e.expect("entry").file_name())
.collect();
assert_eq!(entries, [std::ffi::OsString::from("htpasswd")]);
}
// ── legacy mode (frontend_dir = None) ──────────────────────────────
#[test]

View file

@ -28,7 +28,7 @@ use std::path::{Path, PathBuf};
// `hive-host-sock` (the crate hivectl links instead of the whole daemon);
// re-exported here so this module stays the daemon's single-source facade for
// its own callsites.
pub use hive_host_sock::{AGENTS_ROOT, GATEWAY_HTPASSWD, HOST_SOCKET, agent_state_dir};
pub use hive_host_sock::{AGENTS_ROOT, HOST_SOCKET, agent_state_dir};
/// Name of the per-agent working dir under `agents/<name>/`, a sibling of
/// `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 } => {
crate::forge::reconcile_config_apply(agent.as_str(), *direction).await?
}
HostRequest::GatewayCreateUser { username, password } => {
HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?])
}
HostRequest::GatewayDeleteUser { username } => {
HostResponse::messages(vec![crate::gateway_nginx::delete_user(username)?])
}
HostRequest::GatewayListUsers => {
HostResponse::messages(crate::gateway_nginx::list_users()?)
}
HostRequest::QuotaEnable => handle_quota_enable().await?,
HostRequest::QuotaLimit { name, limit } => {
handle_quota_limit(name.as_str(), *limit).await?
@ -779,7 +770,7 @@ fn hive_urls() -> hive_host_sock::HiveUrls {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let domain = env("HYPERHIVE_HIVE_DOMAIN");
hive_host_sock::HiveUrls {
home: domain.as_ref().map(|d| format!("https://{d}/")),
home: env("HYPERHIVE_DASHBOARD_DOMAIN").map(|d| format!("https://{d}/")),
forge: env("HIVE_FORGE_PUBLIC_URL"),
matrix: env("HIVE_MATRIX_PUBLIC_URL"),
domain,

View file

@ -46,14 +46,6 @@ pub fn agent_state_dir(name: &Ident) -> PathBuf {
PathBuf::from(AGENTS_ROOT).join(name.as_str())
}
/// nginx basic-auth credential store for the operator dashboard vhost.
/// `hivectl`'s `--htpasswd-file` clap default.
///
/// Under the gateway's own state dir rather than c0re's, so that nginx
/// can read it without being given access to everything else c0re keeps.
// nix: read by the gateway's nginx on the host (hive-gateway/) — must match.
pub const GATEWAY_HTPASSWD: &str = "/var/lib/hive-gateway/conf/gateway.htpasswd";
/// `/run/hive-agent` — per-agent runtime socket dir root (web UI unix
/// socket + bound marker), one subdir per agent. The gateway's nginx
/// `proxy_pass`es to `agent_web_socket(name)` directly; `hivectl` dials
@ -261,21 +253,6 @@ pub enum HostRequest {
agent: Ident,
direction: ReconcileDirection,
},
/// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file
/// (`paths::GATEWAY_HTPASSWD`). Daemon-side equivalent of `hivectl gateway
/// create-user`: the daemon bcrypt-hashes `password` (cost 12, remapped to
/// the `$2y$` prefix nginx accepts) and writes the entry, so hivectl never
/// touches the file. `password` is read client-side (inline flag or stdin).
/// Returns a confirmation line in [`HostResponse::messages`].
GatewayCreateUser { username: String, password: String },
/// Remove a gateway HTTP-Basic user from the daemon's htpasswd file.
/// Daemon-side equivalent of `hivectl gateway delete-user`. Errors if the
/// user isn't present so a no-op is detectable.
GatewayDeleteUser { username: String },
/// List the gateway HTTP-Basic usernames in the daemon's htpasswd file.
/// Daemon-side equivalent of `hivectl gateway list-users`; the usernames
/// come back in [`HostResponse::messages`], one per line.
GatewayListUsers,
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
/// the privileged helper. Daemon-side equivalent of `hivectl
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`].
@ -416,7 +393,7 @@ pub struct HiveUrls {
/// Canonical hive domain (`services.hyperhive.domain`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
/// Operator dashboard root (`https://<domain>/`).
/// Operator dashboard root (`https://<services.hyperhive.swarm.dashboardDomain>/`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home: Option<String>,
/// 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")]
pub status_set_at: Option<DateTime<Utc>>,
/// This agent's own web UI, behind the gateway
/// (`https://<hive-domain>/agent/<name>/`) — the same URL the operator
/// dashboard's "open" action uses. `None` when the hive's domain isn't
/// configured (`HYPERHIVE_HIVE_DOMAIN` unset), same condition
/// (`https://<dashboard-domain>/agent/<name>/`) — the same URL the operator
/// dashboard's "open" action uses. `None` when the dashboard's domain isn't
/// configured (`HYPERHIVE_DASHBOARD_DOMAIN` unset), same condition
/// `HostRequest::Urls`'s `HiveUrls::home` is `None` under. Present here
/// (not just derivable from `HiveUrls`) so a client never has to build
/// this path itself — see `hive-c0re/container_view.rs`'s `agent_url`

View file

@ -28,7 +28,6 @@ dispatch:
- **`forge.rs`** — reconciles an agent's config against its forge
repo.
- **`matrix.rs`** — invite a matrix user to the hive Space or a room.
- **`gateway.rs`** — gateway Basic-auth user management (htpasswd).
- **`wg.rs`** — WireGuard mesh helpers.
- **`subvol.rs`** — btrfs state-subvolume ops.
- **`quota.rs`**, **`util.rs`**, **`completions.rs`**, **`open.rs`** —

View file

@ -45,13 +45,6 @@ pub enum Cmd {
#[command(subcommand)]
cmd: MatrixCmd,
},
/// Gateway htpasswd user management.
///
/// Add, remove, or list users for the gateway's HTTP Basic auth.
Gateway {
#[command(subcommand)]
cmd: GatewayCmd,
},
/// Lifecycle actions on ONE managed agent container. Needs the
/// hive-c0re daemon running.
///
@ -178,7 +171,7 @@ pub enum Cmd {
/// Which hive web surface `hivectl open` targets.
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
pub enum OpenTarget {
/// The operator dashboard (`https://<domain>/`).
/// The operator dashboard (`https://<hive>.hives.<swarm domain>/`).
Home,
/// The forge (Forgejo) web UI.
Forge,
@ -279,34 +272,6 @@ pub enum MatrixCmd {
},
}
#[derive(Subcommand)]
pub enum GatewayCmd {
/// Add a user or update an existing user's password in the gateway
/// htpasswd.
///
/// Use `--password-stdin` to keep the password out of shell history.
CreateUser {
/// Username to add or update.
username: String,
/// Set the password inline. WARNING: visible in shell history and
/// process listings — prefer `--password-stdin` for sensitive input.
/// Mutually exclusive with `--password-stdin`.
#[arg(long, conflicts_with = "password_stdin")]
password: Option<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)]
pub enum WgCmd {
/// 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 /
/// plain renderers), split out to keep this file manageable.
mod dag_progress;
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd};
use cli::{Cli, Cmd, ForgeCmd, WgCmd};
mod completions;
mod quota;
mod util;
use completions::generate_completions;
mod gateway;
use gateway::{gateway_create_user, gateway_delete_user, gateway_list_users};
mod matrix;
use matrix::run_matrix_cmd;
mod open;
@ -68,15 +66,6 @@ async fn main() -> Result<()> {
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
},
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
Cmd::Gateway { cmd } => match cmd {
GatewayCmd::CreateUser {
username,
password,
password_stdin,
} => gateway_create_user(&socket, &username, password.as_deref(), password_stdin).await,
GatewayCmd::DeleteUser { username } => gateway_delete_user(&socket, &username).await,
GatewayCmd::ListUsers => gateway_list_users(&socket).await,
},
Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await,
Cmd::ListAgents { json } => agents_list(&socket, json).await,
Cmd::QuotaEnable => quota::quota_enable(&socket).await,

Some files were not shown because too many files have changed in this diff Show more