# nginx virtual-host tree for the gateway: the `_` default server # (dashboard, per-agent routing, matrix discovery), the forge, matrix # and authelia sub-domain vhosts, and the Accept-header SPA map for the # matrix GUI. Pure function — called from ./default.nix with the # outer-scope config values as arguments; returns # `{ virtualHosts, appendHttpConfig }`. { lib, cfg, # services.hyperhive.gateway forgeCfg, matrixCfg, autheliaCfg, # services.hyperhive.swarm.authelia uiCfg, # services.hyperhive.swarm.ui controllerCfg, # services.hyperhive.swarm.controller hyperhiveDomain, dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option }: let # The kit's three members, bound to the names this file already used. # Read through `gwLib` (the published option) rather than importing # ./vhost-lib.nix directly: a service module declaring its own vhost # gets the same object, so "the forge vhost listens where the gateway # listens" is true by construction and not by review. inherit (gwLib) securityHeaders; vhostListen = gwLib.listen; vhostTlsFor = gwLib.tlsFor; # 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}"; # 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}" = (vhostTlsFor forgeCfg.domain) // { 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; ''; }; }; }; # Authelia sub-domain vhost. `server_name = authelia.domain`, all of # `/` → authelia. Empty attrset unless THIS host runs the container: # every hive knows the swarm's `authelia.url`, but only the one # serving it may claim the name — a client hive declaring this vhost # would answer for a service it does not run. # # ⚠️ The server name must be exactly `autheliaCfg.domain`, not a # near-miss: authelia validates `authelia_url ⊂ session cookie domain` # at STARTUP, so a mismatch is a container that refuses to boot rather # than a login that misbehaves. # # ⚠️ And deliberately NO `dashboardAuth` here. That block is the # gateway's `auth_basic`; applying it to the SSO provider would put # the login page behind the login mechanism it exists to replace. autheliaVhost = lib.optionalAttrs autheliaCfg.enable { "${autheliaCfg.domain}" = (vhostTlsFor autheliaCfg.domain) // { listen = vhostListen; extraConfig = securityHeaders; locations."/" = { proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; # authelia decides by the ORIGINAL request, not by the hop it # sees — the login redirect and the session cookie's domain # both derive from these. Without them every request looks # like it arrived at 127.0.0.1 over plain http. proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # A dead upstream here means "not bootstrapped" far more often # than "misconfigured proxy", and a bare 502 says the opposite. proxy_intercept_errors on; error_page 502 503 504 = /__hive_sso_unavailable; ''; }; locations."= /__hive_sso_unavailable" = { extraConfig = '' internal; alias ${errorPages.ssoUnavailable}; default_type text/html; ''; }; }; }; # Swarm UI vhost — the swarm's front page, on the swarm apex, and the # FIRST `auth_request` anywhere in this gateway (everything else is # `auth_basic` + htpasswd). # # ⚠️ `auth_request` answers "is there a session", not "is this an # operator". The operator-only part is authelia's `access_control` # rule (../swarm-authelia.nix) requiring `group:operators` — agents # are getting authelia accounts of their own, and without that rule a # session alone would open this page. # # ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest # that wrongly denies takes the whole UI away. That is the reason the # redirect target and the header set below are copied from a measured # source rather than from an example. # ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a hardening # preference, the only way this page works at all. authelia answers the # auth subrequest for an `http://` target with **400**, and nginx's # `auth_request` only understands 2xx/401/403, so a plain-http visit # dies as "auth request unexpected status: 400" with no hint a login # exists. `vhostListen` binds :80, so without this the door is open on # a port the lock cannot work on. Serving forge or matrix over http is # merely insecure rather than broken, so they keep `addSSL` and the # asymmetry stays local to the vhost whose correctness depends on the # scheme. `removeAttrs` because nixos asserts on a vhost declaring both. # Shared with every swarm-UI-vhost location below (`/`, `/api/`, # `/api/docs/`) — auth_request does not inherit across sibling # locations, so each one that should be operator-gated repeats this # verbatim rather than only the page itself being protected while its # own API and API docs are reachable unauthenticated. swarmAuthRequest = '' auth_request /__hive_authelia; # Captured BEFORE the error_page jump: inside the 401 handler # `$request_uri` is the internal one, so building the return # link there sends the operator back to the auth subrequest # instead of the page they asked for. auth_request_set $target_url $scheme://$http_host$request_uri; error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url; ''; swarmUiVhost = lib.optionalAttrs uiCfg.enable { "${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // { forceSSL = true; listen = vhostListen; extraConfig = securityHeaders; locations = { "/" = { root = "${uiCfg.package}"; extraConfig = '' ${swarmAuthRequest} # SPA: any path the bundle routes client-side is served the # entry document rather than a 404 from the filesystem. try_files $uri /index.html; ''; }; # swarm-controller's whole HTTP surface, including the live # `/api/openapi.json` spec — proxied untouched (no URI segment # after the socket path, same "pass the request through as-is" # shape as the per-hive dashboard's own `/api/` proxy) so the # path swarm-controller registered a route at is the path # nginx forwards, no prefix-stripping to keep in sync by hand. "/api/" = { proxyPass = "http://unix:${controllerCfg.socketPath}:"; extraConfig = swarmAuthRequest; }; # Swagger UI: same "nginx hosts the themed dist straight from # the store, only /api/openapi.json is dynamic" shape as the # per-hive gateway's `swaggerUiLocations` — see that block's # comment for why core-equivalent (here, swarm-controller) # does not also mount its own copy. "= /api/docs" = { extraConfig = '' return 301 /api/docs/; ''; }; "/api/docs/" = { alias = "${swaggerUiTheme}/"; extraConfig = '' index index.html; ${swarmAuthRequest} ''; }; # The subrequest itself. `auth-request` is the implementation # name authelia exposes under `/api/authz/`; `/api/verify` is the # LEGACY path every older example shows. # # Header set measured against the pinned binary (4.39.20), not # copied: `X-Original-URL` and `X-Original-Method` are present as # literals and are what this implementation reads — # `X-Forwarded-Uri` does not appear in it at all, so sending it # would look like configuration and be dead weight. "= /__hive_authelia" = { proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; extraConfig = '' internal; # A subrequest carries no body, and forwarding one here makes # authelia read a payload it will never use. proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ''; }; }; }; }; # 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}" = (vhostTlsFor matrixCfg.gatewayHost) // { 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 `/var/lib/hive-gateway/conf/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 /var/lib/hive-gateway/conf/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 three prefixes — # `/api/` (all dashboard data + actions + the SSE streams), `/webhook/` # (knowledge push + config-PR approval triggers, HMAC-guarded), and # `/health/` (liveness + readiness) — 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 + /health) 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}"; }; "/health/" = { # No dashboardAuth here either, for a different reason than # /webhook/: an external uptime monitor generally can't do # interactive HTTP Basic. The endpoints themselves are scoped to # status + warning kind/message (see hive-c0re/src/dashboard/ # health.rs) — no tokens, no agent detail — so exposing them # unauthenticated isn't a new secret surface. proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; }; }; # Swagger UI: nginx hosts the FULL themed dist (`swaggerUiTheme` — # vendored Swagger UI + our overlay, see nix/packages/swagger-ui- # dist.nix + swagger-ui-theme.nix) straight from the store, with NO # fallback to hive-c0re at all — per the operator's shape: "core # should not need the swagger ui at all if it is hosted in gateway" # / "core only hosts the json". Only `/api/openapi.json` # (the live-generated spec `index.html` fetches; not under this # prefix) still proxies to c0re via "/api/" below — that's the one # thing that has to stay dynamic. # # Prefix location, not exact-match: wins over "/api/" on plain # prefix length (no ordering/`=` needed), and now needs to cover # every file in the tree (bundle.js, maps, favicons, …), not just # our 2 override files — hive-c0re no longer serves any of this as # a fallback once its own `utoipa-swagger-ui` mount is removed. # `= /api/docs` (no trailing slash) issues the same redirect # `utoipa-swagger-ui`'s router used to: that mount is going away # too, so nginx has to own it now, or the H0M3 hub's own `/api/docs` # link (no trailing slash) would 404 once hive-c0re drops the route. swaggerUiLocations = { "= /api/docs" = { extraConfig = '' return 301 /api/docs/; ''; }; "/api/docs/" = { alias = "${swaggerUiTheme}/"; extraConfig = '' index index.html; ${dashboardAuth} ''; }; }; 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 = { # `tlsFor "_"`, not a separate binding: the default server is a # vhost named `_`, and a name that is not a swarm service domain # (`_` never is) resolves to the hive's own leaf — which is what # this vhost has always served. "_" = (vhostTlsFor "_") // { listen = vhostListen; locations = matrixRedirectLocations // wellKnownLocations // agentLocations // dashboardProxyLocation // swaggerUiLocations // 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/hive-gateway/conf/agents.conf # on the host — the same machine nginx runs on. nginx parses # `include` at config-load time so a reload (triggered by c0re # 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 /var/lib/hive-gateway/conf/agents.conf; ''; }; } // forgeVhost // autheliaVhost // matrixVhost // swarmUiVhost; }