diff --git a/docs/gateway.md b/docs/gateway.md index 82eb6c59..e974ec2b 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -234,79 +234,6 @@ State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` and survives container restart / host reboot. To wipe, destroy the container. -## Per-agent static frontend split - -When `services.hyperhive.frontend` is configured, hive-c0re injects -`HIVE_AGENT_FRONTEND_DIR = "${cfg.frontend}/agent"` into its service -environment. The nginx include generator (`gateway_nginx::write`) reads -this variable and, when set, emits split location blocks per agent -instead of the legacy single-proxy block. - -**Location priority for `/agent//...`:** - -```nginx -# 1. Compiled assets — content-addressed nix store path, cache forever -location ^~ /agent//static/ { - alias /static/; - expires 1y; - add_header Cache-Control "public, immutable, max-age=31536000"; -} - -# 2. Static dist + proxy fallback for dynamic paths -location /agent// { - alias /; - try_files $uri $uri.html $uri/index.html @_dynamic; -} - -# 3. Proxy catchall — API, events, icon, send, login, … -location @_dynamic { - proxy_pass ; - proxy_set_header X-Forwarded-Prefix /agent/; - proxy_intercept_errors on; - error_page 502 503 504 = /__hive_agent_unreachable; - # … (full proxy header block) -} -``` - -**`try_files` resolution** (nginx applies the `alias` mapping before -checking file existence): - -| request | resolved | outcome | -| --- | --- | --- | -| `/agent/iris/` | `/index.html` | main agent page | -| `/agent/iris/stats` | `/stats.html` | stats page | -| `/agent/iris/screen` | `/screen.html` | screen page | -| `/agent/iris/static/app.js` | caught by `^~` block first | served with immutable cache | -| `/agent/iris/api/state` | no file match → `@iris_dynamic` | proxied to agent daemon | -| `/agent/iris/events/live` | no file match → `@iris_dynamic` | proxied (SSE) | - -Adding a new HTML page to the frontend dist (`dist/.html`) -automatically makes it reachable at `/agent//` — no -generator change needed. - -**Why `^~` for `/static/`**: the `^~` prefix gives this block higher -priority than the plain prefix `location /agent//`, so compiled -JS/CSS assets skip `try_files` entirely and get the immutable cache -headers. Nix store paths are content-addressed — the hash changes on -any content change — so `max-age=31536000` is safe. - -**Why nix store is reachable from the gateway container**: nspawn -containers bind-mount `/nix/store` read-only by default. The -`HIVE_AGENT_FRONTEND_DIR` path is a nix store path baked in at -hive-c0re build time — the same path is visible to both c0re (writing -`agents.conf`) and the gateway nginx (serving files from it). - -**Graceful degradation**: if `HIVE_AGENT_FRONTEND_DIR` is empty or -unset (e.g. a build that predates `cfg.frontend`), each agent gets the -legacy single-proxy block and all traffic is forwarded to the agent -daemon as before. - -**`extraFiles`**: per-agent `hyperhive.frontend.extraFiles` are in -`mergedDist`, not in the base `cfg.frontend` dist. They are not under -the nix-store `alias` path, so requests for them fall through -`try_files` to `@_dynamic` and are served by the agent daemon -as before. - ## Per-agent error pages `/agent//` requests hit two failure modes; both get static diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index 06cc2d4d..764545a8 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -40,18 +40,9 @@ const PROXY_HEADER_BLOCK: &str = " proxy_http_version 1.1; proxy_set_header X-Forwarded-Proto $scheme;"; /// Render the nginx include body for `names`. Manager is filtered out. -/// -/// When `frontend_dir` is `Some(path)` each agent gets split location -/// blocks: -/// - Exact HTML pages (`/`, `/stats`, `/screen`) → `alias` from the nix -/// store dist. These load even while the agent daemon is restarting. -/// - Static assets (`/static/`) → `alias` from the nix store with cache -/// headers. Served by nginx directly, no agent socket round-trip. -/// - Everything else (all `/api/*`, `/events/*`, `/screen/ws`, `/icon`, -/// `/send`, `/login/*`) → proxied to the agent daemon as before. -/// -/// When `frontend_dir` is `None` (legacy) each agent gets a single -/// `location /agent//` proxy block. +/// Each remaining agent gets a `location /agent//` block whose +/// `proxy_pass` targets the unix socket when the `.bound` marker +/// exists, or TCP loopback otherwise. /// /// Output is deterministic for the same (sorted) set of names + /// `.bound` state: no timestamps, no UUIDs. `BTreeMap` would give @@ -59,7 +50,7 @@ const PROXY_HEADER_BLOCK: &str = " proxy_http_version 1.1; /// they care about diff stability, but the gateway treats the blocks /// as unordered by nginx's longest-prefix-match rules so ordering only /// affects human readability. -fn render(names: &[String], frontend_dir: Option<&str>) -> String { +fn render(names: &[String]) -> String { let mut out = String::from( "# Generated by hive-c0re \u{2014} do not edit.\ \n# Refreshed on every topology change + when agents bind/drop their unix sockets.\ @@ -78,69 +69,18 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { } else { format!("http://127.0.0.1:{port}/") }; - - if let Some(frontend) = frontend_dir { - // Split mode: try to serve files from the nix-store dist first; - // fall through to the agent daemon for anything not found there. - // - // `frontend` is a nix store path injected at build time by hive-c0re.nix - // (`HIVE_AGENT_FRONTEND_DIR = "${cfg.frontend}/agent"`). Nix store paths - // are of the form `/nix/store/-` — only [a-z0-9/._-], never - // shell metacharacters — so interpolating directly into the nginx config - // string is safe. We do NOT accept user-controlled input here. - // - // Location priority for a request to /agent//...: - // ^~ /agent//static/ — highest priority; matches compiled assets. - // Nix store paths are content-addressed and immutable — cache forever. - // /agent// — try_files from dist, proxy fallback for the rest. - // - // try_files resolution (nginx applies the alias mapping before checking): - // $uri — exact file match (/static/app.js → static/app.js) - // $uri.html — bare-path .html fallback (/stats → stats.html) - // $uri/index.html — directory index (/ → index.html) - // @_dynamic — proxy catchall: api, events, icon, login, … - // - // Adding pages to the frontend dist works automatically — no generator - // change needed. Per-agent extraFiles (in mergedDist, not in the base - // dist path) still proxy to the agent daemon via @_dynamic. - let _ = write!( - out, - "\n# Compiled JS/CSS assets — content-addressed nix store path, cache forever\n\ - location ^~ /agent/{name}/static/ {{\n\ - \n alias {frontend}/static/;\n\ - \n expires 1y;\n\ - \n add_header Cache-Control \"public, immutable, max-age=31536000\";\n\ - }}\n\ - \n# Static dist + proxy fallback for dynamic paths\n\ - location /agent/{name}/ {{\n\ - \n alias {frontend}/;\n\ - \n try_files $uri $uri.html $uri/index.html @{name}_dynamic;\n\ - }}\n\ - \nlocation @{name}_dynamic {{\n\ - {PROXY_HEADER_BLOCK}\n\ - \n proxy_pass {upstream};\n\ - \n proxy_set_header X-Forwarded-Prefix /agent/{name};\n\ - \n proxy_buffering off;\n\ - \n proxy_read_timeout 1d;\n\ - \n proxy_intercept_errors on;\n\ - \n error_page 502 503 504 = /__hive_agent_unreachable;\n\ - }}\n", - ); - } else { - // Legacy mode: proxy everything to the agent daemon. - let _ = write!( - out, - "\nlocation /agent/{name}/ {{\n\ - {PROXY_HEADER_BLOCK}\n\ - \n proxy_pass {upstream};\n\ - \n proxy_set_header X-Forwarded-Prefix /agent/{name};\n\ - \n proxy_buffering off;\n\ - \n proxy_read_timeout 1d;\n\ - \n proxy_intercept_errors on;\n\ - \n error_page 502 503 504 = /__hive_agent_unreachable;\n\ - }}\n", - ); - } + let _ = write!( + out, + "\nlocation /agent/{name}/ {{\n\ + {PROXY_HEADER_BLOCK}\n\ + \n proxy_pass {upstream};\n\ + \n proxy_set_header X-Forwarded-Prefix /agent/{name};\n\ + \n proxy_buffering off;\n\ + \n proxy_read_timeout 1d;\n\ + \n proxy_intercept_errors on;\n\ + \n error_page 502 503 504 = /__hive_agent_unreachable;\n\ + }}\n", + ); } out } @@ -164,10 +104,7 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { /// housekeeping restart or the next manual reload; the host's agent /// topology has already been written correctly. pub fn write(names: &[String]) -> Result<()> { - let frontend_dir = std::env::var("HIVE_AGENT_FRONTEND_DIR") - .ok() - .filter(|s| !s.is_empty()); - let body = render(names, frontend_dir.as_deref()); + let body = render(names); let path = host_conf_path(); if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) { return Ok(()); @@ -236,11 +173,9 @@ fn reload_gateway_nginx() { mod tests { use super::*; - // ── legacy mode (frontend_dir = None) ────────────────────────────── - #[test] fn render_empty_is_header_only() { - let body = render(&[], None); + let body = render(&[]); assert!(body.starts_with("# Generated by hive-c0re")); // No location blocks when no agents. assert!(!body.contains("location")); @@ -252,7 +187,9 @@ mod tests { .iter() .map(|s| (*s).to_owned()) .collect(); - let body = render(&names, None); + // Use build_map_with so we don't need real .bound files. + // For render, we call it directly with names. + let body = render(&names); assert!(!body.contains(&format!("/agent/{MANAGER_NAME}/"))); assert!(body.contains("/agent/iris/")); } @@ -261,7 +198,7 @@ mod tests { fn render_tcp_upstream_when_no_bound_marker() { // No .bound file on disk → falls back to TCP loopback. let names = vec!["iris".to_owned()]; - let body = render(&names, None); + let body = render(&names); let port = lifecycle::agent_web_port("iris"); assert!( body.contains(&format!("proxy_pass http://127.0.0.1:{port}/")), @@ -272,7 +209,7 @@ mod tests { #[test] fn render_includes_proxy_headers() { let names = vec!["iris".to_owned()]; - let body = render(&names, None); + let body = render(&names); // All required proxy headers present (WebSocket upgrade + forwarded-for). assert!(body.contains("proxy_http_version 1.1")); assert!(body.contains("proxy_set_header Upgrade $http_upgrade")); @@ -282,7 +219,7 @@ mod tests { #[test] fn render_includes_forwarded_prefix() { let names = vec!["atlas".to_owned()]; - let body = render(&names, None); + let body = render(&names); assert!( body.contains("X-Forwarded-Prefix /agent/atlas"), "expected X-Forwarded-Prefix for atlas, got:\n{body}" @@ -292,7 +229,7 @@ mod tests { #[test] fn render_includes_error_pages() { let names = vec!["iris".to_owned()]; - let body = render(&names, None); + let body = render(&names); assert!(body.contains("proxy_intercept_errors on")); assert!(body.contains("__hive_agent_unreachable")); } @@ -300,95 +237,8 @@ mod tests { #[test] fn render_includes_buffering_and_timeout() { let names = vec!["iris".to_owned()]; - let body = render(&names, None); + let body = render(&names); assert!(body.contains("proxy_buffering off")); assert!(body.contains("proxy_read_timeout 1d")); } - - // ── split mode (frontend_dir = Some) ─────────────────────────────── - - const FAKE_FRONTEND: &str = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-frontend/agent"; - - #[test] - fn split_mode_has_immutable_cache_for_static_assets() { - let names = vec!["iris".to_owned()]; - let body = render(&names, Some(FAKE_FRONTEND)); - // ^~ prefix block for /static/ with immutable cache headers - assert!( - body.contains("location ^~ /agent/iris/static/ {"), - "expected ^~ static location, got:\n{body}" - ); - assert!(body.contains(&format!("alias {FAKE_FRONTEND}/static/;"))); - assert!(body.contains("expires 1y;")); - assert!(body.contains("immutable")); - } - - #[test] - fn split_mode_has_alias_and_try_files() { - let names = vec!["iris".to_owned()]; - let body = render(&names, Some(FAKE_FRONTEND)); - // Plain prefix location with alias + try_files - assert!( - body.contains("location /agent/iris/ {"), - "expected try_files location, got:\n{body}" - ); - assert!( - body.contains(&format!("alias {FAKE_FRONTEND}/;")), - "expected alias, got:\n{body}" - ); - // try_files handles exact file, bare-path .html, directory index - assert!( - body.contains("try_files $uri $uri.html $uri/index.html @iris_dynamic"), - "expected try_files with .html fallback, got:\n{body}" - ); - } - - #[test] - fn split_mode_has_named_proxy_location() { - let names = vec!["iris".to_owned()]; - let body = render(&names, Some(FAKE_FRONTEND)); - // Named location for dynamic proxy - assert!( - body.contains("location @iris_dynamic {"), - "expected named dynamic location, got:\n{body}" - ); - assert!(body.contains("proxy_pass")); - assert!(body.contains("proxy_intercept_errors on")); - assert!(body.contains("__hive_agent_unreachable")); - } - - #[test] - fn split_mode_filters_manager() { - let names: Vec = [MANAGER_NAME, "iris"] - .iter() - .map(|s| (*s).to_owned()) - .collect(); - let body = render(&names, Some(FAKE_FRONTEND)); - assert!(!body.contains(&format!("/agent/{MANAGER_NAME}/"))); - assert!(body.contains("/agent/iris/")); - } - - #[test] - fn split_mode_proxy_includes_forwarded_prefix() { - let names = vec!["atlas".to_owned()]; - let body = render(&names, Some(FAKE_FRONTEND)); - assert!( - body.contains("X-Forwarded-Prefix /agent/atlas"), - "expected X-Forwarded-Prefix for atlas in split mode, got:\n{body}" - ); - } - - #[test] - fn split_mode_named_location_uses_agent_name() { - // Named location must be unique per agent to avoid nginx conflicts - let names = vec!["damocles".to_owned(), "iris".to_owned()]; - let body = render(&names, Some(FAKE_FRONTEND)); - assert!(body.contains("@damocles_dynamic")); - assert!(body.contains("@iris_dynamic")); - // Each agent gets exactly one named dynamic location block. - let damocles_count = body.matches("@damocles_dynamic {").count(); - let iris_count = body.matches("@iris_dynamic {").count(); - assert_eq!(damocles_count, 1, "expected exactly one damocles_dynamic block"); - assert_eq!(iris_count, 1, "expected exactly one iris_dynamic block"); - } } diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 38a7f158..5bd9a618 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -302,14 +302,6 @@ in # serves this via `tower_http::ServeDir` for any path it doesn't # match against an API/action route. HIVE_STATIC_DIR = "${cfg.frontend}/dashboard"; - # 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 - # nix store directly; dynamic API paths still proxied to the - # agent daemon. The nix store is shared across nspawn - # containers, so this path is reachable from inside the - # gateway container's nginx. - HIVE_AGENT_FRONTEND_DIR = "${cfg.frontend}/agent"; # Path to the static runtime asset tree (branding + claude # prompts). `hive_sh4re::assets::*` reads paths underneath. # `forge.rs` reads the avatar PNGs from here on startup.