{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; # Per-agent port table for `/agent//` routing (#15 v0). Single- # sourced from `cfg.agentPortsFile` (default # `/var/lib/hyperhive/agent-ports.json`), written by hive-c0re on every # topology change in shape `{ "": , ... }`. # # Read at deploy time via `builtins.fromJSON (builtins.readFile ...)` # — pure eval (the file lives outside the nix store; nix copies the # content into the store as a fixed-output dep). When the file is # missing (fresh install before c0re has had a chance to write it), # default to an empty map → no per-agent routes generated → gateway # falls back to its pre-#15 shape. The container rebuilds on every # `hivectl gateway-sync` (operator-initiated) or on the next # `nixos-rebuild switch`, picking up whatever c0re has written # since the last build. # # mara on #740 (comment 9295) + #15 (comment 9270): the gateway # nginx container lives in system config (not meta), so it can't # auto-rebuild from meta-flake events — the JSON file is what # bridges the host's nix eval to the agent-lifecycle data c0re owns. agentPortsTable = if cfg.agentPortsFile == null || !builtins.pathExists cfg.agentPortsFile then { } else builtins.fromJSON (builtins.readFile cfg.agentPortsFile); 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. ''; }; agentPortsFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = "/var/lib/hyperhive/agent-ports.json"; example = "/var/lib/hyperhive/agent-ports.json"; description = '' Path to a JSON file mapping sub-agent names to their web ports for `/agent//` routing through the gateway (#15 v0). Shape: `{ "": , ... }`. Written by hive-c0re on every topology change (the rust side knows the canonical port allocation via `lifecycle::agent_web_port`; the gateway just reads what it's told). For each `: ` entry, the gateway adds a `location /agent//` block that `proxy_pass`es to `http://127.0.0.1:/`. Empty / missing file → no per-agent routes generated → gateway falls back to its pre-#15 shape (just `/` + matrix surfaces). **Purely additive**: the old `http://:/` direct reach keeps working in parallel; this just gives the operator a single-origin route. Manager isn't included in the map (no per-agent prefix needed; manager already gets the `/` route via the c0re upstream block). Set to `null` to disable per-agent routing entirely without creating the file. Set to a custom path if the operator's c0re writes the table elsewhere. **Rebuild trigger**: the gateway container picks up new entries on the next `nixos-rebuild switch` (or `hivectl gateway-sync` if that helper lands). c0re writes are not auto-applied to a running gateway — see the follow-up in #15 for runtime nginx include + reload + eventual 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; ''; }; # FluffyChat fetches `/matrix/config.json` directly on # boot for its own branding + default-homeserver # bootstrap, BEFORE asking the user to pick a server # (#736). The upstream `pkgs.fluffychat-web` dist # ships without one, so the fetch 404s and the user # sees the empty "enter homeserver" prompt. Serve a # minimal config that pre-fills `defaultHomeserver` # with the operator's hive domain — the matrix # client then runs `.well-known/matrix/client` against # that domain (already served by the # `= /.well-known/matrix/client` block below) and # discovers the actual tuwunel endpoint. # # Only the `defaultHomeserver` field is overridden — # everything else (branding, audio defaults, etc.) # falls back to fluffychat's hardcoded defaults so # we don't pin against upstream config-schema drift. # No-op when `services.hyperhive.domain` is unset # (the location block is omitted entirely; the SPA # then falls back to its empty form, same as before # #736). } // lib.optionalAttrs ( matrixCfg.enable && matrixCfg.gui.enable && hyperhiveDomain != null ) { "= /matrix/config.json" = { extraConfig = '' default_type application/json; return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; ''; }; } // # `.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 `: ` entry in # `agentPortsTable` (loaded from `cfg.agentPortsFile` # — `/var/lib/hyperhive/agent-ports.json` by default, # written by hive-c0re on every topology change). # # 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 / missing `cfg.agentPortsFile` → empty table # → no per-agent blocks; old `:/` direct # reach still works. lib.mapAttrs' (name: port: { name = "/agent/${name}/"; value = { proxyPass = "http://127.0.0.1:${toString port}/"; proxyWebsockets = true; extraConfig = '' proxy_set_header X-Forwarded-Prefix /agent/${name}; proxy_buffering off; proxy_read_timeout 1d; ''; }; }) agentPortsTable // { # 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 ]; }; }; }