{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; forgeCfg = config.services.hyperhive.forge; # 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). Target now points at the # sub-domain-root `/index.html` (fluffychat moved off # `/matrix/` sub-path to `matrix./` root in # #772; `--base-href` reverts to upstream-default `/`). appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' map $http_accept $matrix_spa_target { default "/__matrix_spa_no_html_fallback"; "~*text/html" "/index.html"; } ''; virtualHosts = { "_" = { listen = [ { addr = "0.0.0.0"; port = cfg.port; } ]; locations = # Bare-domain `/matrix/*` → 301 redirect to the # matrix sub-domain root (#772). fluffychat-web used to # live at this sub-path; #772 moved it to `matrix./` # so it gets full sub-domain origin isolation + sub-spec # matches the matrix-spec deploy shape. The redirect # preserves bookmark + deep-link compatibility for # `/matrix/#/...` URLs during the transition; # operators can drop the redirect block once it's been # in the wild long enough that no stale bookmarks remain. # # Only emitted when both the matrix GUI is on AND # `matrixCfg.gatewayHost` is set (else there's no # canonical sub-domain to redirect to). lib.optionalAttrs ( matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null ) ( let portSuffix = if cfg.port == 80 then "" else ":${toString cfg.port}"; target = "http://${matrixCfg.gatewayHost}${portSuffix}"; in { # `rewrite ^/matrix/(.*)$ → matrix./$1` — # strips the `/matrix/` prefix on the way out so # `/matrix/#/rooms/...` lands at the right # SPA route on the sub-domain side. `permanent` # emits 301 + sets the canonical Location header. "/matrix/" = { extraConfig = '' rewrite ^/matrix/(.*)$ ${target}/$1 permanent; ''; }; } ) // # `.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) ( let # `.well-known/matrix/{client,server}` advertise where # the actual matrix API lives. When `matrixCfg.gatewayHost` # is set (default `matrix.`, #747), point # at the sub-domain — no port suffix when the gateway # is on the canonical port 80, transparent to clients # (mara on #749:9609 sub-domain verdict, "not user- # visible because the .well-known redirect routes # clients through automatically"). When `gatewayHost` # is unset (no hive-domain, or operator nulled it), # fall back to the direct `host:port` shape — clients # reach tuwunel without going through the gateway, # no sub-domain delegation. portSuffix = if cfg.port == 80 then "" else ":${toString cfg.port}"; clientBaseUrl = if matrixCfg.gatewayHost != null then "http://${matrixCfg.gatewayHost}${portSuffix}" else "http://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; serverHostPort = if matrixCfg.gatewayHost != null then "${matrixCfg.gatewayHost}${portSuffix}" else "${hyperhiveDomain}:${toString matrixCfg.httpPort}"; in { "= /.well-known/matrix/client" = { extraConfig = '' default_type application/json; add_header Access-Control-Allow-Origin *; return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}'; ''; }; "= /.well-known/matrix/server" = { extraConfig = '' default_type application/json; return 200 '{"m.server":"${serverHostPort}"}'; ''; }; } ) // # 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; ''; }; }; }; } // # Forge vhost (#749, mara verdict at issue:9609 — # sub-domain over sub-path). When forgejo runs behind the # gateway (`forge.behindGateway = true`), it gets its own # `server { server_name = forge.domain; }` block. The # block proxies all `/` → `http://127.0.0.1:/` # so forgejo handles requests at root (default deploy shape # — no `ROOT_URL`-prefix translation needed). # # `forge.domain` is the full hostname (e.g. # `forge.darkest.space`, `git.example.com`) — single source # of truth for both the forgejo `DOMAIN` setting and the # gateway vhost name (mara on #754:9684 — "specify full # forge domain in options instead"). # # `client_max_body_size 1G` — git pushes + LFS uploads can # be large; nginx's default 1M would 413 most real commits. # # Long timeouts for big repo operations: a fresh clone of a # multi-GB repo can take minutes; the default 60s # `proxy_read_timeout` would abort mid-stream. # # `proxyWebsockets = true` keeps forgejo's live-update # endpoints (`/api/v1/events`) + any future websocket # endpoints working transparently. SSH stays direct on # `forge.sshPort` (separate listener protocol, not HTTP). lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) { "${forgeCfg.domain}" = { listen = [ { addr = "0.0.0.0"; port = cfg.port; } ]; locations."/" = { proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; client_max_body_size 1G; proxy_read_timeout 1h; proxy_send_timeout 1h; ''; }; }; } // # Matrix homeserver vhost (#747, mara verdict on #749:9609 — # sub-domain over sub-path for matrix; "not user-visible" # because clients discover the sub-domain via the # `.well-known/matrix/{client,server}` delegation served # above on the bare hive-domain). # # `server { server_name = matrixCfg.gatewayHost; }` proxies # `/_matrix/...` → `http://127.0.0.1:''${matrixCfg.httpPort}/_matrix/...`. # Tuwunel listens on `:''${httpPort}` (default 8008); the # gateway terminates on `:''${cfg.port}` (80) so external # clients speak matrix over the canonical web port without # operators having to open the tuwunel port through firewalls. # # `/` returns 404 — nothing else lives at the matrix vhost; # the matrix client-server API is entirely under `/_matrix/`, # and federation under `/_matrix/federation/...`. # # CORS `*` on the matrix vhost per the matrix spec — # federation + client requests come from any origin. # # `client_max_body_size 50M` covers typical media uploads # (matrix-spec media size cap default); operators with bigger # uploads override via the matrix module's own cap when that # lands. # # `proxy_read_timeout 1h` for long-poll `/sync`; the default # 60s would abort `/sync?timeout=30000` legitimately when # tuwunel's keepalive exceeds that. lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { "${matrixCfg.gatewayHost}" = { listen = [ { addr = "0.0.0.0"; port = cfg.port; } ]; locations = { "/_matrix/" = { proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; client_max_body_size 50M; proxy_read_timeout 1h; proxy_send_timeout 1h; add_header Access-Control-Allow-Origin *; ''; }; } // # Fluffychat-web (or override) served at sub-domain # root (#772 — moved from bare-domain `/matrix/`). # nginx location-precedence: longer prefix wins, # so `/_matrix/` (above) handles the matrix API # and `/` falls through to fluffychat for # everything else. # # SPA fallback uses the same Accept-header `$matrix_spa_target` # map from `appendHttpConfig` above — navigations # fall to `/index.html` (now sub-domain-root path), # asset misses return clean 404. # # `= /config.json` serves the FluffyChat boot-config # pre-fill with the operator's hive-domain so the # client's `.well-known/matrix/client` lookup hits # the right delegation endpoint (#736). lib.optionalAttrs (matrixCfg.gui.enable) ( { "/" = { alias = "${matrixCfg.gui.package}/"; extraConfig = '' try_files $uri $uri/ $matrix_spa_target =404; ''; }; } // lib.optionalAttrs (hyperhiveDomain != null) { "= /config.json" = { extraConfig = '' default_type application/json; return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; ''; }; } ) // # Fall-through `/` when GUI is off: nothing else # lives at the matrix vhost, return 404 cleanly. lib.optionalAttrs (!matrixCfg.gui.enable) { "/" = { return = "404"; }; }; }; }; }; }; }; networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; }; # `/etc/hosts` entries for local dev: the bare hive domain plus # any sub-domain modules (forge via #749/#754, matrix via #747) # that are on. All map to `127.0.0.1` since the gateway shares # host netns. Operators with real DNS leave `localHostsEntry = # false`; this is the dev-loop shortcut for `http:///` # + `http://forge./` + `http://matrix./` # resolving locally. # # `lib.unique` collapses any duplicate (e.g. if forge.domain # happens to equal hyperhiveDomain or matrixCfg.gatewayHost) so # `/etc/hosts` doesn't carry the same entry twice. networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) { "127.0.0.1" = lib.unique ( [ hyperhiveDomain ] ++ lib.optional ( (config.services.hyperhive.forge.enable or false) && (config.services.hyperhive.forge.behindGateway or false) ) config.services.hyperhive.forge.domain ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost ); }; }; }