feat(#1843): static-serve the dashboard via the gateway, hive-c0re API-only

nginx proxied `<hive>/` straight to hive-c0re:7000, and hive-c0re served the
dashboard dist itself via `tower_http::ServeDir` (from `HIVE_STATIC_DIR` baked
into its service env). So a frontend-only change rebuilt the hive-c0re unit and
restarted the core daemon — every operator session dropped its SSE stream for a
pure CSS/JS change.

The gateway nginx now static-serves the dashboard dist directly; hive-c0re's
dashboard router is API-only. The split uses the Accept-header SPA fallback (the
same `map $http_accept` pattern the matrix/agent vhosts already use), so no
backend prefix has to be enumerated: a browser navigation (Accept: text/html)
whose path is not an on-disk asset gets the SPA index.html; everything else
(every /api route, the bare action/mutation routes, the two SSE streams, the
knowledge webhook — all Accept != text/html) falls through `try_files` to the
`@c0re` named location and is reverse-proxied to hive-c0re. A new c0re route
needs no gateway change.

- hive-c0re.nix: expose the themed dist as a new internal read-only option
  `services.hyperhive.c0re.servedFrontend`; drop `HIVE_STATIC_DIR` from the
  service env (the router no longer serves files).
- hive-gateway.nix: read that option in host-module scope (dashboardDist),
  static-serve `dashboard/` with the Accept-header `try_files ... @c0re` split;
  `@c0re` carries `proxy_buffering off` + a 1d read timeout for the SSE streams
  and a duplicated auth_basic block (named locations do not inherit it). The
  dashboard map is unconditional; the matrix map stays gated on the matrix GUI.
- dashboard.rs: drop the ServeDir fallback + the HIVE_STATIC_DIR resolution; the
  router 404s unmatched paths (the gateway only proxies non-static requests).
- hive-c0re/Cargo.toml: drop the now-unused tower-http dependency.
- docs/gateway.md: document the dashboard static split + the `@c0re` fall-through.

The store path is reachable inside the gateway nspawn container (shared
/nix/store), mirroring how HIVE_AGENT_FRONTEND_DIR already exposes the per-agent
UIs. The gateway and c0re changes must land together (atomic cutover) or the
dashboard 404s — this needs a watched gateway + c0re rebuild.
This commit is contained in:
atlas 2026-06-22 00:23:53 +02:00 committed by mara
commit 4db8a8cd3d
6 changed files with 68 additions and 58 deletions

View file

@ -368,6 +368,19 @@ in
endpoints) is the source of truth for any replacement.
'';
};
servedFrontend = lib.mkOption {
type = lib.types.package;
internal = true;
readOnly = true;
default = servedFrontend;
defaultText = lib.literalExpression "<stylix-themed overlay of `frontend`>";
description = ''
Internal, read-only: `frontend` re-themed with the active stylix
palette (or `frontend` verbatim when unthemed); has `dashboard/`
and `agent/`. Exposed so `hive-gateway.nix` can static-serve
`dashboard/` as an nginx root instead of proxying to hive-c0re.
'';
};
assets = lib.mkOption {
type = lib.types.package;
default = hyperhiveAssets pkgs.stdenv.hostPlatform.system;
@ -721,10 +734,8 @@ in
# the writable StateDirectory.
HOME = "/var/lib/hyperhive";
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
# Path to the dashboard static dist. The hive-c0re axum router
# serves this via `tower_http::ServeDir` for any path it doesn't
# match against an API/action route.
HIVE_STATIC_DIR = "${servedFrontend}/dashboard";
# No HIVE_STATIC_DIR: the gateway static-serves the dashboard dist
# now (see hive-gateway.nix); this router is API-only.
# Path to the base agent frontend dist. hive-c0re's
# gateway_nginx.rs uses this to generate split location
# blocks in agents.conf — static HTML/CSS/JS served from the

View file

@ -11,6 +11,10 @@ let
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
@ -743,35 +747,38 @@ in
};
};
# Everything else proxies to hive-c0re. Upgrade headers stay set so
# SSE (`/dashboard/stream`, `/events/stream`) + websocket
# (`/screen/ws`) endpoints keep working transparently. When auth is
# enabled, nginx's built-in `auth_basic` validates against the
# bind-mounted htpasswd; the `=401` error_page points at the
# internal unauthorized page (the auth-only location below).
# Shared auth block — named locations don't inherit auth_basic, so
# both `/` and `@c0re` need it or the proxied 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. The
# Accept-header map splits without enumerating routes — html
# navigations → SPA index.html, everything else (API/SSE/actions/
# webhook) → @c0re. Replaces the old `location / { proxy_pass c0re }`
# that made c0re ServeDir the dist (and restart on every frontend
# change). New c0re routes need no gateway change.
dashboardProxyLocation = {
"/" = {
root = dashboardDist;
extraConfig = ''
try_files $uri $dashboard_spa_target @c0re;
${dashboardAuth}
'';
};
"@c0re" = {
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
proxyWebsockets = true;
extraConfig = ''
# off + 1d keep the SSE streams live.
proxy_buffering off;
proxy_read_timeout 1d;
${lib.optionalString cfg.auth.enable ''
auth_basic "${cfg.auth.realm}";
# htpasswd file lives in the gateway state dir,
# already bind-mounted read-only at /run/hive-state/.
# Host path: /var/lib/hyperhive/gateway/gateway.htpasswd
auth_basic_user_file /run/hive-state/gateway.htpasswd;
# Serve a custom page when credentials are missing or wrong.
# `=401` forces the final status to remain 401 so browsers
# still present the login dialog on first visit; users who
# dismiss the dialog see a page explaining how to add users
# with `hivectl gateway create-user`.
# The exact-match location below beats `location /` in nginx's
# prefix ordering, so the internal subrequest does not loop back
# through auth_basic.
error_page 401 =401 /__hive_auth_unauthorized;
''}
${dashboardAuth}
'';
};
};
@ -855,12 +862,17 @@ in
recommendedTlsSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
# Accept-header SPA fallback: navigations
# (`Accept: text/html,...`) fall to index.html, asset
# fetches (Accept *anything else*) fall to a sentinel
# nonexistent path → `try_files` returns 404. Pattern
# detailed in `docs/gateway.md` ("SPA fallback").
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
# Accept-header SPA maps (see docs/gateway.md "SPA fallback"):
# text/html → index.html, else a sentinel so try_files falls
# through (dashboard → @c0re, matrix → 404). Dashboard map is
# unconditional; matrix map only with the matrix GUI.
appendHttpConfig = ''
map $http_accept $dashboard_spa_target {
default "/__dashboard_no_html_fallback";
"~*text/html" "/index.html";
}
''
+ lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/index.html";