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:
parent
1caf978004
commit
4db8a8cd3d
6 changed files with 68 additions and 58 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1374,7 +1374,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Single nginx in front of every hyperhive web surface. Container `hive-gateway`,
|
|||
|
||||
| URL | vhost | upstream | source |
|
||||
| --- | --- | --- | --- |
|
||||
| `<hive>/` | `_` (catch-all) | hive-c0re dashboard (`7000`) | always |
|
||||
| `<hive>/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`) + API/SSE/actions → hive-c0re (`7000`) via `@c0re` | always |
|
||||
| `<hive>/agent/<name>/` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) |
|
||||
| `<hive>/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` |
|
||||
| `<hive>/matrix/` (deprecated) | `_` | 301 → `matrix.<hive>/` | `matrix.gui.enable` |
|
||||
|
|
@ -31,12 +31,17 @@ Federation peers fetch `.well-known/matrix/server` → `{"m.server":"matrix.<hiv
|
|||
|
||||
## SPA fallback (Accept-header pattern)
|
||||
|
||||
The `<hive>` catch-all and the `matrix.<hive>` vhost both serve a flutter SPA (per-agent UI, fluffychat). Two requirements collide:
|
||||
The `<hive>` catch-all (operator dashboard), the per-agent UIs, and the `matrix.<hive>` vhost all serve a flutter/SPA bundle. Two requirements collide:
|
||||
|
||||
- hard-refresh on a sub-route must serve `index.html` (SPA's client-side router takes over after JS bootstrap)
|
||||
- missing assets must surface as 404, not as HTML with wrong content-type
|
||||
- a non-navigation request that isn't an on-disk asset must NOT get HTML with the wrong content-type
|
||||
|
||||
Solution: an `nginx http`-context `map $http_accept $matrix_spa_target { ... }` keyed on the request's Accept header. Browser navigations (`Accept: text/html,...`) get `index.html`; asset fetches (`Accept: image/*`, `*/*`, etc.) get a sentinel nonexistent path → `try_files` falls through to `=404`. No extension allowlist, no `if` block, no regex heuristics.
|
||||
Solution: an `nginx http`-context `map $http_accept $<name>_spa_target { ... }` keyed on the request's Accept header. Browser navigations (`Accept: text/html,...`) get `index.html`; everything else (`Accept: image/*`, `*/*`, `application/json`, `text/event-stream`, …) gets a sentinel nonexistent path, so `try_files $uri $<name>_spa_target <final>` falls through to `<final>`. No extension allowlist, no `if` block, no regex heuristics.
|
||||
|
||||
The two vhosts differ only in `<final>`:
|
||||
|
||||
- **matrix / per-agent static assets** → `=404` (a missing asset is just missing).
|
||||
- **dashboard** → `@c0re` (a named location that reverse-proxies to hive-c0re `7000`). The dashboard's dynamic surface — every `/api/*`, the two SSE streams, the ~20 bare action/mutation routes (`/approve/{id}`, `/kill/{name}`, `/op-send`, …), and `/webhook/knowledge` — is all `Accept != text/html`, so it lands on `@c0re` automatically, **without enumerating a single backend prefix**. This is what lets the gateway static-serve the dashboard dist (from the `servedFrontend` nix-store path) while hive-c0re stays API-only — so a frontend-only change no longer rebuilds + restarts the core daemon. `@c0re` carries `proxy_buffering off` + a 1d read timeout (for the SSE streams) and a duplicated `auth_basic` block (named locations don't inherit it). Follow-up #1846 will move every backend route under `/api/`, collapsing this to a trivial `/api/* → c0re, else static` split.
|
||||
|
||||
## Local dev (`localHostsEntry`)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -23,7 +23,6 @@ use hive_sh4re::Approval;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::{Stream, StreamExt};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::container_view::{ContainerView, claude_has_session};
|
||||
use crate::coordinator::Coordinator;
|
||||
|
|
@ -65,19 +64,8 @@ struct AppState {
|
|||
obscure the route map for no readability gain"
|
||||
)]
|
||||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context(
|
||||
"HIVE_STATIC_DIR env var not set — point it at the bundled \
|
||||
dashboard dist (see services.hive-c0re.frontend in nix)",
|
||||
)?;
|
||||
if !static_dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||
static_dir.display()
|
||||
);
|
||||
}
|
||||
tracing::info!(static_dir = %static_dir.display(), "dashboard static dir resolved");
|
||||
// API-only: the gateway static-serves the dashboard dist and proxies
|
||||
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
|
||||
let app = Router::new()
|
||||
.route("/api/state", get(api_state))
|
||||
.route("/approve/{id}", post(approvals::post_approve))
|
||||
|
|
@ -219,11 +207,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.route("/api/meta-update", post(post_meta_update))
|
||||
.route("/api/dashboard/stream", get(dashboard_stream))
|
||||
.route("/api/dashboard/history", get(dashboard_history))
|
||||
// Anything not matched by the dynamic routes above falls
|
||||
// through to the bundled dashboard dist (GET / →
|
||||
// dist/index.html, /favicon.svg → dist/favicon.svg,
|
||||
// /static/dashboard.css → dist/static/dashboard.css, etc.).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
// No static fallback — the gateway owns the dist; unmatched paths 404.
|
||||
.with_state(AppState { coord });
|
||||
// Binds loopback-only; external access via gateway.
|
||||
// Rationale: docs/gateway.md::Firewall posture.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue