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.
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.
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.
- 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.
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.
mara on PR #775: "this is too much docs in code - move bigger picture
stuff to md files and put refs in code"
New `docs/gateway.md` consolidates the gateway architecture story
that was spreading across long inline comments in `hive-gateway.nix`,
`hive-matrix.nix`, and `hive-forge.nix`:
- vhost map (which URL serves what, which upstream, which option)
- matrix discovery flow (.well-known → sub-domain delegation
sequence)
- Accept-header SPA fallback pattern (#686 / #729 design history)
- local-dev `localHostsEntry` story
- sub-domain rationale (mara verdict tracking) + when sub-path is
right (hyperhive-internal apps)
- per-vhost tuning knobs (forge LFS, matrix long-poll, agent SSE)
- sequencing history (which PR added which routing piece)
In-code comments in the two nix modules get trimmed to short refs
into the doc — keeps the *why* in the markdown while the *what*
stays alongside the code:
- hive-gateway.nix: top-of-file comment, `agentPortsTable`,
`appendHttpConfig`, every location block + vhost
- hive-matrix.nix: `fluffychat-web-fixed`, `fluffychat-web-imaging`,
the dart compile postInstall
README.md gets a new row in the docs table pointing at gateway.md.
Verified `nix eval` still resolves the same vhost + location layout
after the comment trim — no behavioral change, just less in-code
prose.
mara on #764:9897: "host the fluffy chat app at / as follow up?"
Moves fluffychat-web from the bare-domain sub-path
(`<hive>/matrix/`) to the matrix sub-domain root
(`matrix.<hive>/`). Follow-up to #764 (matrix vhost itself), per
mara's gateway-architecture verdict (sub-domain for external standard
apps, sub-path for hyperhive-internal). Stacked on
`atlas/747-matrix-behind-gateway` — depends on #764 landing first.
## Mechanics
**hive-matrix.nix:**
- Drop `flutterBuildFlags = [ "--base-href" "/matrix/" ]` from
`fluffychat-web-fixed`. Upstream default `--base-href "/"` is correct
at sub-domain root.
- Update option docs to reflect new mount point.
**hive-gateway.nix:**
- `$matrix_spa_target` map target flips from `/matrix/index.html` →
`/index.html` (sub-domain root now).
- New `<hive>/matrix/*` location: `rewrite ^/matrix/(.*)$
matrix.<hive>/$1 permanent;` — 301 redirect preserves bookmark +
deep-link compatibility for `<hive>/matrix/#/rooms/...` URLs during
the transition.
- `<hive>/matrix/config.json` location removed (moved to `/config.json`
on the matrix vhost).
- Matrix vhost (#764) gains `/` location: serves fluffychat dist as
static files with the Accept-header SPA fallback (`/_matrix/`
proxying to tuwunel keeps working via nginx longer-prefix-wins
precedence). When `gui.enable = false`, `/` returns 404 cleanly.
- Matrix vhost gains `= /config.json` for the FluffyChat boot-config
pre-fill (#736).
## Verified
```
vhosts: ["_", "forge.test.local", "matrix.test.local"]
bare locations: ["/", "/matrix/", "= /.well-known/matrix/client",
"= /.well-known/matrix/server"]
matrix vhost locations: ["/", "/_matrix/", "= /config.json"]
/matrix/ extraConfig: "rewrite ^/matrix/(.*)$ http://matrix.test.local/$1 permanent;"
matrix vhost / alias: /nix/store/...fluffychat-web-2.6.0/
```
Full container toplevel builds clean.
## Risk
Medium. Two breaking changes for operators:
1. **Bookmark migration**: `http://<hive>/matrix/#/rooms/...` 301s
to `http://matrix.<hive>/#/rooms/...`. Browser bookmarks +
shared links keep working via the redirect; can be cleaned up
once it's been in the wild long enough.
2. **fluffychat-web dist hash changes**: dropping the
`--base-href "/matrix/"` flag changes the derivation hash, so
`gui.package` rebuilds even though the source is the same.
Operators on substitute caches will fetch the new dist; building
from source takes the same time as before.
The `.well-known/matrix/{client,server}` delegation (already
advertising `matrix.<hive>` per #764) means matrix clients
auto-discover the new location — no client config change needed.
## Sequencing
**Depends on #764** — needs the matrix vhost to host the new `/`
location. Merge after #764 lands + soaks. If #764 changes shape
during review I'll rebase + force-push.
Closes#772.
mara on #747:9722: "this still seems to be an issue in current version"
(after #751 closed without merge). Mirroring the forge sub-domain
pattern just merged as #754 for matrix per mara's #749:9609 verdict
(sub-domain over sub-path for forge + matrix, "not user-visible for
matrix because the .well-known/matrix/{client,server} redirect routes
clients through automatically").
## Mechanics
**New `services.hyperhive.matrix.gatewayHost`** — nullable str, defaults
to `matrix.<services.hyperhive.domain>` when hive-domain set, else
null. Full hostname (`matrix.darkest.space`, `homeserver.internal.lan`)
for bespoke shapes per mara's #754:9684 "specify full domain in
options instead" pattern.
**Gateway:** new `server { server_name = matrixCfg.gatewayHost; }`
block proxying `/_matrix/...` → `http://127.0.0.1:<httpPort>/_matrix/...`
with matrix-spec CORS + tuned for long-poll `/sync` (1h timeout) +
typical media uploads (50M body cap). `/` returns 404 — nothing
else lives at the matrix vhost. Matches the forge vhost shape from #754.
**`.well-known/matrix/{client,server}`** (already served at bare hive-
domain since #660): now points at `matrixCfg.gatewayHost` (no port
suffix when gateway is on the canonical port 80) instead of the
direct `<hive-domain>:<httpPort>` shape. Falls back to direct shape
when `gatewayHost = null` (no hive-domain, or operator nulled it).
**`localHostsEntry` extension**: `/etc/hosts` (when set) now adds the
matrix sub-domain → 127.0.0.1 alongside hive-domain + forge.domain.
`lib.unique` collapses any duplicate (edge case if operator sets
gatewayHost equal to hive-domain).
## Verified via `nix eval`
```
vhosts: ["_", "forge.test.local", "matrix.test.local"]
gatewayHost: "matrix.test.local"
client wellknown: m.homeserver.base_url = "http://matrix.test.local"
server wellknown: m.server = "matrix.test.local"
/etc/hosts: ["test.local", "forge.test.local", "matrix.test.local"]
```
## What this fixes for #747
mara's HAR showed `GET /.well-known/matrix/client` and
`GET /_matrix/client/versions` both failing on `pr1ma.darkest.space`:
1. **`.well-known/matrix/client`** was advertising
`http://pr1ma.darkest.space:8008` — that URL only works if tuwunel's
port 8008 is firewall-open to the operator's browser (it isn't by
default — `services.hyperhive.matrix.openFirewall` defaults to false
since #651). Now advertises `http://matrix.pr1ma.darkest.space/`
which goes through the gateway on the (already-open) port 80.
2. **`/_matrix/client/versions`** was hitting the bare-domain `"_"`
vhost, which has no `/_matrix/` location — fell through to `/` →
c0re's dashboard upstream → 404. Now hits the new `matrix.<hive>`
vhost which proxies the request to tuwunel cleanly.
server_name + serverName unaffected — matrix identifiers (`@alice:<hive>`)
still embed the bare hive-domain per #660; only the wire-level transport
URL moves to the sub-domain.
## Risk
Medium. Existing matrix tokens / sessions stay valid because:
- `serverName` (the identifier domain) doesn't change
- tuwunel's `/_matrix/` endpoints serve the same requests, just reached
via the new sub-domain instead of the direct port
Operators with `services.hyperhive.matrix.openFirewall = true` and
external clients reaching `:8008` directly keep working too — the
sub-domain vhost is additive, doesn't take away the direct port.
## Sequencing
This is a parallel matrix-side mirror of #754 (forge). Both follow
the same mara-verdict pattern; once both have soaked, the gateway-
behind-everything story is done for v0.
Closes#747.
mara on PR #754: "would it be better to specify full forge domain in
options instead?"
Drops the awkward `cfg.subdomain` label option. Now `cfg.domain` is
the single source of truth for both the forgejo `DOMAIN` setting
(existing semantics) AND the gateway vhost server-name (new).
## Before / after
```nix
# before: separate label + cfg.domain juggling
services.hyperhive.forge.subdomain = "forge"; # → forge.<hive>
services.hyperhive.forge.domain = "localhost"; # unused for vhost
# after: full domain, single option
services.hyperhive.forge.domain = "forge.darkest.space"; # ← used for ROOT_URL + vhost
```
## Default
`cfg.domain` default auto-derives:
- `forge.<services.hyperhive.domain>` when hive-domain is set
- `"localhost"` otherwise (pre-#749 direct-on-port shape)
So the common case (hive-domain set) gets `forge.<hive>` for free,
operators with a bespoke shape (`git.example.com`) set the full
hostname directly.
## Assertions
- `cfg.domain != ""` — empty would render `.<hive>` shaped garbage
in both server_name + /etc/hosts.
- `cfg.behindGateway → gateway.enable` — can't route through a
gateway that isn't running.
(The previous "subdomain = empty" assertion is dropped — that
edge case is gone with the rename.)
## Verified
- default with `hyperhive.domain = "test.local"` → `forge.test.local`,
`ROOT_URL = http://forge.test.local/`, vhost present
- `forge.domain = "git.example.com"` → `git.example.com`,
`ROOT_URL = http://git.example.com/`, vhost = `["_", "git.example.com"]`
- `gateway.enable = false` → `forge.domain` falls back to `localhost`,
`ROOT_URL = http://localhost:3000/`, no gateway vhost
(`behindGateway = false`)
- `/etc/hosts` (when `localHostsEntry = true`) → unique entries for
hive-domain + forge.domain (de-duped via `lib.unique` for the
edge case where forge.domain = hive-domain)
- full container toplevel builds clean
## PR title
(Will fix the PR title separately — still says "/forge/" which is
wrong since the rewrite to sub-domain shape.)
mara on #749:9609: "we will go with sub domains for forge and matrix
(redirected in well known in the latter case, not user visible). close /
fix PRs you have open that dont match this."
Reshapes the v1 sub-path (`<host>/forge/`) approach into a sub-domain
vhost (`forge.<host>/`) per the mara verdict. matrix gets the same
treatment in damocles's #751 follow-up.
## Why sub-domain
- forgejo's default `ROOT_URL = http://<host>/` works without any
`X-Forwarded-Prefix` gymnastics — sub-domain hosting is the
canonical Forgejo deploy shape, matches every upstream-doc example.
- Cookie / storage isolation between the dashboard and forge (XSS blast
radius shrinks; a future forge XSS can't reach dashboard session).
- matches the matrix-spec pattern that #751 wires up for the
homeserver.
## Mechanics
**forge options:**
- `services.hyperhive.forge.subdomain` — nullable str, default `"forge"`
→ rendered sub-domain is `forge.<hive-domain>`. Set to `null` to opt
out (forge stays direct on `httpPort`); set to `""` for bare-domain
landing (advanced, collides with dashboard).
- `services.hyperhive.forge.rootUrl` — nullable str override. When
null, auto-derived: `http://<subdomain>.<hive>/` when gateway is on
+ subdomain set, else `http://<domain>:<httpPort>/` (direct).
- **Asserts** rootUrl ends with `/` (argus 🟡 on #754: forgejo's
ROOT_URL contract requires trailing slash, else emits
`https://forge.example.com.user.id` shaped garbage). Asserts
`subdomain != null` requires `hyperhive.domain` set.
**gateway:**
- New `virtualHosts."<subdomain>.<hive-domain>"` server block —
separate from the `"_"` catch-all. Proxies all `/` →
`http://127.0.0.1:<forge.httpPort>/` so forgejo handles requests at
root (no prefix translation needed; matches the upstream-default
ROOT_URL shape).
- Git-tuned: `client_max_body_size 1G`, `proxy_read_timeout 1h`,
`proxy_send_timeout 1h`, `proxy_buffering off`,
`proxyWebsockets = true`. SSH stays direct on `cfg.sshPort`.
- `networking.hosts` (when `localHostsEntry = true`) now also adds
`forge.<hive-domain> -> 127.0.0.1` for the dev loop.
## Verified
- `nix eval ROOT_URL` → `http://forge.test.local/` (default with
gateway on)
- `nix eval ROOT_URL` with `gateway.enable = false` → `http://localhost:3000/`
(current direct shape preserved)
- `nix eval virtualHosts attrs` → `["_", "forge.test.local"]`
- `nix eval networking.hosts` with `localHostsEntry = true` →
`{"127.0.0.1": ["test.local", "forge.test.local"], ...}`
- bad rootUrl (no trailing /) triggers assertion at toplevel build
with the spelled-out forgejo failure mode
- full container toplevel builds clean
(`nixos-system-hive-gateway-26.05pre-git`)
## Migration
ROOT_URL change is a one-way migration on rebuild:
- Existing agent `git remote origin` URLs (`http://localhost:3000/...`)
**keep working** — forgejo accepts any inbound URL; the URL on the
agent side is unchanged.
- New clone-link copy-paste from forge UI uses `forge.<hive>/...` —
operators copying clones after this lands need to go through the
new sub-domain.
- Direct browsing on `:3000` shows pages with `forge.<hive>` links →
works if hosts entry / DNS resolves, broken otherwise. Operators
should switch to `http://forge.<hive>/`.
## Out of scope
- TLS termination (mara explicit on #15: no TLS v0)
- SSH-over-HTTPS / wildcard cert provisioning
- matrix sub-domain (damocles's #751, sibling work)
Closes#749. Addresses argus 🟡 on #754.
mara on PR #740 comment 9295: "we decided to go with the json" (issue #15 comment 9270:
"nginx container lives in system config, so it cannot be just rebuilt
from meta flake. go for the json file the c0re writes").
Drops:
- `cfg.agents` listOf str option
- Replicated FNV-1a hash + char-code table + manager-port special case
- Drift-hazard comment (no more rust↔nix constant sync)
Adds:
- `cfg.agentPortsFile = "/var/lib/hyperhive/agent-ports.json"` (default,
nullable to disable) — path to a JSON map of `{ "<name>": <port> }`
written by hive-c0re on every topology change.
- `agentPortsTable` reads the file at eval time via
`builtins.fromJSON (builtins.readFile path)`, guarded by
`builtins.pathExists` so a missing file gracefully defaults to `{}`.
- Per-agent locations generated via `lib.mapAttrs'` over the table —
one location block per entry; empty table → empty attrset → no
per-agent blocks, pre-#15 shape.
Rust-side dependency: hive-c0re needs to emit the JSON file on every
topology change. Coordinating with damocles via a separate ping — the
nix side ships now with safe defaults (missing file = no routes, no
behavior change vs main).
Verified:
- nix eval with `/tmp/test-agent-ports.json` → 4 per-agent blocks at
correct ports (8178 iris, 8267 argus, 8304 atlas, 8549 damocles)
- nix eval with nonexistent file → only `/` location (graceful default)
- full container toplevel builds clean with matrix on
Empty file case mirrors the previous empty-list default — purely
additive, old `<host>:<port>/` direct reach untouched, no per-agent
blocks until c0re writes the JSON. Operator can also `null` the
option to disable entirely.
Per mara on #14 (comment 9081): focused, purely additive to what's
there, no TLS / no manager special cases, old `<host>:<port>/` path
keeps working. Builds on iris's #731 (agent UI now serves
document-relative URLs so it works under any nginx prefix).
Mechanics:
- New `services.hyperhive.gateway.agents` option (`listOf str`,
default `[]`) lists sub-agent names to expose at
`/agent/<name>/` through the gateway.
- For each name, generate one `location /agent/<name>/` block that
`proxy_pass`es to `http://127.0.0.1:<port>/`, where `<port>`
is computed from the same FNV-1a hash hive-c0re uses internally
(`lifecycle::agent_web_port`).
- Trailing-slash pair on location + proxy_pass strips the
`/agent/<name>` prefix on the upstream side — agent server
receives `GET /`, `GET /api/state`, `GET /screen/ws`, etc. as if
reached directly on its port.
- `X-Forwarded-Prefix` set so the harness can build correct absolute
URLs for cases where document-relative isn't enough.
- `proxyWebsockets = true` + `proxy_buffering off` keeps SSE
+ WS endpoints working transparently.
- Empty `cfg.agents` (default) → no per-agent blocks generated.
- Manager not included — already gets `/` via the c0re upstream.
FNV-1a hash replicated in nix to match `lifecycle::agent_web_port`
line-for-line. Verified against rust output for 8 representative
agent names:
agent | nix | rust | match
iris | 8178 | 8178 | ✓
atlas | 8304 | 8304 | ✓
argus | 8267 | 8267 | ✓
damocles | 8549 | 8549 | ✓
manager | 8000 | 8000 | ✓ (special case)
dmatrix | 8266 | 8266 | ✓
triage | 8737 | 8737 | ✓
bitburner | 8658 | 8658 | ✓
Drift hazard documented in the let-block comment: if the rust
constants change (MANAGER_PORT, WEB_PORT_BASE, WEB_PORT_RANGE, or
the FNV-1a parameters), the nix copy needs a lockstep bump or
gateway will proxy to wrong ports. Tracked in the option's
description as a follow-up to single-source via
`/var/lib/hyperhive/meta/topology.json` lib.importJSON OR runtime
nginx-include written by c0re.
Char-code lookup table covers `[a-z0-9_-]` — the current
`hyperhive.user.name` alphabet. Names with other chars produce an
eval-time error rather than a silent wrong hash.
Verified:
- `nix eval` on the locations attrset for [iris atlas argus damocles]
→ correct ports (matching rust impl) on each `/agent/<name>/` block
- empty `cfg.agents` default → no per-agent blocks (`[ "/" ]` only)
- full container toplevel builds cleanly with 7 agents + matrix on
(`nixos-system-hive-gateway-26.05pre-git`)
Sequencing per mara: this is #15 v0 (gateway-side per-agent routing,
purely additive). #14 netns isolation follows once this soaks.
Out of scope: TLS, manager special-case routing, per-agent unix
sockets (mara: "at some point the agent servers will be domain
sockets"), CORS workaround removal at `POST /answer-question/{id}`,
gateway auth.
Closes#15 v0.
mara on PR #729: "this still feels hacky - is there a proper way to do this?"
damocles: agreed, "Accept-header map is meaningfully better than the
allowlist [...] one map definition that encodes browser semantics directly,
vs ~20 extensions to keep synced with whatever fluffychat (and any future
hyperhive-served SPA) decides to ship".
The previous shape (#684 catch-all regex, then this PR v1's
extension allowlist) leaned on heuristics to distinguish "missing
asset → 404" from "unknown SPA route → fall back to index.html".
Both shapes were fragile against a SPA shipping a new extension,
and the allowlist became dead code the moment a route ended in
`.html-ish-suffix`.
The proper distinction lives at the HTTP layer: top-frame browser
navigations send `Accept: text/html,...` (chrome/firefox/safari are
consistent on this). Asset fetches from script tags / img / fetch() /
XHR send asset-typed Accepts (`image/*`, `application/javascript`,
`*/*`) without `text/html`.
Mechanics: an `nginx http`-context `map` keyed on `$http_accept`
emits either `/matrix/index.html` (navigation) or a sentinel
nonexistent path (`/__matrix_spa_no_html_fallback`); the location's
`try_files $uri $uri/ $matrix_spa_target =404;` does the right thing
for both cases. No extension list, no regex narrowing, no `if` block,
no named-location fallback.
The `map` lives in `services.nginx.appendHttpConfig` (only added
when the matrix GUI is on, otherwise no `map` directive at all).
The location's `extraConfig` is now a single `try_files` line.
Verified via `nix eval` on both the rendered `appendHttpConfig` and
the location's `extraConfig`. Full closure build pending operator
deploy.
Closes#686.
iris's diagnosis on #643 (mara's fluffychat-web login attempt): the gateway's
`/matrix/` location used
try_files $uri $uri/ /matrix/index.html;
which silently returned `index.html` (Content-Type: text/html, status 200) for
ANY missing path under `/matrix/`, including static assets like
`native_executor.js`. flutter's bootstrap requested that JS file, got HTML back,
failed to load the JS runtime, and the page rendered blank without any visible
error in the browser console.
(Confirmed root cause for the missing file itself: the upstream `fluffychat-web`
dist in nixpkgs ships `native_executor.dart` but no compiled `native_executor.js`,
even though `main.dart.js` references the latter. That's a separate
fluffychat-web packaging issue — tracked separately; this PR fixes only the
gateway-side masking that hides such failures.)
Replaces the inline `try_files` fallback with a named-location fallback that
distinguishes between route-shaped URIs (no extension) and asset-shaped URIs
(any `.<ext>` suffix):
location /matrix/ {
alias <pkg>/;
try_files $uri $uri/ @matrix_spa_fallback;
}
location @matrix_spa_fallback {
if ($uri ~ "\.[A-Za-z0-9]+$") {
return 404;
}
rewrite ^ /matrix/index.html last;
}
Routes still fall back to `index.html` so SPA client-side routing keeps
working; missing assets now surface a real 404 so flutter (and the operator's
devtools) can see the failure.
Verified the rendered nginx location attr via
`nix eval .#nixosConfigurations.* .... locations."@matrix_spa_fallback".extraConfig`.
mara on #660: "Matrix domain should default to hive domain if not
set otherwise / redirect matrix clients with .well-known"
Two coupled changes:
1. `services.hyperhive.matrix.serverName` default flipped from
`matrix.${services.hyperhive.domain}` (subdomain) to just
`${services.hyperhive.domain}` (bare hive domain).
This is a "for new deploys only" change — `server_name` is
embedded irrevocably in every user/room ID, so existing
homeservers must set `serverName` explicitly to preserve the
subdomain shape if that's where their identifiers were minted.
Description updated to point at the .well-known piece below.
2. `hive-gateway` nginx now serves matrix-spec `.well-known`
auto-discovery JSON at the canonical location when matrix is
enabled + hive domain set:
GET /.well-known/matrix/client
{"m.homeserver":{"base_url":"http://<domain>:<httpPort>"}}
+ Access-Control-Allow-Origin: * (per matrix spec)
GET /.well-known/matrix/server
{"m.server":"<domain>:<httpPort>"}
tuwunel serves both client + federation on the same `httpPort`
(see hive-matrix.nix), so both records point at the same
endpoint. No-op when matrix isn't enabled or hive domain isn't
set — nothing to advertise.
Combined effect: with `services.hyperhive.domain = "darkest.space"` +
matrix enabled, a matrix client pointed at `darkest.space` resolves
through `.well-known` to the actual `:8008` endpoint, no subdomain
needed. MXIDs become `@atlas:darkest.space` (was: `@atlas:matrix.darkest.space`).
Verified via `nix eval`:
- server_name = "darkest.space" (was "matrix.darkest.space")
- gateway locations include `= /.well-known/matrix/client` + `= /.well-known/matrix/server`
- well-known/matrix/client returns the spec-shaped JSON
Caveat: `m.homeserver.base_url` advertises HTTP (no TLS yet —
follow-up). matrix clients increasingly require HTTPS for new
account creation, so the v0 setup works for local-network testing
but won't satisfy public clients until the gateway TLS story lands.
Closes#660.
argus picked option (a) on #653: put the upgrade note in each option's
`description` so it shows up in `nix flake show` + the rendered
options docs, right next to the option itself. cheapest option, no
eval-time noise (a `warnings` block would fire on every new
deployment that wants false — the normal case now).
Appended a `**Breaking change as of #651**` paragraph to each of the
three `openFirewall` descriptions, naming the exact option string the
operator needs to set to restore the old behaviour.
Gateway's note specifically calls out that external reach is the
common case (operator's primary entry point), so the upgrade hint
is most likely needed there.
mara on #651: "Dont default openFirewall to true."
Flip the `openFirewall` default from `true` to `false` for all three
modules that expose host-side ports:
- `services.hyperhive.forge.openFirewall` (httpPort 3000 + sshPort 2222)
- `services.hyperhive.gateway.openFirewall` (port 80)
- `services.hyperhive.matrix.openFirewall` (httpPort 8008)
Rationale: secure-by-default. With shared host netns, the host +
every agent container reach these services via `localhost` regardless
of the firewall — the open only matters for access from outside the
host. Operators who want external reach now flip the bool explicitly:
services.hyperhive.gateway.openFirewall = true;
Each description updated to explain the new default + when to flip
it (operator's browser, external git clients, federation announcement,
etc.). Behind a host-level reverse proxy that handles TLS, leave off.
Verified via `nix eval` on a clean stub config:
- forge openFirewall = false
- gateway openFirewall = false
- matrix openFirewall = false
- networking.firewall.allowedTCPPorts = [] (was: [80 2222 3000 8008])
Note: c0re's direct ports (7000/8000/8100-8999) are gated separately
via #621 on `gateway.enable` — that gate stays; this PR only touches
the per-module `openFirewall` knobs.
Closes#651.
Per mara's directive on #609: stand up a single nginx in its own
nixos-container, serve the matrix GUI static dist there, proxy
everything else to hive-c0re. v0 is HTTP-only; TLS / public-domain
shape lands in follow-ups.
New `nix/modules/hive-gateway.nix` declaring `containers.hive-gateway`
modelled on `hive-forge`:
- nixos-container running nginx, shares host netns
- `location /matrix/` → static-serves `hyperhive.matrix.gui.package`
(fluffychat-web by default) when `matrix.gui.enable` is true
- `location /` → proxy_pass to `127.0.0.1:${dashboardPort}` with
websocket + SSE upgrade headers + 1d read timeout
Options (`hyperhive.gateway.*`):
- `enable` (default `true`) — gateway on by default, opt out to bypass
- `port` (default `80`) — nginx listen port on the host
- `upstreamHost` / `upstreamPort` — c0re target, defaults to
`127.0.0.1:${services.hive-c0re.dashboardPort}`
- `openFirewall` (default `true`) — open the listen port
- `localHostsEntry` (default `false`) — when true, adds an
`/etc/hosts` entry mapping `hyperhive.domain` → `127.0.0.1` for
local-dev / test loops without real DNS (per mara's spec)
`hive-c0re.nix` updates: when gateway is enabled, skip wiring
`HIVE_MATRIX_GUI_DIR` (gateway owns `/matrix/` now). When gateway is
off, c0re's pre-existing matrix mount stays as the fallback.
README: short "Optional" block introducing the gateway + the
`localHostsEntry` knob.
```sh
nix flake check --no-build
nix build .#docs-host
```
End-to-end eval matrix:
| gateway.enable | matrix.gui.enable | c0re HIVE_MATRIX_GUI_DIR | gateway container |
| --- | --- | --- | --- |
| true (default) | true | unset (gateway serves) | present |
| true | false | unset | present, no /matrix |
| false | true | set (c0re serves) | absent |
| false | false | unset | absent |
- TLS termination — separate follow-up once mara picks a story
(self-signed-mkcert vs operator-provided certs)
- Per-agent UI routing (`/agent/<name>/`) — depends on agent base-path
support which is a frontend lift
- Subdomain routing for `matrix.${hyperhive.domain}` — same-origin
`/matrix/` is the v0 shape per mara ("leave everything else as is")
Closes part of #609 (matrix GUI re-rooting onto nginx); leaves the
issue open for the subdomain re-root + `.well-known/matrix/client`
piece once the multi-host story matures.