Tuwunel 1.6.1 appends " 💕" to every new user's display name via the
new_user_displayname_suffix config option. Set it to empty string so
agent and operator matrix accounts get clean names.
Existing users (atlas, mara, root) need a one-time display name reset.
Atlas's was already corrected via the client API.
hive-ci-register.service now runs unconditionally on every boot (not
just when .runner is absent). Before fetching a registration token it
validates existing .runner credentials via the forge admin API:
- 200: runner still registered, write dummy token and exit
- 404: runner deleted from forge, purge .runner and re-register
- 000: forge unreachable, keep credentials (runner surfaces the error)
- other non-200 or malformed .runner: purge and re-register
Removes ConditionPathExists so stale credentials from a wiped forge
no longer block the runner indefinitely. Updates docs/ci.md to match.
Remove the custom htpasswdFile option and bind-mount. The htpasswd file
now lives at the fixed path /var/lib/hyperhive/gateway/gateway.htpasswd
on the host, which is already exposed inside the container at
/run/hive-state/gateway.htpasswd via the existing gateway state
bind-mount — no extra bind-mount needed.
A tmpfiles rule pre-creates the file so nginx can open it even before
any users exist (empty file → all requests return 401, which is correct).
hivectl gateway commands default --file to the standard path so
`hivectl gateway create-user alice` just works without any flags.
Per argus review: the hardcoded /etc/hyperhive/gateway.htpasswd
example was wrong for operators with a custom htpasswdFile path.
Move the unauthorized.html from the static agentErrorPagesDir derivation
into a pkgs.writeText inside the lib.optionalAttrs guard where
cfg.auth.htpasswdFile is in scope and statically known non-null.
The rendered page now shows the operator's actual configured path.
When HTTP Basic auth is enabled and credentials are absent or rejected,
nginx serves a Catppuccin-styled 401 page that tells the operator which
hivectl command to run to create a user. Uses error_page 401 =401 so
the browser still receives a 401 status (login dialog fires on first
visit) while getting a human-readable body when the dialog is dismissed.
The exact-match location (= /__hive_auth_unauthorized) beats location /
in nginx's prefix ordering so the internal subrequest does not loop back
through auth_basic.
Per mara's direction: both nixpkgs and nixpkgs-unstable are now
top-level meta flake inputs with explicit store-path URLs. Hyperhive
follows them rather than the other way around:
inputs.nixpkgs.url = "path:${pkgs.path}";
inputs.nixpkgs-unstable.url = "path:${nixpkgs-unstable}";
inputs.hyperhive.url = "...";
inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs";
inputs.hyperhive.inputs.nixpkgs-unstable.follows = "nixpkgs-unstable";
New NixOS host options (auto-set at build time, overridable):
services.hyperhive.c0re.nixpkgsFlake
default: "path:${pkgs.path}" — host's evaluated nixpkgs.
services.hyperhive.c0re.nixpkgsUnstableFlake
default: "path:${nixpkgs-unstable}" from hyperhive's flake.nix —
the channel that carries claude-code. Operators can override to
track a different unstable snapshot.
Legacy fallback (both args empty) preserved for backward compat.
Two new Rust tests cover the full-URL and fallback paths.
meta flake was using `nixpkgs.follows = "hyperhive/nixpkgs"` but
`hyperhive` is a store-path input, so nix resolves hyperhive's own
pinned lock rather than the host's follows-substituted version.
When an operator sets `inputs.hyperhive.inputs.nixpkgs.follows =
"nixpkgs"` in their host flake, the meta flake was silently ignoring
it and using hyperhive's pinned nixpkgs instead.
Fix: hive-c0re.nix injects `--nixpkgs-flake path:${pkgs.path}` into
the daemon's ExecStart. `pkgs` IS the host's nixpkgs when follows is
set; otherwise it's hyperhive's own pin — so the meta flake gets the
right nixpkgs in both cases. render_flake emits `nixpkgs.url = "..."`
(explicit) when nixpkgs_flake is non-empty, falling back to the old
`follows` form when empty for backward compat.
Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
Replaces the earlier PAM+binary approach with nginx's built-in
`auth_basic` module. No new binary, no new systemd service, no PAM.
New option `services.hyperhive.gateway.auth`:
- `enable` — off by default
- `htpasswdFile` — host path to an htpasswd file (required when enable)
- `realm` — WWW-Authenticate realm string (default "hyperhive");
restricted to `strMatching "[^\"$]*"` to prevent nginx config injection
When enabled:
- the parent directory of `htpasswdFile` is bind-mounted read-only
into the gateway container at `/run/gateway-auth/`
- the `"/"` proxy location gets `auth_basic` + `auth_basic_user_file`
Create credentials: `htpasswd -Bc /path/to/file alice` (BCrypt).
See `docs/gateway.md` ("HTTP Basic auth") for the full setup guide.
Adds opt-in HTTP Basic auth to the hive-gateway backed by the host PAM
stack + group membership check.
New binary `hive-gateway-auth` (hive-c0re workspace):
- Axum HTTP service on 127.0.0.1:7002 (host loopback)
- Decodes Basic credentials, authenticates via pam_unix.so
- Checks membership in `hyperhive-operator` group (or custom)
- Returns 200 / 401 / 403; nginx `auth_request` consumes these
New options under `services.hyperhive.gateway.auth`:
- `enable` — off by default
- `port` — auth service port (default 7002)
- `realm` — WWW-Authenticate realm string (default "hyperhive")
- `group` — required host group (default "hyperhive-operator")
- `pamService` — PAM service name (default "hive-gateway")
Host-side NixOS wiring:
- `users.groups.hyperhive-operator` declared when default group used
- `/etc/pam.d/hive-gateway` emitted via `security.pam.services`
- `systemd.services.hive-gateway-auth` runs the auth binary as root
(needs /etc/shadow access for pam_unix.so)
Gateway container nginx wiring:
- `location = /__hive_gateway_auth` — internal proxy to auth service
- `auth_request /__hive_gateway_auth` on the `"/"` proxy location
- `@hive_auth_required` named location adds WWW-Authenticate: Basic
header on 401 so browsers display a login prompt
Workspace deps: pam = "0.8"; flake.nix: linux-pam added to
nativeBuildInputs so pkg-config can find libpam at build time.
Without [actions] ENABLED = true in forgejo's config, the
GET /api/v1/admin/runners/registration-token endpoint returns
"runner registration token not found" regardless of token scopes,
causing hive-ci-register.service to fail on first boot.
gateway_nginx.rs reads HIVE_AGENT_FRONTEND_DIR (injected by hive-c0re.nix
as ${cfg.frontend}/agent). When set, agents.conf emits per-agent split
blocks instead of the old single proxy_pass:
# Compiled assets — immutable nix store path, cache 1y
location ^~ /agent/<name>/static/ {
alias <frontend>/static/;
expires 1y; add_header Cache-Control "public, immutable, ...";
}
# Static dist + proxy fallback
location /agent/<name>/ {
alias <frontend>/;
try_files $uri $uri.html $uri/index.html @<name>_dynamic;
}
location @<name>_dynamic {
proxy_pass <upstream>; # api, events, icon, login, …
…proxy headers unchanged…
}
try_files path resolution (nginx applies alias mapping first):
$uri — exact file (/static/app.js → static/app.js)
$uri.html — bare-path fallback (/stats → stats.html)
$uri/index.html — directory index (/ → index.html)
@<name>_dynamic — proxy catchall for anything not in the dist
Adding pages to the frontend dist works automatically — no generator
change needed. Per-agent extraFiles (in mergedDist, not in the base
nix-store path) continue to proxy to the agent daemon.
frontend is a nix store path injected at build time — only [a-z0-9/._-],
no shell metacharacters — safe to interpolate without sanitization;
comment added documenting this assumption.
Without HIVE_AGENT_FRONTEND_DIR the existing single-proxy block is
emitted unchanged — backward-compatible for deployments without the env.
render() takes frontend_dir as a parameter so tests exercise both code
paths safely in parallel. 13 tests: 7 legacy, 6 split-mode. No clippy
warnings in changed files.
nix/modules/hive-c0re.nix: inject HIVE_AGENT_FRONTEND_DIR = "${cfg.frontend}/agent".
Nix side of the hive-priv phase 1 work. hive-priv is socket-activated
exclusively: systemd holds /run/hive/priv.sock (mode 0660) and starts
the service on first connection. LISTEN_FDS + LISTEN_PID are passed;
hive-priv reads them in socket_listener() to accept the pre-bound fd.
Phase 2 note (comment in file): when hive-c0re drops to a non-root
user, add SocketGroup = hive-core to the socket unit so the
unprivileged caller can still connect. No code change needed in
hive-priv itself.
Add a systemd.sockets.hive-c0re unit that holds /run/hyperhive/host.sock
before hive-c0re starts. hive-c0re serve() detects LISTEN_FDS via the
listenfd crate and accepts the systemd-handed fd instead of calling bind().
Falls back to the existing bind path when LISTEN_FDS is absent so direct
invocation and CI are unaffected.
Benefits: hivectl can connect the moment the socket unit activates (no
racy window), and a hive-c0re restart never drops the socket inode.
Part of the docs-migration chore (issue #708). Remove GitHub issue
numbers from inline comments, option descriptions, and rustdoc —
these are contextless noise for anyone reading the code without
access to the original discussions. Replace with prose that captures
the same rationale directly.
No functional change. Build still clean (cargo check passes).
The nixpkgs gitea-actions-runner module uses tokenFile as a systemd
EnvironmentFile, loaded before any ExecStartPre. Our previous
ExecStartPre override was too late — file didn't exist yet.
Fix: tmpfiles pre-creates /run/hive-ci/runner-token with a placeholder
on every boot. A new hive-ci-register.service oneshot (gated on
ConditionPathExists=!/var/lib/gitea-runner/hive/.runner) fetches the
real token and overwrites before gitea-runner-hive.service starts,
wired via After/Wants. On subsequent boots the placeholder is harmless
— nixpkgs register step exits early when .runner already exists.
Closes#918.
Per-instance package option does not exist in the nixpkgs module.
Package is configured at services.gitea-actions-runner.package.
Fixes build failure on main. Closes#916.
nix flake check pulls rust tools hermetically. curl/jq in preStart
use absolute store paths. nix is part of any NixOS system by default.
git is the only package genuinely needed at runtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the manual runnerTokenFile operator step. On first boot the
container fetches a runner registration token from Forgejo's admin
API using hive-c0re's existing admin token
(/var/lib/hyperhive/forge-core-token). A preStart script writes the
token to /run/hive-ci/runner-token; on subsequent boots it writes a
dummy (registered .runner creds take precedence anyway).
Operator bootstrap is now just `services.hyperhive.ci.enable = true`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds `services.hyperhive.ci` NixOS module that spins up a `hive-ci`
nixos-container running `gitea-actions-runner` against the hive-forge
Forgejo instance. Off by default; opt in with `ci.enable = true` after
generating a runner registration token in Forgejo.
Also adds `.forgejo/workflows/ci.yml` with four jobs: nix flake check,
formatting (nix fmt + cargo fmt), cargo test, and cargo clippy. Jobs
target the `hive-ci` runner label.
Container design mirrors hive-forge (shared host netns, non-ephemeral
state, loopback reach to forge). sandbox-fallback = true since nspawn
containers can't create user-namespaces for nix sandbox.
Closes#175.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The #872 path-unit approach was silently broken: IN_MOVED_TO from an
atomic rename on the host does not propagate across the nspawn
mount-namespace boundary into the container, so the watcher inside
the gateway container never fired.
Fix: after each agents.conf write, c0re calls
`systemd-run --machine=hive-gateway -- nginx -s reload` from the host.
The reload is best-effort (logged on failure, not fatal).
Remove the now-unused `hive-gateway-agents-conf.path` +
`hive-gateway-nginx-reload.service` from the gateway container config.
Update docs/gateway.md + comments to reflect the host-side approach.
Dashboard links to the forge were hardcoded as http://<hostname>:3000,
which breaks when the operator accesses the dashboard through
hive-gateway (forge is served at forge.<domain> with no port).
- nix/modules/hive-c0re.nix: inject HIVE_FORGE_PUBLIC_URL when
forge.behindGateway = true (e.g. http://forge.pr1ma.darkest.space)
- hive-c0re/src/dashboard.rs: expose forge_url: Option<String> in
StateSnapshot, reading from HIVE_FORGE_PUBLIC_URL
- frontend/packages/dashboard/src/tabs.js: use state.forge_url when
present; fall back to http://<hostname>:3000 for gateway-off /
local-dev deploys
- gateway_nginx.rs: HOST_CONF_PATH → /var/lib/hyperhive/gateway/agents.conf
- hive-gateway.nix: hostPath = /var/lib/hyperhive/gateway (not whole parent
dir — avoids exposing forge tokens or other credentials to the gateway
container)
- tmpfiles: add /var/lib/hyperhive/gateway/ dir rule + seed agents.conf there
- scrub "(#869)" from hive-gateway-nginx-reload service description
Replace eval-time per-agent nginx location baking with a runtime
include file. c0re writes /var/lib/hyperhive/agents.conf (nginx
location blocks, UDS or TCP per agent) on every topology change and
on the 10s marker poll. The gateway container bind-mounts
/var/lib/hyperhive/ at /run/hive-state/ and nginx includes
/run/hive-state/agents.conf. A systemd path unit inside the container
watches the file for changes and fires `nginx -s reload` on each
atomic rename from c0re — no nixos-rebuild switch needed when agents
start, stop, or flip useUnixSocket.
- new hive-c0re/src/gateway_nginx.rs: write() + render()
- lib.rs + meta.rs + agent_sockets::spawn_poll: hook in write()
- hive-gateway.nix: drop agentPortsTable/agentSocketsTable/
agentUpstreamFor/lib.mapAttrs', add /run/hive-state bind-mount,
include directive, systemd path unit + reload service, tmpfiles
for /var/lib/hyperhive + agents.conf seed
- docs/gateway.md: update vhost table + Per-agent UDS section
nginx-pre-start runs the config test as the nginx user; a 0600
root-owned key fails that check with BIO_new_file Permission denied
even though the master process would later load it as root.
Parent /var/lib/hive-gateway came out 0700, blocking the nginx-user
worker from reaching the 0755 tls/ subdir and surfacing as a generic
cert-load failure at nginx start.
When `services.hyperhive.gateway.enable` is on (default), the c0re
NixOS module now sets `HIVE_GATEWAY_ENABLED=1` on the service env.
`/api/state` exposes the flag as `gateway_enabled`; the SW4RM tab's
container-row renderer flips three link sites (primary agent-name
link, favicon fetch, `container`-kind nav-strip links) from the
legacy `http://<host>:<port>/` direct TCP shape to same-origin
`/agent/<name>/` URLs — the gateway proxies them to the per-agent
harness via `agent-ports.json` or `agent-sockets.json` (#784 / #815).
Gateway-off deploys keep the direct TCP fallback so local-dev /
operator opt-out keeps working.
`forge`-kind nav-strip links still resolve against `:3000` (separate
sub-domain transition, tracked by `forge.behindGateway`);
`external`-kind links are already absolute.
Mirrors the `HIVE_MATRIX_GUI_ENABLED` env→snapshot-flag pattern.
Docs updated: `docs/web-ui.md::Container row` + new
`docs/gateway.md::Dashboard link shape` section.
forge/matrix subdomain address rules are redundant when those are
sub-domains of hyperhive.domain (dnsmasq's /<domain>/ matches
sub-domains). Keep explicit for the cross-domain case (e.g.
forge.domain = "git.example.com") + add a comment explaining
the trade-off.
Stand up the host-side bridge interface + per-agent DNS resolver
ahead of #14 (netns isolation). Mara on #805#11541: "we need it
before #14 so nothing breaks when we switch over". v1 ships the
endpoint live but containers stay on shared host netns — when #14
flips them to private netns the DNS contract is already there.
Shape:
- new `nix/modules/hive-network.nix` with `services.hyperhive.network.*`
options (enable + bridgeName + bridgeIp + bridgePrefixLength +
upstreamDns). Default off. Imported from `hive-c0re.nix`.
- bridge interface via `networking.bridges` (no slave NICs at v1;
per-agent veth pairs attach once #14 lands).
- bridge IP assigned via `networking.interfaces`.
- `networking.firewall.interfaces.<bridge>.allowed{UDP,TCP}Ports =
[ 53 ]` opens the resolver on the bridge interface only —
other interfaces stay closed.
- dnsmasq config added to the existing `hive-gateway` container
(mara on #805:10957: "put the resolver into the gateway container").
Listens only on `bridgeName` + `lo`; authoritative for
`<hive-domain>`, `forge.<hive>`, `matrix.<hive>` answering with
the bridge IP; forwards everything else to upstream.
`resolveLocalQueries = false` keeps the gateway container's own
resolver untouched.
Asserts `services.hyperhive.domain != null` + `gateway.enable =
true` — both required for the resolver to be meaningful.
Docs: new `docs/network.md` covering v1 vs v2 split, container shape
rationale, default addressing, resolver behaviour, firewall posture.
`nix flake check` clean.
Defensive against the fresh-boot-before-any-agent-spawn window where
the bind-mount source wouldn't exist from c0re's per-agent
`set_nspawn_flags` mkdir chain yet. nspawn auto-creates missing
sources, so this is intent-explicit rather than functional fix.
Switch per-agent gateway upstreams from TCP loopback to unix-domain
socket when the agent has opted in via `hyperhive.web.useUnixSocket`
(#822). Coexists with the TCP path during rollout.
Changes:
- New `agentSocketsFile` option (default
`/var/lib/hyperhive/agent-sockets.json`) — c0re writes the map
there via `hive_c0re::agent_sockets::write` (#809).
- `agentSocketsTable = lib.importJSON ...` (graceful empty when
file missing).
- `agentUpstreamFor name port` picks `http://unix:<path>:/` when the
socket has a JSON entry AND the file exists at eval time; else
`http://127.0.0.1:<port>/`. Path-exists gate guards against
c0re's blanket-emit shape during the canary window (agents in
`agent-sockets.json` who haven't actually flipped have no
bound socket on disk → fall back to TCP). Damocles will ship a
`.bound` marker filter on the c0re side (#784 step 2d
follow-up); once that's in, the path-exists check is redundant
but harmless. Step 4 drops it entirely along with the TCP
fallback.
- `containers.hive-gateway.bindMounts."/run/hive-agent"` —
read-only, unconditional. Inert when no agents have opted in.
Required so nginx inside the gateway container can `connect(2)`
to the per-agent sockets damocles's #813 bind-mounts into agent
containers at the same paths.
Docs:
- `docs/gateway.md::Per-agent UDS upstream (#784)` — full rollout
flow, subdir-bind rationale (damocles #813), eval-time gate
explainer, step 4 drop plan.
`nix flake check` clean; `nix fmt` clean.
Canary plan: once #822 (`useUnixSocket` option) lands + this PR
merges, manager flips atlas's agent.nix to `useUnixSocket = true`
via the config-update flow. End-to-end validation against atlas
before broader rollout.
mara on #755: "e.g. /agent/name should show an error page stating
that the agent could not be found if missing in json or that it is
not reachable if we get a connection error. we dont want a fully
generic fallback, only for routes already special cased in the
nginx config."
Adds two static HTML pages built at deploy time via
`pkgs.runCommand "hyperhive-agent-error-pages"`:
- **not-found.html** — served when `/agent/<unknown>/...` hits the
bare `/agent/` catch-all. The catch-all `return 404`s, and
`error_page 404 = /__hive_agent_not_found` rewrites to the static
page.
- **unreachable.html** — served when `/agent/<known>/...` proxy_pass
to the harness returns 502 / 503 / 504. `proxy_intercept_errors
on` + `error_page 502 503 504 = /__hive_agent_unreachable` on each
per-agent location block rewrites to the static page.
Mechanics:
- `agentErrorPagesDir` (in the `let` block) is a `runCommand` that
emits two HTML files using a `<<EOF` heredoc — no template engine
needed.
- Two `internal` nginx locations (`= /__hive_agent_not_found`,
`= /__hive_agent_unreachable`) `alias` the exact files. `internal`
keeps the URIs unreachable from direct operator request — only
nginx's own error-handling can hit them.
- Per-agent location blocks pick up the `error_page` directive
through the existing `lib.mapAttrs'` over `agentPortsTable`. No
per-agent generated content; same static page for all.
- `/agent/` catch-all generates from a tiny optionalAttrs alongside
the per-agent block — both are no-op when the agent table is
empty (matches the pre-#15 shape).
Pages: minimal inline CSS, catppuccin palette matching the
dashboard (`#1e1e2e` bg, `#cdd6f4` text, `#cba6f7` not-found heading,
`#f9e2af` unreachable heading). No frontend-dist dependency — render
even when hive-c0re is down. Both link back to `/`.
Per mara's "only for routes already special cased" — scope stays
narrow. Forge / matrix / fluffychat keep nginx defaults; extending
the custom-error pattern to other vhosts is a separate follow-up
if/when needed.
Verified:
- nginx location attrset has `["/", "/agent/", "= /__hive_agent_not_found", "= /__hive_agent_unreachable"]`
- container toplevel builds clean (`nixos-system-hive-gateway-26.05pre-git`)
- `docs/gateway.md::Per-agent error pages` section captures the
design + rationale + intentional narrowness
Closes#755.