# nginx virtual-host tree for the gateway container: the `_` default # server (dashboard, per-agent routing, matrix discovery), the forge # and matrix sub-domain vhosts, and the Accept-header SPA map for the # matrix GUI. Pure function — called from ./default.nix inside the # container config with the outer-scope config values as arguments; # returns `{ virtualHosts, appendHttpConfig }`. { lib, cfg, # services.hyperhive.gateway forgeCfg, matrixCfg, hyperhiveDomain, dashboardDist, errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized } tlsCert, tlsKey, }: let # The gateway always terminates TLS: self-signed is the implicit # floor when neither `tls.certDir` nor ACME is set, so there is no # http-only mode. Listen addresses every vhost shares — plain http # on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md` # ("TLS modes"). vhostListen = [ { addr = "0.0.0.0"; port = cfg.port; } { 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. vhostTls = if cfg.tls.acme.enable then { addSSL = true; enableACME = true; } else { 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): # always `https://` (matrix-spec compliance) — the canonical # 443 elides the port. See `docs/gateway.md` ("Self-signed TLS"). publicScheme = "https"; publicPort = cfg.httpsPort; publicPortSuffix = if publicPort == 443 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"; }; }; }; }; # `/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 ${errorPages.notFound}; default_type text/html; ''; }; "= /__hive_agent_unreachable" = { extraConfig = '' internal; alias ${errorPages.unreachable}; default_type text/html; ''; }; }; # Shared auth block — separate locations don't inherit auth_basic, so # each dashboard location (`/`, `/api/`) needs it or that surface is # unauthed. `/webhook/` is intentionally excluded: Forgejo cannot # send HTTP Basic credentials with webhook deliveries, and the HMAC # secret (`X-Hub-Signature-256`) protects those endpoints instead. 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/` (knowledge push + config-PR approval triggers, HMAC- # guarded) — so those proxy to c0re and everything else serves the # dist with an SPA fallback to index.html. Path routing is # deterministic where an Accept-header split would make the SAME url # behave differently by content-type (e.g. `/api/state` fetched with # `Accept: text/html` wrongly getting index.html). A new top-level # c0re route prefix (beyond /api + /webhook) needs 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/" = { # No dashboardAuth here: Forgejo cannot send HTTP Basic credentials # with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth # for these endpoints; hive-c0re verifies it in the handler. proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; }; }; in { # 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 doesn't use 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" = { extraConfig = '' internal; alias ${errorPages.unauthorized}; 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; }