diff --git a/Cargo.lock b/Cargo.lock index 1ae04e0f..d483ac59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1374,7 +1374,6 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", - "tower-http", "tracing", "tracing-subscriber", ] diff --git a/docs/gateway.md b/docs/gateway.md index 6315b961..8bee3097 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -6,7 +6,7 @@ Single nginx in front of every hyperhive web surface. Container `hive-gateway`, | URL | vhost | upstream | source | | --- | --- | --- | --- | -| `/` | `_` (catch-all) | hive-c0re dashboard (`7000`) | always | +| `/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`) + API/SSE/actions → hive-c0re (`7000`) via `@c0re` | always | | `/agent//` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) | | `/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` | | `/matrix/` (deprecated) | `_` | 301 → `matrix./` | `matrix.gui.enable` | @@ -31,12 +31,17 @@ Federation peers fetch `.well-known/matrix/server` → `{"m.server":"matrix.` catch-all and the `matrix.` vhost both serve a flutter SPA (per-agent UI, fluffychat). Two requirements collide: +The `` catch-all (operator dashboard), the per-agent UIs, and the `matrix.` 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 $_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 $_spa_target ` falls through to ``. No extension allowlist, no `if` block, no regex heuristics. + +The two vhosts differ only in ``: + +- **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`) diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 3cbadf1a..f57c3a4d 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -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 diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 9aea8f5a..c3e9aa91 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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) -> 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) -> 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. diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 1a364204..eeaf1c4b 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -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 ""; + 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 diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 77a45bc2..e88c32ad 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -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";