{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; 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. ''; }; }; 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; 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/`. fluffychat is a SPA # — fall back to its index.html on deep links. lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable) { "/matrix/" = { alias = "${matrixCfg.gui.package}/"; extraConfig = '' try_files $uri $uri/ @matrix_spa_fallback; ''; }; # SPA fallback (iris/#643). The naive # `try_files $uri $uri/ /matrix/index.html;` shape # silently masked missing static 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. # # Asset-shaped URIs (anything with a `.` segment) # get an explicit 404 so the SPA + browser see the # missing-asset error cleanly. Only route-shaped URIs # (no extension) fall through to index.html for SPA # client-side routing. "@matrix_spa_fallback" = { extraConfig = '' if ($uri ~ "\.[A-Za-z0-9]+$") { return 404; } rewrite ^ /matrix/index.html last; ''; }; } // # `.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}"}'; ''; }; } // { # 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 ]; }; }; }