feat(#955): split agent page serving — statics from nix store, API proxied

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/<name>/static/ {
      alias <frontend>/static/;
      expires 1y; add_header Cache-Control "public, immutable, ...";
  }
  # Static dist + proxy fallback
  location /agent/<name>/ {
      alias <frontend>/;
      try_files $uri $uri.html $uri/index.html @<name>_dynamic;
  }
  location @<name>_dynamic {
      proxy_pass <upstream>;   # 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)
  @<name>_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".
This commit is contained in:
iris 2026-06-01 17:13:02 +02:00 committed by mara
commit e162c1a1fa
2 changed files with 184 additions and 26 deletions

View file

@ -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/<name>/` 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/<name>/` 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/<hash>-<name>` — 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/<name>/...:
// ^~ /agent/<name>/static/ — highest priority; matches compiled assets.
// Nix store paths are content-addressed and immutable — cache forever.
// /agent/<name>/ — 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)
// @<name>_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 @<name>_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<String> = [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");
}
}

View file

@ -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.