{ 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//` 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):///agent//` through the gateway (#15 v0). For each name in the list, the gateway adds a `location /agent//` block that `proxy_pass`es to `http://127.0.0.1:/`, where `` 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://:/` 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//` # block per name in `services.hyperhive.gateway.agents`, # proxying to the agent's harness web server on # `127.0.0.1:` where `` comes from the # `agentWebPortLib` FNV-1a hash (matches # `lifecycle::agent_web_port` line-for-line). # # Trailing-slash pair (`/agent//` + `proxy_pass # http://...:/`) strips the `/agent/` # 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 `:/` 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 ]; }; }; }