{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; forgeCfg = config.services.hyperhive.forge; networkCfg = config.services.hyperhive.network; # Static error pages for `/agent//` mishaps. # Useful pages 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.

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 (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`. **Note**: this used to default to `true`. Add `services.hyperhive.gateway.openFirewall = true;` to your host config if external reach stopped working after a recent upgrade. ''; }; 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. ''; }; selfSignedTls = lib.mkOption { type = lib.types.bool; default = true; example = false; description = '' Generate a self-signed TLS cert at first gateway boot and listen on `httpsPort` (default 443) with it on every vhost. On by default because matrix-dart-sdk (the SDK behind FluffyChat + several other Matrix clients) hardcodes `https:///.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http — without TLS the browser client just won't connect. Self-signed means browsers will show a "not secure" warning on first visit; the operator clicks through once per browser. For production deployments, set this to `false` and front the gateway with a reverse proxy (caddy, traefik, or nginx with ACME) that does proper TLS termination. The cert is regenerated on demand if the file is missing but never rotated automatically; delete `/var/lib/hive-gateway/tls/cert.pem` inside the gateway container to force a fresh one. See `docs/gateway.md` ("Self-signed TLS"). ''; }; httpsPort = lib.mkOption { type = lib.types.port; default = 443; example = 8443; description = '' TCP port for the TLS-terminated vhosts when `selfSignedTls` is enabled. Default 443. Setting `selfSignedTls = false` renders this option inert (the gateway listens on `port` only). ''; }; }; 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. ''; } ]; # Ensure bind-mount sources exist at host boot before the gateway # container's first start. nspawn would auto-create missing dirs # tmpfiles rules make the intent explicit # and cover the fresh-boot window before c0re has run. # # /run/hive-agent — per-agent UDS socket dir, written by c0re's # set_nspawn_flags when agents start. # /var/lib/hyperhive — hyperhive state dir, created by c0re on # first run. Also pre-seed agents.conf with an empty-but-valid # header so nginx can start + include the file before c0re writes # its first real content (f = create-if-absent, no overwrite). systemd.tmpfiles.rules = [ "d /run/hive-agent 0755 root root - -" "d /var/lib/hyperhive 0755 root root - -" "d /var/lib/hyperhive/gateway 0755 root root - -" "f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re — do not edit.\n" ]; 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; # Bind-mount the per-agent socket dir so nginx inside the gateway # container can `connect(2)` to the UDS upstreams. # Read-only (we just connect; harness writes the socket inside # the agent's own container). Host-side dir is pre-created by a # tmpfiles rule so nspawn always finds a source at boot. bindMounts."/run/hive-agent" = { hostPath = "/run/hive-agent"; isReadOnly = true; }; # Bind-mount ONLY the gateway-specific subdir of the hyperhive # state dir. Scoped to /var/lib/hyperhive/gateway/ rather than # the whole parent so the gateway container can't read forge # tokens or other files that may live at the parent level. # c0re writes agents.conf under this subdir and triggers an nginx # reload from the host via systemd-run after each write. # Pre-created by a tmpfiles rule. bindMounts."/run/hive-state" = { hostPath = "/var/lib/hyperhive/gateway"; isReadOnly = true; }; config = { pkgs, ... }: let tlsDir = "/var/lib/hive-gateway/tls"; tlsCert = "${tlsDir}/cert.pem"; tlsKey = "${tlsDir}/key.pem"; # Listen addresses every vhost shares. Plain http on `cfg.port` # always; `cfg.httpsPort` with TLS sits beside it when # `cfg.selfSignedTls` is on. See `docs/gateway.md` # ("Self-signed TLS") for the cert lifecycle. vhostListen = [ { addr = "0.0.0.0"; port = cfg.port; } ] ++ lib.optional cfg.selfSignedTls { addr = "0.0.0.0"; port = cfg.httpsPort; ssl = true; }; # nixos `services.nginx.virtualHosts.` ssl attrs to mix # into each vhost when self-signed TLS is on. `addSSL = true` # is what gates `ssl_certificate` directive emission in the # nixos nginx module (`hasSSL` checks addSSL / onlySSL / # forceSSL). The actual ssl listen is the explicit entry # with `ssl = true` in `vhostListen` — the nixos module's # auto-listen-generation only kicks in when `listen` is # empty, so the explicit listen wins and there's no # duplicate-listen risk. Empty otherwise so the http-only # path stays identical. vhostTls = lib.optionalAttrs cfg.selfSignedTls { addSSL = true; sslCertificate = tlsCert; sslCertificateKey = tlsKey; }; # Public-facing scheme + port-suffix for URLs the gateway # mints into responses (well-known JSON, the deprecated # `/matrix/*` 301 redirect, future absolute-URL needs). # When self-signed TLS is on, prefer `https://` (matrix- # spec compliance) — 443 elides the port. Otherwise fall back # to the plain-http listen with the bare port. See # `docs/gateway.md` ("Self-signed TLS"). Shared at this scope # Shared to avoid repetition. publicScheme = if cfg.selfSignedTls then "https" else "http"; publicPort = if cfg.selfSignedTls then cfg.httpsPort else cfg.port; publicPortDefault = if cfg.selfSignedTls then 443 else 80; publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}"; in { system.stateVersion = "26.05"; # Ensure a valid self-signed cert exists before nginx starts. # nginx `Requires=` this via `requiredBy`, so systemd refuses # to start nginx until the script succeeds. ALWAYS runs (no # ConditionPathExists) and is idempotent — that's necessary # to reconcile broken state left over from prior failed # boots (a 0700 dir from a stale UMask, a truncated cert # from an interrupted oneshot, etc.) which a guarded-on- # missing-cert script would silently skip and leave broken. # Cert covers the bare hive domain plus `*.${hyperhiveDomain}` # so the matrix + forge sub-domains are valid under the same # cert. See `docs/gateway.md` ("Self-signed TLS"). systemd.services.hive-gateway-self-signed-cert = lib.mkIf cfg.selfSignedTls { description = "Ensure self-signed TLS cert for hive-gateway"; wantedBy = [ "multi-user.target" ]; before = [ "nginx.service" ]; requiredBy = [ "nginx.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.openssl pkgs.coreutils ]; script = let subjectCN = if hyperhiveDomain != null then hyperhiveDomain else "hyperhive.local"; # `subjectAltName` covers the bare hive + the canonical # sub-domains. Includes a wildcard `*.${domain}` so any # future sub-domain we mint (per-agent UI under a # sub-domain, etc.) inherits the cert without a rebuild. sanLines = lib.concatStringsSep "," ( [ "DNS:${subjectCN}" ] ++ lib.optional (hyperhiveDomain != null) "DNS:*.${hyperhiveDomain}" ); in '' set -eu mkdir -p ${tlsDir} # 0755 on BOTH the cert dir and its parent so the # nginx user can traverse the full path. The parent # `/var/lib/hive-gateway` lands at 0700 by default # (systemd StateDirectory / mkdir umask depending on # which service created it first), which on its own # blocks traversal. Re-applied every boot in case a # prior run left a tighter mode behind. chmod 0755 ${builtins.dirOf tlsDir} chmod 0755 ${tlsDir} # Generate the cert when EITHER the cert or key is # missing/empty, OR the cert fails an openssl parse — # catches truncated / corrupt leftovers from a previous # interrupted run AND the "cert clean but key absent" # edge case (argus 🟡 on the first revision) which # otherwise tripped `chmod 0600 ${tlsKey}` below with # ENOENT under `set -eu`. The whole oneshot is safe to # re-run; a healthy cert+key pair is left alone. if [ ! -s ${tlsCert} ] || [ ! -s ${tlsKey} ] || ! openssl x509 -in ${tlsCert} -noout >/dev/null 2>&1; then echo "generating fresh self-signed cert at ${tlsCert}" openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \ -keyout ${tlsKey} \ -out ${tlsCert} \ -subj "/CN=${subjectCN}" \ -addext "subjectAltName=${sanLines}" fi # Key owned by root:nginx, mode 0640 so nginx-pre-start # (which runs `nginx -t` as the nginx user, not root) # can read it. A 0600 root:root key passes the master- # process load (master starts as root) but fails the # pre-start config test with `BIO_new_file() … # Permission denied`, blocking the unit from starting # at all. Cert is world-readable. chown root:nginx ${tlsKey} chmod 0640 ${tlsKey} chmod 0644 ${tlsCert} ''; }; # nginx reload is triggered from the HOST side by hive-c0re # via `systemd-run --machine=hive-gateway nginx -s reload` # after each agents.conf write. A path unit watching the # bind-mounted file inside the container was tried first # (A path unit inside the container was tried but IN_MOVED_TO from an atomic rename on the host # does not propagate across the nspawn mount-namespace boundary, # does not cross the mount-namespace boundary. Host-side trigger is the # correct approach. services.nginx = { enable = true; recommendedProxySettings = true; recommendedOptimisation = true; # Accept-header SPA fallback: 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 = { "_" = vhostTls // { listen = vhostListen; locations = # `/matrix/*` → 301 → `matrix./$1` # (fluffychat moved to sub-domain root; 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 target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; 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 clientBaseUrl = if matrixCfg.gatewayHost != null then "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}" else "${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; serverHostPort = if matrixCfg.gatewayHost != null then "${matrixCfg.gatewayHost}${publicPortSuffix}" 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}"}'; ''; }; } ) // # `/agent/` catch-all: hits when an operator # requests `/agent//...`. Without this the # request falls through to `/` (c0re dashboard) and # returns 404 with no useful context. Custom 404 # page instead. Per-agent `location /agent//` # blocks live in `/run/hive-state/agents.conf` — # nginx picks them up via the `include` in # `extraConfig` below; the catch-all only matches # names that aren't in that file (nginx longest- # prefix-match: `/agent/atlas/` beats `/agent/`). { "/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; ''; }; }; # Per-agent location blocks, generated at runtime by # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf # on the host. The bind-mount at /run/hive-state/ exposes # that file here. nginx parses `include` at config-load # time so a reload (triggered by c0re via systemd-run # after each agents.conf write) picks up new or removed # agents without a nixos-rebuild. nginx's longest-prefix- # match rule ensures `/agent//` from this file beats # the `/agent/` catch-all above. extraConfig = '' include /run/hive-state/agents.conf; ''; }; } // # Forge sub-domain vhost. `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}" = vhostTls // { listen = vhostListen; 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. `server_name = # matrixCfg.gatewayHost`. `/_matrix/*` → tuwunel (CORS *, # 50M body cap, 1h long-poll timeout). `/` serves # fluffychat 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}" = vhostTls // { listen = vhostListen; 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. "= /config.json" = { extraConfig = '' default_type application/json; return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; ''; }; } ) // lib.optionalAttrs (!matrixCfg.gui.enable) { "/" = { return = "404"; }; }; }; }; }; # Hive-internal DNS resolver, co-located in the # gateway container — single # front-door for both DNS and HTTP, saves a sibling # container. Listens on the bridge interface from # `services.hyperhive.network`; authoritative for the hive # domain + sub-domains, forwards everything else upstream. # No-op when `network.enable = false`. services.dnsmasq = lib.mkIf networkCfg.enable { enable = true; # Don't substitute the container's /etc/resolv.conf — # the gateway uses the host's resolver for its own # outbound traffic; dnsmasq is purely for incoming # queries from agent containers. resolveLocalQueries = false; settings = { # Bind only on the bridge interface (and lo for # health-checks). Outside hosts can't even see the # listener. interface = [ networkCfg.bridgeName "lo" ]; bind-interfaces = true; port = 53; # Don't read /etc/resolv.conf — we control upstream # explicitly to dodge dependency on the gateway # container's own resolver state. no-resolv = true; server = networkCfg.upstreamDns; # Hive authoritative records — answer queries for the # hive domain + its sub-domains with the bridge IP # (where nginx is reachable from container netns once # #14 lands; today it's the host loopback alias and # works in either shape). # # The forge / matrix entries are redundant in the # common case where `forge.domain` / # `matrix.gatewayHost` are sub-domains of # `hyperhive.domain` — dnsmasq's `//` rule # already matches sub-domains. # Kept explicit because operators can override either # to a cross-domain hostname (e.g. # `forge.domain = "git.example.com"`); listing them # explicitly keeps that case routed without needing # an extra config block. address = [ "/${hyperhiveDomain}/${networkCfg.bridgeIp}" ] ++ lib.optional ( (forgeCfg.enable or false) && (forgeCfg.behindGateway or false) ) "/${forgeCfg.domain}/${networkCfg.bridgeIp}" ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; }; }; }; }; networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ] ++ lib.optional cfg.selfSignedTls cfg.httpsPort; }; # `/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 ); }; }; }