{ 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. C0re writes # this JSON on every topology change; gateway reads at deploy time. # Missing file → empty map → no per-agent routes (graceful default). # See `docs/gateway.md` for the discovery + rebuild flow. agentPortsTable = if cfg.agentPortsFile == null || !builtins.pathExists cfg.agentPortsFile then { } else builtins.fromJSON (builtins.readFile cfg.agentPortsFile); # Static error pages for `/agent//` mishaps (#755). Mara's # call: useful pages instead of nginx's default 404/502 for routes # we've already special-cased. See `docs/gateway.md::Per-agent # error pages` for the design rationale + page-vs-status semantics. agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } '' mkdir -p $out cat > $out/not-found.html <<'EOF' agent not found ◆ hyperhive

◆ agent not found

No agent matches the requested /agent/<name>/ path on this hive.

Operator: check the agent name in the dashboard — the gateway picks up new agents on the next nixos-rebuild switch.

EOF cat > $out/unreachable.html <<'EOF' agent unreachable ◆ hyperhive

◆ agent unreachable

The agent's harness web server isn't responding. Container restarting, or the agent crashed.

Operator: dashboard → check the container status / journal; the page will recover on retry once the harness is back up.

EOF ''; in { # Single nginx in front of every hyperhive web surface — dashboard, # per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known # delegations. Container `hive-gateway`, shared host netns, # state-free. Full vhost map + discovery flow + design rationale in # `docs/gateway.md`. 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; # Accept-header SPA fallback (#686 / #729): navigations # (`Accept: text/html,...`) fall to index.html, asset # fetches (Accept *anything else*) fall to a sentinel # nonexistent path → `try_files` returns 404. Pattern # detailed in `docs/gateway.md` ("SPA fallback"). 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 = # `/matrix/*` → 301 → `matrix./$1` # (fluffychat moved to sub-domain root in #772; this # keeps bookmarks + deep-links working during the # transition). See `docs/gateway.md` for the vhost # map. 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 { "/matrix/" = { extraConfig = '' rewrite ^/matrix/(.*)$ ${target}/$1 permanent; ''; }; } ) // # `.well-known/matrix/{client,server}` discovery JSON. # Points clients at `matrixCfg.gatewayHost` (sub-domain # vhost) when set; falls back to direct `:` # when no gateway target. CORS `*` per matrix spec. # See `docs/gateway.md` "Discovery flow" for the full # client-bootstrap sequence. lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) ( let 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`. Trailing-slash pair # strips the prefix; `X-Forwarded-Prefix` lets the # harness build absolute URLs when relative isn't # enough. `proxy_intercept_errors` + `error_page` rewrite # upstream 502/503/504 (container down / restarting) to # the static `unreachable.html` instead of nginx's # default Bad Gateway page (#755). See # `docs/gateway.md` for the vhost map + tuning. 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; proxy_intercept_errors on; error_page 502 503 504 = /__hive_agent_unreachable; ''; }; }) agentPortsTable // # `/agent/` catch-all (#755): hits when an operator # requests `/agent//...` — a name not in # `agentPortsTable`. Without this it falls through to # `/` (c0re dashboard upstream) which returns 404 # with no useful context. Custom 404 page instead. { "/agent/" = { extraConfig = '' error_page 404 = /__hive_agent_not_found; return 404; ''; }; # Internal static-file locations the error_page # directives above point at. `internal` keeps # operators from hitting the file directly (only # nginx's error-handling can reach it); `alias` # serves the exact file regardless of request URI. "= /__hive_agent_not_found" = { extraConfig = '' internal; alias ${agentErrorPagesDir}/not-found.html; default_type text/html; ''; }; "= /__hive_agent_unreachable" = { extraConfig = '' internal; alias ${agentErrorPagesDir}/unreachable.html; default_type text/html; ''; }; } // { # 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 sub-domain vhost (#749 / #754). `server_name = # forge.domain`, proxies all `/` → forgejo. Tuned for # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` # (multi-GB clones). SSH stays direct on `forge.sshPort`. # See `docs/gateway.md`. 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 sub-domain vhost (#747 / #764). `server_name = # matrixCfg.gatewayHost`. `/_matrix/*` → tuwunel (CORS *, # 50M body cap, 1h long-poll timeout). `/` serves # fluffychat (#772) or 404 if GUI off. nginx # longer-prefix-wins puts `/_matrix/` ahead of `/`. # See `docs/gateway.md`. 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 *; ''; }; } // lib.optionalAttrs (matrixCfg.gui.enable) ( { # fluffychat at sub-domain root, SPA-fallback via # the Accept-header `$matrix_spa_target` map. "/" = { alias = "${matrixCfg.gui.package}/"; extraConfig = '' try_files $uri $uri/ $matrix_spa_target =404; ''; }; } // lib.optionalAttrs (hyperhiveDomain != null) { # FluffyChat boot-config pre-fill so the client's # `.well-known/matrix/client` lookup hits the # right delegation endpoint (#736). "= /config.json" = { extraConfig = '' default_type application/json; return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; ''; }; } ) // lib.optionalAttrs (!matrixCfg.gui.enable) { "/" = { return = "404"; }; }; }; }; }; }; }; networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; }; # `/etc/hosts` entries for local dev — bare hive domain + any # sub-domain modules that are on. `lib.unique` dedupes if any # sub-domain happens to equal another. See `docs/gateway.md` # ("Local dev"). 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 ); }; }; }