hyperhive/nix/modules/hive-gateway.nix
atlas cb1a5cdbb8 nix/hive-gateway: per-agent /agent/<name>/ routing through gateway (#15 v0)
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.
2026-05-31 12:49:53 +02:00

425 lines
18 KiB
Nix

{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
matrixCfg = config.services.hyperhive.matrix;
# Per-agent web port, mirroring `hive-c0re::lifecycle::agent_web_port`
# exactly so the gateway and the harness agree on what port to talk
# to without a runtime contract. The manager is fixed at
# `MANAGER_PORT`; every sub-agent is `WEB_PORT_BASE + FNV-1a(name) %
# WEB_PORT_RANGE`. Pure + reproducible from just the name, matches
# the rust constants line-for-line (lifecycle.rs:60-79).
#
# **Drift hazard**: if the rust constants change (MANAGER_PORT,
# WEB_PORT_BASE, WEB_PORT_RANGE, MANAGER_NAME, or the FNV-1a
# parameters), this nix copy must change in lockstep — otherwise
# `/agent/<name>/` requests get proxied to the wrong port. There's
# no automated cross-check today (#15 v0 follow-up: have c0re emit
# the port mapping as `/var/lib/hyperhive/agent-ports.json` and
# have this module read it via `lib.importJSON`, single-sourcing
# the table).
agentWebPortLib =
let
# ASCII char → byte code lookup, limited to chars valid in agent
# names (lowercase alpha, digits, dash, underscore). Names with
# other chars are an eval-time error rather than a silent wrong
# hash. Add entries here if hyperhive ever loosens the naming
# constraint.
charCode = {
"0" = 48; "1" = 49; "2" = 50; "3" = 51; "4" = 52; "5" = 53;
"6" = 54; "7" = 55; "8" = 56; "9" = 57;
"-" = 45; "_" = 95;
"a" = 97; "b" = 98; "c" = 99; "d" = 100; "e" = 101;
"f" = 102; "g" = 103; "h" = 104; "i" = 105; "j" = 106;
"k" = 107; "l" = 108; "m" = 109; "n" = 110; "o" = 111;
"p" = 112; "q" = 113; "r" = 114; "s" = 115; "t" = 116;
"u" = 117; "v" = 118; "w" = 119; "x" = 120; "y" = 121;
"z" = 122;
};
webPortBase = 8100;
webPortRange = 900;
managerPort = 8000;
managerName = "manager";
# nix uses signed 64-bit ints; mask each step to u32 to mirror
# rust's `u32::wrapping_mul`. FNV-1a constants identical to the
# rust version (offset basis 2_166_136_261, prime 16_777_619).
fnv1aU32 =
name:
let
chars = lib.stringToCharacters name;
mask32 = h: lib.bitAnd h 4294967295;
step =
acc: c:
mask32 (
(lib.bitXor acc (
charCode.${c}
or (throw "agent name '${name}' contains char '${c}' outside the [a-z0-9_-] alphabet hyperhive.user.name constraint mismatch?")
))
* 16777619
);
in
lib.foldl' step 2166136261 chars;
in
name: if name == managerName then managerPort else webPortBase + lib.mod (fnv1aU32 name) webPortRange;
in
{
# Single nginx in front of every hyperhive surface (#609 / #15 v0).
# Lives in its own nixos-container (like hive-forge / hive-matrix) so
# the operator can opt out without touching the host's own nginx, and
# so the static-serve responsibility for the matrix GUI moves off
# hive-c0re's axum router. Shares host netns so `localhost`
# upstream resolution works without any port-forward dance.
#
# Routes (v0):
# `location /matrix/` → static-serve fluffychat-web dist (when
# `services.hyperhive.matrix.gui.enable` is true)
# `location /` → proxy_pass to hive-c0re's dashboard upstream
#
# Container name `hive-gateway` keeps hive-c0re's lifecycle scanner
# (which only sees `h-*`) out of the picture. State-free — nginx
# config lives in the nix store, no runtime persistence to manage.
options.services.hyperhive.gateway = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Run hive-gateway a single nginx in front of every hyperhive
surface. On by default: the gateway hosts the matrix GUI static
dist (when `services.hyperhive.matrix.gui.enable` is true) and
proxies everything else to hive-c0re's dashboard upstream. Set
`services.hyperhive.gateway.enable = false` to bypass nginx
entirely and reach hive-c0re directly on its dashboard port
(7000 by default).
v0 is HTTP-only; TLS / public-domain shape is tracked
separately.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 80;
example = 8080;
description = ''
TCP port the gateway listens on. Default 80 (canonical web
port). nginx inside the container binds <1024 because the
container's init runs as root; if 80 is already taken on the
host (existing nginx, traefik, etc.) override to an unused
port like 8080 or move the conflicting service.
'';
};
upstreamHost = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Host the gateway proxies non-static requests to. Defaults to
`127.0.0.1` because the gateway container shares the host
netns, so loopback resolves directly to hive-c0re.
'';
};
upstreamPort = lib.mkOption {
type = lib.types.port;
default = 7000;
description = ''
TCP port the gateway proxies non-static requests to. Defaults
to `7000` (hive-c0re's out-of-the-box dashboard port). Operators
who change `services.hyperhive.c0re.dashboardPort` should set
`upstreamPort` to match kept as a hardcoded default rather
than a cross-reference to keep this module's options eval
independent of c0re's option tree shape.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Open `port` in the host firewall. Off by default (#651,
secure-by-default). Flip to `true` to expose the gateway to
the operator's browser / external clients required for any
out-of-host reach, since the agents themselves talk to
hive-c0re via the per-agent unix sockets and don't need the
nginx vhost. Leave off when running behind another reverse
proxy (e.g. caddy / traefik on the host) that handles TLS
termination + forwards to `port`.
**Breaking change as of #651**: this used to default to
`true`. If you relied on the old default for external reach
(the common case the gateway is the operator's primary
entry point), add `services.hyperhive.gateway.openFirewall = true;`
to your host config before rebuilding.
'';
};
localHostsEntry = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Add an `/etc/hosts` entry mapping `services.hyperhive.domain`
to `127.0.0.1` on the host. Useful for local deployments +
tests where there's no real DNS for `services.hyperhive.domain`
but the operator (or browser-based tests) want to hit
`http://''${services.hyperhive.domain}` to exercise the
gateway shape. Off by default operators running with real
DNS shouldn't have a stale `/etc/hosts` entry sticking
around. Requires `services.hyperhive.domain` to be set.
'';
};
agents = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"iris"
"atlas"
"argus"
"damocles"
];
description = ''
Sub-agent names to expose at `http(s)://<gateway>/agent/<name>/`
through the gateway (#15 v0). For each name in the list, the
gateway adds a `location /agent/<name>/` block that
`proxy_pass`es to `http://127.0.0.1:<port>/`, where `<port>` is
derived from the same `agent_web_port(name)` hash hive-c0re
uses internally (`lifecycle.rs` constants + FNV-1a, replicated
in this module's `let` block).
**Purely additive** the old `http://<host>:<port>/` direct
reach still works in parallel; this just gives the operator a
single-origin route. Manager isn't included (no per-agent
prefix needed; manager already gets the `/` route via the
c0re upstream block).
Empty list (default) leaves the gateway in its pre-#15 shape:
no per-agent routes, only `/` (c0re) + `/matrix/` (fluffychat
when matrix is on) + `.well-known/matrix/*` (matrix
autodiscovery).
Maintenance: this list is currently operator-managed (the
gateway runs at the host level, the agent list lives in the
meta-flake at runtime, and the host's nix eval doesn't see
the meta-flake's agent dirs). Follow-up tracked at #15:
either auto-derive from
`/var/lib/hyperhive/meta/topology.json` via `lib.importJSON`
in the gateway module (gives a single source of truth at the
cost of an eval-time impurity), or have c0re write an nginx
snippet that the container `include`s + reloads on topology
change (decouples from rebuilds entirely; also handles the
eventual move to per-agent unix sockets).
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
message = ''
services.hyperhive.gateway.localHostsEntry = true requires
services.hyperhive.domain to be set. Either pin a hostname
or leave `localHostsEntry` at its default of false.
'';
}
];
containers.hive-gateway = {
autoStart = true;
ephemeral = false;
# Share host netns — nginx then binds host-level ports directly,
# `localhost` upstream resolution reaches hive-c0re without any
# port-forward dance, and the firewall config below is the only
# layer that matters.
privateNetwork = false;
config =
{ pkgs, ... }:
{
system.stateVersion = "26.05";
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
# SPA-fallback target keyed on the `Accept` request header
# (#686, mara + damocles on PR #729). This decides whether a
# `/matrix/...` miss falls through to `index.html` (route
# navigation) or returns a clean 404 (asset miss) — see the
# `/matrix/` location comment below for the full rationale.
#
# Top-frame browser navigations always 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`.
# Mapping is purely on the header → no extension allowlist
# to keep in sync with whatever the SPA ships, no regex
# heuristic to false-positive on dot-segment routes.
#
# `$matrix_spa_target` defaults to a sentinel nonexistent
# path so `try_files` falls through to the trailing `=404`
# for asset misses. Browser navigations route to
# `/matrix/index.html` where the SPA's client-side router
# takes over.
#
# Only emitted when the matrix GUI is on (saves a no-op
# `map` directive otherwise).
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/matrix/index.html";
}
'';
virtualHosts."_" = {
listen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
];
locations =
# Matrix GUI: when the operator has flipped both
# `services.hyperhive.matrix.enable` and `matrix.gui.enable`
# on, nginx serves fluffychat-web (or whatever override)
# as a static dist at `/matrix/`.
#
# SPA fallback (iris/#643, rewritten in #686 per mara
# + damocles on PR #729): the original
# `try_files $uri $uri/ /matrix/index.html;` shape
# silently masked missing assets — flutter's bootstrap
# requesting e.g. `/matrix/native_executor.js` got
# `index.html` (Content-Type: text/html, status 200)
# when the file was absent from the dist, so the JS
# runtime never loaded and `/matrix/` rendered blank
# without any visible error.
#
# The followup #729 narrowed it with an extension
# allowlist; this version uses `$matrix_spa_target`
# (defined in the `appendHttpConfig` above, keyed on
# the `Accept` header) so the decision lives in HTTP
# semantics rather than a maintained extension list.
# Navigations (Accept: text/html) fall to index.html;
# asset fetches (Accept: */*, image/*, etc.) get a
# clean 404 via the trailing `=404`.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable) {
"/matrix/" = {
alias = "${matrixCfg.gui.package}/";
extraConfig = ''
try_files $uri $uri/ $matrix_spa_target =404;
'';
};
}
//
# `.well-known/matrix/*` auto-discovery (#660): when
# `services.hyperhive.matrix.enable` is on and the
# operator's set a hive domain, the gateway serves
# the matrix-spec discovery JSON at the canonical
# location so clients pointed at `${hyperhive.domain}`
# resolve through to the actual tuwunel endpoint
# without needing a `matrix.` subdomain.
#
# `m.homeserver.base_url` advertises the client-server
# API. `m.server` advertises the federation
# `host:port` (tuwunel serves both client + federation
# on the same `httpPort` — see hive-matrix.nix).
#
# CORS `*` on the client endpoint per the matrix spec
# (https://spec.matrix.org/v1.15/client-server-api/#getwell-knownmatrixclient).
# No-op until the operator turns matrix on; until then
# there's no homeserver to advertise.
lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) {
"= /.well-known/matrix/client" = {
extraConfig = ''
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver":{"base_url":"http://${hyperhiveDomain}:${toString matrixCfg.httpPort}"}}';
'';
};
"= /.well-known/matrix/server" = {
extraConfig = ''
default_type application/json;
return 200 '{"m.server":"${hyperhiveDomain}:${toString matrixCfg.httpPort}"}';
'';
};
}
//
# Per-agent UIs (#15 v0). One `/agent/<name>/`
# block per name in `services.hyperhive.gateway.agents`,
# proxying to the agent's harness web server on
# `127.0.0.1:<port>` where `<port>` comes from the
# `agentWebPortLib` FNV-1a hash (matches
# `lifecycle::agent_web_port` line-for-line).
#
# Trailing-slash pair (`/agent/<name>/` + `proxy_pass
# http://...:<port>/`) strips the `/agent/<name>`
# prefix on the upstream side, so the agent server
# receives `GET /` for the SPA root, `GET /api/state`
# for the API, `GET /screen/ws` for the websocket, etc.
# The agent's emitted asset URLs are document-relative
# (iris's #731) so they round-trip back through the
# gateway under the same prefix without the harness
# needing prefix-awareness.
#
# `X-Forwarded-Prefix` set so the harness can build
# correct absolute URLs for any case where relative
# isn't enough (server-emitted redirects, OG meta
# tags, etc.).
#
# SSE / websocket support via `proxyWebsockets = true`
# (same as the c0re `/` block below).
#
# Empty `cfg.agents` list → empty attrset → no
# per-agent blocks; old `<host>:<port>/` direct reach
# still works.
builtins.listToAttrs (
builtins.map (name: {
name = "/agent/${name}/";
value = {
proxyPass = "http://127.0.0.1:${toString (agentWebPortLib name)}/";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header X-Forwarded-Prefix /agent/${name};
proxy_buffering off;
proxy_read_timeout 1d;
'';
};
}) cfg.agents
)
// {
# Everything else proxies to hive-c0re. Upgrade
# headers stay set so SSE (`/dashboard/stream`,
# `/events/stream`) + websocket (`/screen/ws`)
# endpoints keep working transparently.
"/" = {
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
proxy_read_timeout 1d;
'';
};
};
};
};
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) {
"127.0.0.1" = [ hyperhiveDomain ];
};
};
}