{ 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; # Dashboard SPA dist, static-served by nginx below. Read in OUTER scope so # `config` is the host's (inside the container block it'd be the container's). dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; # Self-signed TLS is the implicit floor: when neither an operator cert # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no # explicit toggle and no http-only mode — matrix discovery requires https, # so the gateway always terminates TLS. The deprecated `selfSignedTls` # option is a no-op kept only so existing configs eval (see warnings). useSelfSigned = cfg.tls.certDir == null && !cfg.tls.acme.enable; # 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 = { # The gateway is always run alongside hyperhive (it's the single nginx # in front of every surface and the only thing exposed to the outside); # there is no enable flag. An operator who wants their own reverse proxy # in front points it at the gateway's `port`. The gateway config below # is gated on the top-level `services.hyperhive.enable`. 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 = '' **DEPRECATED — ignored.** Self-signed TLS is now the implicit default: when neither `tls.certDir` nor `tls.acme.enable` is configured, the gateway generates and serves a hive-CA-signed leaf (see the `hive-tls` module). There is no explicit toggle and no http-only mode — matrix discovery hardcodes `https:///.well-known/matrix/client`, so the gateway always terminates TLS. This option is retained as a no-op so existing configs eval; setting it (to either value) warns and has no effect, and it will be removed in a future release. Use `tls.certDir` or `tls.acme` to override the self-signed default. See `docs/gateway.md` ("Self-signed TLS"). ''; }; useSelfSigned = lib.mkOption { type = lib.types.bool; internal = true; readOnly = true; default = useSelfSigned; defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable"; description = '' Read-only derived flag: `true` when the gateway serves the self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor `tls.acme.enable` is configured. Single source of truth for the self-signed condition; consumed by the `hive-tls` and `hive-ci` modules so the derivation isn't duplicated. Internal — not meant to be set by operators (use `tls.certDir` / `tls.acme` to override the self-signed default). ''; }; httpsPort = lib.mkOption { type = lib.types.port; default = 443; example = 8443; description = '' TCP port for the TLS-terminated vhosts. Default 443. The gateway always terminates TLS (self-signed is the implicit floor when no `tls.certDir` / ACME is configured), so this port is always active alongside the plain-http `port`. ''; }; tls = { certDir = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; example = lib.literalExpression ''"/var/lib/acme/example.com"''; description = '' Path to a host directory containing a TLS certificate and private key for nginx. When set, nginx listens on `httpsPort` and uses this cert, overriding the self-signed default — the auto-generated hive-CA-signed leaf is skipped entirely. The directory is bind-mounted read-only into the gateway container at `/run/hive-tls/`. nginx reads `/` and `/`. Default filenames (`cert.pem` / `key.pem`) match the output layout of nixpkgs's `security.acme` module. Typical ACME setup: ```nix security.acme.certs."example.com" = { ... }; services.hyperhive.gateway.tls.certDir = config.security.acme.certs."example.com".directory; ``` When using an external CA cert, peer hives can declare this hive in `services.hyperhive.swarm.peers` without `certFingerprint` — the standard CA bundle validates. Mutual exclusion with `tls.acme.enable` — set one or the other, not both. ''; }; certName = lib.mkOption { type = lib.types.str; default = "cert.pem"; description = '' Filename of the TLS certificate within `tls.certDir`. Defaults to `cert.pem` which matches nixpkgs's `security.acme` output. ''; }; keyName = lib.mkOption { type = lib.types.str; default = "key.pem"; description = '' Filename of the TLS private key within `tls.certDir`. Defaults to `key.pem` which matches nixpkgs's `security.acme` output. ''; }; acme = { enable = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Let nginx inside the gateway container obtain and renew TLS certificates automatically via ACME (Let's Encrypt). When enabled, each vhost calls out to Let's Encrypt using the HTTP-01 challenge on `port` (default 80) and stores certs inside the gateway container's persistent state dir. Requirements: - `services.hyperhive.domain` must be set and publicly DNS-resolvable to this host. - `services.hyperhive.gateway.openFirewall = true` so Let's Encrypt can reach `/.well-known/acme-challenge/`. - `tls.acme.email` must be set (ACME account contact). Mutual exclusion: `tls.certDir` set together with `tls.acme.enable = true` fails at eval — pick one TLS source. Typical setup: ```nix services.hyperhive.gateway = { openFirewall = true; tls.acme = { enable = true; email = "admin@example.com"; }; }; ``` After enabling, peer hives can omit `certFingerprint` in `swarm.peers` — Let's Encrypt certs are CA-trusted by default. ''; }; email = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "admin@example.com"; description = '' Email address for the ACME account registration with Let's Encrypt. Required when `tls.acme.enable = true`. Let's Encrypt sends expiry warnings to this address. ''; }; }; }; auth = { enable = lib.mkEnableOption '' HTTP basic auth on the gateway using an htpasswd file. When enabled, every request to the gateway's main vhost requires a valid username and password. nginx's built-in `auth_basic` module validates credentials against `/var/lib/hyperhive/gateway/gateway.htpasswd` on the host (exposed as `/run/hive-state/gateway.htpasswd` inside the container via the existing gateway state bind-mount). Off by default. Manage users with `hivectl gateway create-user`, `delete-user`, and `list-users` — see `hivectl gateway --help` for usage. The htpasswd file is created automatically when auth is enabled; add at least one user before enabling to avoid locking everyone out. ''; realm = lib.mkOption { type = lib.types.strMatching "[^\"$]*"; default = "hyperhive"; example = "my-hive"; description = '' HTTP Basic auth `realm` value sent in the `WWW-Authenticate` header when credentials are absent or rejected. Must not contain `"` or `$` (nginx string metacharacters). ''; }; }; hsts = { enable = lib.mkOption { type = lib.types.bool; default = false; description = '' Add `Strict-Transport-Security` to all gateway vhosts. Disabled by default: HSTS pins HTTPS in the browser's HSTS preload list; enabling it on a deployment that later loses TLS will lock browsers out until the max-age expires. Only enable this when you are certain TLS is permanent. The gateway always terminates TLS now (self-signed floor), so HSTS is always served over https when enabled — but mind the warning above: HSTS pins https in the browser, so only enable it when TLS is permanent for this deployment. ''; }; maxAge = lib.mkOption { type = lib.types.ints.positive; default = 31536000; example = 86400; description = '' Value for the `max-age` directive in seconds. Default: 31536000 (1 year), which is the value required for HSTS preload list submission. Use a shorter value (e.g. 86400) while testing so browsers forget the pin quickly. ''; }; includeSubDomains = lib.mkOption { type = lib.types.bool; default = true; description = '' Whether to include `includeSubDomains` in the HSTS header. Only disable this if the gateway host has sub-domains that intentionally serve plain HTTP. ''; }; }; }; config = lib.mkIf config.services.hyperhive.enable { assertions = [ { assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); message = '' services.hyperhive.gateway.tls.acme.enable = true and tls.certDir are mutually exclusive. Pick one TLS mode. ''; } { assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null; message = '' services.hyperhive.gateway.tls.acme.enable = true requires services.hyperhive.gateway.tls.acme.email to be set — Let's Encrypt needs a contact address for the ACME account. ''; } ]; # Deprecation surface for the removed `selfSignedTls` toggle. Self-signed # is now the implicit floor (used whenever neither `tls.certDir` nor # `tls.acme` is set), so the toggle no longer does anything. Warn only # when it's set to `false` — that's the case that previously meant # "http-only / external-only", which no longer exists; `= true` matches # the effective behaviour and stays silent to avoid noise. warnings = lib.optional (!cfg.selfSignedTls) '' services.hyperhive.gateway.selfSignedTls = false is deprecated and ignored — self-signed TLS is now the default whenever no other TLS source is configured, and there is no http-only mode. Remove the setting; configure `tls.certDir` or `tls.acme` to override the self-signed default. ''; # 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. Owned by `hive-core` (the # unprivileged coordinator user, privsep phase 2): c0re does the # `create_dir_all(/run/hive-agent/)` itself, so a root-owned # parent would EACCES on the very first agent create on a fresh host # (hive-priv only chowns the subdir afterwards, it doesn't make it). # /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 hive-core hive-core - -" "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" # Pre-create the htpasswd file so nginx can open it even before any # users have been added. An empty file causes all auth checks to # return 401 (no valid credentials), which is the correct no-users # behaviour. `f` = create-if-absent, never overwrite. "f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -" ]; 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; }; # Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME). # Only mounted when `tls.certDir` is set; when it is, the self-signed # floor is off (so the `/run/hive-ca` mount above is absent). nginx # reads cert + key from `/run/hive-tls/` and ``. bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { hostPath = cfg.tls.certDir; isReadOnly = true; }; # Self-signed mode: the host `hive-tls-ca` service generates a hive # CA + a leaf signed by it under `services.hyperhive.tls.stateDir`. # Bind-mount that dir read-only so the in-container import service # (below) can copy the leaf into nginx's state dir with the right # owner/mode. Source files: `gateway.pem` + `gateway-key.pem`. bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned { hostPath = config.services.hyperhive.tls.stateDir; isReadOnly = true; }; config = { pkgs, ... }: let tlsDir = "/var/lib/hive-gateway/tls"; # TLS cert + key paths inside the container. # - self-signed (default): imported hive-CA-signed leaf in the # persistent state dir. # - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls. tlsCert = if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; tlsKey = if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; # The gateway always terminates TLS now: self-signed is the # implicit floor (`useSelfSigned`) when neither `tls.certDir` nor # ACME is set, so there is no http-only mode. Kept as a named # binding for the vhost listen/ssl wiring below. hasTls = true; # Listen addresses every vhost shares. Plain http on `cfg.port` # always; `cfg.httpsPort` with TLS sits beside it when TLS is # active (any mode). See `docs/gateway.md` ("TLS modes"). vhostListen = [ { addr = "0.0.0.0"; port = cfg.port; } ] ++ lib.optional hasTls { addr = "0.0.0.0"; port = cfg.httpsPort; ssl = true; }; # nixos `services.nginx.virtualHosts.` ssl attrs merged # into each vhost. For ACME mode: `enableACME` + `addSSL` — # NixOS's ACME integration manages the cert lifecycle and sets # ssl_certificate automatically. For self-signed / certDir: # explicit cert paths. Empty for http-only. vhostTls = if cfg.tls.acme.enable then { addSSL = true; enableACME = true; } else lib.optionalAttrs hasTls { 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 TLS is active (self-signed OR operator cert), 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"). publicScheme = if hasTls then "https" else "http"; publicPort = if hasTls then cfg.httpsPort else cfg.port; publicPortDefault = if hasTls then 443 else 80; publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}"; # Security headers added at the server scope on every vhost. # nginx's add_header inheritance rule: a location that defines its # own add_header does NOT inherit the server-level ones. Any # location with its own add_header (e.g. CORS on /.well-known or # /_matrix/) must repeat the security headers explicitly — see those # locations below. HTML-serving and proxy locations that carry no # add_header of their own pick these up from the server scope # automatically. hstsDirectives = lib.concatStringsSep "; " ( [ "max-age=${toString cfg.hsts.maxAge}" ] ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" ); securityHeaders = '' add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} ''; # 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`. Empty attrset when the # forge isn't behind the gateway. forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) { "${forgeCfg.domain}" = vhostTls // { listen = vhostListen; extraConfig = securityHeaders; 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`. Empty attrset when matrix has no gateway host. matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { "${matrixCfg.gatewayHost}" = vhostTls // { listen = vhostListen; extraConfig = securityHeaders; 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; ${securityHeaders} 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; ''; }; } // { # FluffyChat boot-config pre-fill so the client's # `.well-known/matrix/client` lookup hits the # right delegation endpoint. `domain` is required, so # this is always present. "= /config.json" = { extraConfig = '' default_type application/json; return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; ''; }; } ) // lib.optionalAttrs (!matrixCfg.gui.enable) { "/" = { return = "404"; }; }; }; }; # `_` (default) server location groups, lifted out of the inline # `//`-chain so each conditional group reads on its own. Composed # into the `_` vhost's `locations` below alongside the still-inline # auth-401 group (a self-contained `lib.optionalAttrs`). # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link # shim during the fluffychat sub-domain move). See `docs/gateway.md`. matrixRedirectLocations = 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` when set; falls back to direct # `:`. CORS `*` per matrix spec. The `m.server` # port-8448 carve-out is documented inline. See `docs/gateway.md`. wellKnownLocations = lib.optionalAttrs matrixCfg.enable ( let clientBaseUrl = if matrixCfg.gatewayHost != null then "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}" else "${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; # `m.server` is NOT a URL: per the matrix server-server spec # (Resolving Server Names) a delegated host with NO port resolves # to the federation default 8448 (after the SRV check) — the # https-implies-443 rule does NOT apply here. So the port must be # explicit even when it's the HTTPS default; `publicPortSuffix` # (which drops :443) is right for the client base_url above but # wrong for federation delegation. Without this, peers federate to # :8448 (closed) while the endpoint actually lives on # the gateway's 443 vhost. See docs/gateway.md discovery flow. serverHostPort = if matrixCfg.gatewayHost != null then "${matrixCfg.gatewayHost}:${toString publicPort}" else "${hyperhiveDomain}:${toString matrixCfg.httpPort}"; in { "= /.well-known/matrix/client" = { extraConfig = '' default_type application/json; ${securityHeaders} 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 404 + the two internal error-page targets it # points at. Per-agent `location /agent//` blocks live in the # runtime-generated `/run/hive-state/agents.conf` (included via # `extraConfig` on the vhost); nginx longest-prefix-match makes a # real `/agent//` beat this catch-all. `internal` keeps the # error pages reachable only through nginx's error handling. agentLocations = { "/agent/" = { extraConfig = '' error_page 404 = /__hive_agent_not_found; return 404; ''; }; "= /__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; ''; }; }; # Shared auth block — separate locations don't inherit auth_basic, so # each dashboard location (`/`, `/api/`, `/webhook/`) needs it or that # surface is unauthed. dashboardAuth = lib.optionalString cfg.auth.enable '' auth_basic "${cfg.auth.realm}"; auth_basic_user_file /run/hive-state/gateway.htpasswd; # `=401` keeps the status 401 so the login dialog shows; the # internal page explains `hivectl gateway create-user`. error_page 401 =401 /__hive_auth_unauthorized; ''; # Dashboard: nginx static-serves the dist, c0re is API-only. Routing # is by PATH, never content-type. c0re serves exactly two prefixes — # `/api/` (all dashboard data + actions + the SSE streams) and # `/webhook/` (the knowledge webhook) — so those proxy to c0re and # everything else serves the dist with an SPA fallback to index.html. # The earlier `map $http_accept` Accept-header split made the SAME # url behave differently by content-type (e.g. `/api/state` fetched # with `Accept: text/html` wrongly got index.html); path routing is # deterministic. A new top-level c0re route prefix (beyond /api + # /webhook) would need a matching location added here. dashboardProxyLocation = { "/" = { root = dashboardDist; extraConfig = '' try_files $uri /index.html; ${dashboardAuth} ''; }; "/api/" = { proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; proxyWebsockets = true; extraConfig = '' # off + 1d keep the SSE streams (/api/dashboard/stream, # /api/build-logs/id/{id}/stream) live. proxy_buffering off; proxy_read_timeout 1d; ${dashboardAuth} ''; }; "/webhook/" = { proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; extraConfig = dashboardAuth; }; }; in { system.stateVersion = "26.05"; # ACME (Let's Encrypt) integration. nginx vhosts set # `enableACME = true` via `vhostTls`; this provides the # shared ACME config (acceptTerms + email). The gateway # container has shared host netns so outbound ACME requests # work without extra routing config. Certs are stored in the # container's persistent state (`ephemeral = false`). security.acme = lib.mkIf cfg.tls.acme.enable { acceptTerms = true; defaults.email = cfg.tls.acme.email; }; # Import the host-generated leaf cert before nginx starts. # The hive CA + gateway leaf are generated on the HOST by # `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only # at `/run/hive-ca`; this service copies the leaf into nginx's # state dir with the owner/mode nginx needs, rather than reading # the bind-mount directly (the host key is 0600 root:root and a # cross-namespace bind-mount can't be relaxed in place). nginx # `Requires=` this via `requiredBy`, so it refuses to start until # the copy succeeds. ALWAYS runs (no ConditionPathExists) and is # idempotent — necessary to reconcile broken state from prior # failed boots (a 0700 dir from a stale UMask, a truncated copy # from an interrupted oneshot, etc.). The leaf covers the bare # hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}` # so all sub-domains validate under the same cert + the hive CA. # See `docs/gateway.md` ("Self-signed TLS"). systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned { description = "Import host-generated TLS leaf for hive-gateway"; wantedBy = [ "multi-user.target" ]; before = [ "nginx.service" ]; requiredBy = [ "nginx.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "hive-gateway-self-signed-cert"; }; path = [ pkgs.coreutils ]; script = '' 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} # Copy the host leaf in. `install` writes atomically with the # target mode; run as root (container root == host root, # privateUsers=false) so the 0600 root:root host key is # readable. Key ends up root:nginx 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 load but # fails the pre-start config test with `BIO_new_file() … # Permission denied`, blocking the unit. Cert is world-read. install -m 0644 /run/hive-ca/gateway.pem ${tlsCert} install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey} ''; }; # nginx reload is triggered from the HOST side by hive-c0re # via `systemctl -M hive-gateway reload nginx` after each # agents.conf write — letting systemd resolve the nginx binary # path avoids exit-203 EXEC failures. A path unit watching the # bind-mounted file inside the container was tried first but # doesn't work: an IN_MOVED_TO from an atomic rename on the host # does not propagate across the nspawn mount-namespace boundary. # The host-side trigger is the correct approach. services.nginx = { enable = true; recommendedProxySettings = true; recommendedTlsSettings = true; recommendedGzipSettings = true; recommendedOptimisation = true; # Accept-header SPA map for the matrix GUI only (see docs/gateway.md # "SPA fallback"): text/html → index.html, else a sentinel so # try_files falls through to 404. The dashboard no longer uses an # Accept-header map — it routes by path (see dashboardProxyLocation). 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 = matrixRedirectLocations // wellKnownLocations // agentLocations // dashboardProxyLocation // lib.optionalAttrs cfg.auth.enable { # Internal-only target for the 401 error_page above. # `internal` prevents direct client access; `alias` serves # the pre-built HTML from the Nix store. "= /__hive_auth_unauthorized" = let page = pkgs.writeText "hive-gateway-unauthorized.html" '' unauthorized ◆ hyperhive

◆ unauthorized

This hive is protected by HTTP Basic auth. Valid credentials are required.

Operator: add a user with hivectl gateway create-user:

hivectl gateway create-user \
                          <username> --password-stdin

Then reload your browser and enter the credentials when prompted.

''; in { extraConfig = '' internal; alias ${page}; default_type text/html; ''; }; }; # 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 = securityHeaders + '' include /run/hive-state/agents.conf; ''; }; } // forgeVhost // matrixVhost; }; # 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 # per-agent netns isolation 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.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 # The gateway always terminates TLS now (self-signed floor), so # `httpsPort` is always opened alongside the plain-http `port`. 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 { "127.0.0.1" = lib.unique ( [ hyperhiveDomain ] ++ lib.optional (config.services.hyperhive.forge.behindGateway or false ) config.services.hyperhive.forge.domain ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost ); }; }; }