# nginx virtual-host tree for the **hive's own** gateway surface: the `_` # default server, which answers unmatched `Host` with a bare 444, and the # vhost named for the hive domain, which carries the dashboard, per-agent # routing, matrix discovery, the swagger UI and the legacy # `/matrix/*` redirect. # # Scope: a swarm service declares its own vhost and dns name in its own # module, built from the kit this file also consumes # (`services.hyperhive.gateway.lib`, ./vhost-lib.nix). That keeps a # service's gateway surface next to the service, and keeps this file to # the surface the hive itself serves. # # Pure function — called from ./default.nix with the outer-scope config # values as arguments; returns `{ virtualHosts }`. { lib, cfg, # services.hyperhive.gateway matrixCfg, 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}"; # `/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 { virtualHosts = { # The catch-all, and now *only* a catch-all: anything whose `Host` # matches no vhost gets an immediate 444 (close without a response) # rather than being served the hive's dashboard. # # `_` is the idiomatic spelling because it is not a legal hostname, # so it can never match a request by name — it serves traffic solely # by being `default_server`. # # ⚠️ It still needs TLS attrs. It listens on the https port, so a # client connecting by IP completes a TLS handshake *before* nginx # can look at `Host` and reject it; with no cert the vhost fails to # load. The certificate will not match what such a client asked for # — that is unavoidable and correct: nothing can present a valid # cert for a name the operator never issued one for. # # `mkDefault` per the operator: an operator with their own # `default = true` vhost must be able to win without fighting # priorities. The assertion in ./default.nix catches the case where # they add one *without* turning this off, which nginx would # otherwise only report at runtime as a failed config test. "_" = (vhostTlsFor "_") // { listen = vhostListen; default = lib.mkDefault true; extraConfig = '' return 444; ''; }; # The hive's own surface, now reachable by NAME. This used to be # served by the `_` vhost above: no vhost was named for the hive # domain, so every dashboard and agent-UI request matched the # default server instead (confirmed against 24h of nginx's access # log — `server: _` on requests whose Host *was* the hive domain). # Naming it is what lets the catch-all start rejecting. ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { 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; ''; }; }; }