# The hive's gateway surface, declared under `services.hyperhive.gateway.vhosts` # like every service's: the dashboard's vhost (`swarm.dashboardDomain`), which # carries the dashboard behind authelia, per-agent routing and the swagger UI; # and the vhost named for the hive domain, which redirects everything to the # dashboard's name. Matrix discovery on the hive domain is added by # ../glue-matrix-hive-discovery.nix. { lib, config, ... }: let hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; inherit (hyperhiveCfg.swarm) dashboardDomain; autheliaDomain = hyperhiveCfg.swarm.authelia.domain; inherit (gatewayCfg.lib) errorPages; # Dashboard SPA dist, static-served by nginx. dashboardDist = "${hyperhiveCfg.c0re.servedFrontend}/dashboard"; # The canonical 443 elides the port; see `docs/networking/gateway.md` # ("Self-signed TLS"). publicPortSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}"; # `/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; ''; }; }; # Repeated verbatim by every operator-gated location (`/`, `/api/`, # `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling # locations, so a location without it is reachable with no session. # # ⚠️ `auth_request` answers "is there a session", not "is this an # operator". authelia's `operatorsOnly` rule for this name # (../swarm-authelia.nix) is what refuses an agent's own account. dashboardAuth = '' auth_request /__hive_authelia; # Captured BEFORE the error_page jump: inside the 401 handler # `$request_uri` is the internal one. auth_request_set $target_url $scheme://$http_host$request_uri; error_page 401 =302 https://${autheliaDomain}/?rd=$target_url; ''; # 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 # `/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 + /health) needs # a matching location added here. dashboardProxyLocation = { "/" = { root = dashboardDist; extraConfig = '' try_files $uri /index.html; ${dashboardAuth} ''; }; "/api/" = { proxyPass = "http://${gatewayCfg.upstreamHost}:${toString gatewayCfg.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} ''; }; "/health/" = { # No dashboardAuth here: an external uptime monitor can't do an # interactive login. 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://${gatewayCfg.upstreamHost}:${toString gatewayCfg.upstreamPort}"; }; }; # Swagger UI: nginx hosts the FULL themed dist (`gateway.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: core hosts only the JSON. Only # `/api/openapi.json` (the live-generated spec `index.html` fetches; # not under this prefix) proxies to c0re via "/api/" above — 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 covers every file in # the tree (bundle.js, maps, favicons, …), not just our 2 override # files — hive-c0re serves none of this as a fallback. # `= /api/docs` (no trailing slash) redirects to `/api/docs/`: nginx # owns that redirect because hive-c0re has no route for it, and the # dashboard's own `/api/docs` links (no trailing slash) would 404 # without it. swaggerUiLocations = { "= /api/docs" = { extraConfig = '' return 301 /api/docs/; ''; }; "/api/docs/" = { alias = "${gatewayCfg.swaggerUiTheme}/"; extraConfig = '' index index.html; ${dashboardAuth} ''; }; }; # The subrequest `dashboardAuth` makes, the same as the swarm UI's # (../swarm-ui.nix), which documents the header set. autheliaLocation = { "= /__hive_authelia" = { proxyPass = "https://${autheliaDomain}/api/authz/auth-request"; # nixpkgs appends its OWN `Host $host` after extraConfig, which would # override verifiedProxyTo's — see the comment on verifiedProxyTo in # ../hive-gateway/vhost-lib.nix. recommendedProxySettings = false; extraConfig = '' internal; ${gatewayCfg.lib.verifiedProxyTo autheliaDomain} 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; ''; }; }; in { # Gated on the gateway too: a hive may run with the gateway off, and a # vhost declared then fails the eval (../hive-gateway/declared-vhosts.nix). config = lib.mkIf (hyperhiveCfg.deploy.hive-controller.enable && gatewayCfg.enable && dashboardDomain != null) { # The dashboard's name, for agents on the bridge and (with # `localHostsEntry`) the host's own browser. services.hyperhive.gateway.localNames = [ dashboardDomain ]; services.hyperhive.gateway.vhosts = { # The dashboard, reachable by its own NAME under the swarm domain, # where authelia's session cookie reaches. The hive leaf carrying # this name exists only where a hive runs. # # The agent routes live here and not on the hive domain because the # agent page calls the dashboard's `/api/` on its own origin # (frontend/packages/agent/src/lib/dashboardBase.ts), and the # dashboard links to them by path. They carry no `auth_request`. # # `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers # the subrequest for an `http://` target with 400, which # `auth_request` cannot read. ${dashboardDomain} = { forceSSL = true; locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation; # 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 = '' include /var/lib/hive-gateway/conf/agents.conf; ''; }; # The hive domain's own vhost: a redirect of every path to the # dashboard's name, so a bookmarked dashboard or agent link still # lands. ${hyperhiveCfg.domain} = { locations."/".extraConfig = '' return 301 https://${dashboardDomain}${publicPortSuffix}$request_uri; ''; }; }; }; }