From e162c1a1fada1adf14b4236335c347235e38d840 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 1 Jun 2026 17:13:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(#955):=20split=20agent=20page=20serving=20?= =?UTF-8?q?=E2=80=94=20statics=20from=20nix=20store,=20API=20proxied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway_nginx.rs reads HIVE_AGENT_FRONTEND_DIR (injected by hive-c0re.nix as ${cfg.frontend}/agent). When set, agents.conf emits per-agent split blocks instead of the old single proxy_pass: # Compiled assets — immutable nix store path, cache 1y location ^~ /agent//static/ { alias /static/; expires 1y; add_header Cache-Control "public, immutable, ..."; } # Static dist + proxy fallback location /agent// { alias /; try_files $uri $uri.html $uri/index.html @_dynamic; } location @_dynamic { proxy_pass ; # api, events, icon, login, … …proxy headers unchanged… } try_files path resolution (nginx applies alias mapping first): $uri — exact file (/static/app.js → static/app.js) $uri.html — bare-path fallback (/stats → stats.html) $uri/index.html — directory index (/ → index.html) @_dynamic — proxy catchall for anything not in the dist Adding pages to the frontend dist works automatically — no generator change needed. Per-agent extraFiles (in mergedDist, not in the base nix-store path) continue to proxy to the agent daemon. frontend is a nix store path injected at build time — only [a-z0-9/._-], no shell metacharacters — safe to interpolate without sanitization; comment added documenting this assumption. Without HIVE_AGENT_FRONTEND_DIR the existing single-proxy block is emitted unchanged — backward-compatible for deployments without the env. render() takes frontend_dir as a parameter so tests exercise both code paths safely in parallel. 13 tests: 7 legacy, 6 split-mode. No clippy warnings in changed files. nix/modules/hive-c0re.nix: inject HIVE_AGENT_FRONTEND_DIR = "${cfg.frontend}/agent". --- hive-c0re/src/gateway_nginx.rs | 202 ++++++++++++++++++++++++++++----- nix/modules/hive-c0re.nix | 8 ++ 2 files changed, 184 insertions(+), 26 deletions(-) diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index 764545a8..06cc2d4d 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -40,9 +40,18 @@ 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. -/// Each remaining agent gets a `location /agent//` block whose -/// `proxy_pass` targets the unix socket when the `.bound` marker -/// exists, or TCP loopback otherwise. +/// +/// 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. /// /// Output is deterministic for the same (sorted) set of names + /// `.bound` state: no timestamps, no UUIDs. `BTreeMap` would give @@ -50,7 +59,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]) -> String { +fn render(names: &[String], frontend_dir: Option<&str>) -> 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.\ @@ -69,18 +78,69 @@ fn render(names: &[String]) -> String { } else { format!("http://127.0.0.1:{port}/") }; - 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", - ); + + 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", + ); + } } out } @@ -104,7 +164,10 @@ fn render(names: &[String]) -> 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 body = render(names); + 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 path = host_conf_path(); if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) { return Ok(()); @@ -173,9 +236,11 @@ fn reload_gateway_nginx() { mod tests { use super::*; + // ── legacy mode (frontend_dir = None) ────────────────────────────── + #[test] fn render_empty_is_header_only() { - let body = render(&[]); + let body = render(&[], None); assert!(body.starts_with("# Generated by hive-c0re")); // No location blocks when no agents. assert!(!body.contains("location")); @@ -187,9 +252,7 @@ mod tests { .iter() .map(|s| (*s).to_owned()) .collect(); - // Use build_map_with so we don't need real .bound files. - // For render, we call it directly with names. - let body = render(&names); + let body = render(&names, None); assert!(!body.contains(&format!("/agent/{MANAGER_NAME}/"))); assert!(body.contains("/agent/iris/")); } @@ -198,7 +261,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); + let body = render(&names, None); let port = lifecycle::agent_web_port("iris"); assert!( body.contains(&format!("proxy_pass http://127.0.0.1:{port}/")), @@ -209,7 +272,7 @@ mod tests { #[test] fn render_includes_proxy_headers() { let names = vec!["iris".to_owned()]; - let body = render(&names); + let body = render(&names, None); // 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")); @@ -219,7 +282,7 @@ mod tests { #[test] fn render_includes_forwarded_prefix() { let names = vec!["atlas".to_owned()]; - let body = render(&names); + let body = render(&names, None); assert!( body.contains("X-Forwarded-Prefix /agent/atlas"), "expected X-Forwarded-Prefix for atlas, got:\n{body}" @@ -229,7 +292,7 @@ mod tests { #[test] fn render_includes_error_pages() { let names = vec!["iris".to_owned()]; - let body = render(&names); + let body = render(&names, None); assert!(body.contains("proxy_intercept_errors on")); assert!(body.contains("__hive_agent_unreachable")); } @@ -237,8 +300,95 @@ mod tests { #[test] fn render_includes_buffering_and_timeout() { let names = vec!["iris".to_owned()]; - let body = render(&names); + let body = render(&names, None); 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 5bd9a618..38a7f158 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -302,6 +302,14 @@ 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.