//! Runtime nginx include-file generator for the gateway's per-agent //! `/agent//` location blocks. //! //! Writes `/var/lib/hyperhive/gateway/agents.conf` on every topology change. //! The gateway container bind-mounts `/var/lib/hyperhive/gateway/` (NOT the //! whole parent dir) at `/run/hive-state/`; nginx includes //! `/run/hive-state/agents.conf`. After each write, c0re triggers //! `nginx -s reload` inside the gateway container via //! `systemd-run --machine=hive-gateway` from the host — no `nixos-rebuild //! switch` needed when agents start, stop, or flip `useUnixSocket`. //! (A path unit inside the container was tried first but `IN_MOVED_TO` //! from the atomic rename does not cross the nspawn mount-namespace //! boundary — see `docs/gateway.md` for the failure analysis.) //! //! Upstream selection mirrors `agent_sockets::build_map`: an agent //! gets a UDS upstream when its `.bound` marker exists (harness has //! bound the unix socket); otherwise falls back to the deterministic //! TCP port from `lifecycle::agent_web_port`. Proxy headers are //! emitted in full so the generated file is self-contained nginx //! config — no dependency on which `recommendedProxySettings` knobs //! the host config has on. //! //! `write()` is idempotent: if the rendered body equals what's already //! on disk, the rename is skipped and the path unit doesn't fire. //! Same atomic `.tmp` + `rename()` shape as `agent_ports` / //! `agent_sockets` — a crashing c0re process never leaves a partial //! file the gateway's nginx would fail to parse. use anyhow::{Context, Result}; use std::fmt::Write as _; use std::path::PathBuf; use crate::agent_sockets; use crate::lifecycle::{self, MANAGER_NAME}; const HOST_CONF_PATH: &str = "/var/lib/hyperhive/gateway/agents.conf"; /// Host-side path where c0re writes the generated nginx include file. /// The gateway container bind-mounts `/var/lib/hyperhive/gateway/` /// (not the whole parent dir) at `/run/hive-state/` so nginx inside /// can read it at `/run/hive-state/agents.conf`. Subdirectory scoping /// avoids exposing the rest of `/var/lib/hyperhive/` (which may contain /// forge tokens or other credentials) to the gateway container. #[must_use] pub fn host_conf_path() -> PathBuf { PathBuf::from(HOST_CONF_PATH) } /// Nginx proxy headers present in every per-agent location block. /// `$connection_upgrade` is defined in the http context by the NixOS /// nginx module when `recommendedProxySettings = true` (which the /// gateway always sets). `X-Forwarded-Prefix` is per-location (set /// below in the render loop). Kept as a standalone const so it's /// visible to tests without repeating the text. const PROXY_HEADER_BLOCK: &str = " proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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. /// /// Output is deterministic for the same (sorted) set of names + /// `.bound` state: no timestamps, no UUIDs. `BTreeMap` would give /// alphabetical order; we rely on the caller to pass sorted names if /// 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 { 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.\ \n# Gateway reloads nginx automatically on each update (systemd path unit).\n", ); for name in names { if name == MANAGER_NAME { continue; } let port = lifecycle::agent_web_port(name); let upstream = if agent_sockets::ready_marker_for(name).exists() { format!( "http://unix:{}:/", agent_sockets::socket_path_for(name).display() ) } 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", ); } out } /// Atomically write the nginx include file for `names` to /// [`host_conf_path()`]. Skips the write + reload when the rendered /// body matches what's already on disk (idempotent; avoids spurious /// gateway reloads on a quiet tick). /// /// After a successful write, triggers an nginx reload inside the /// gateway container from the HOST side via /// `systemd-run --machine=hive-gateway nginx -s reload`. This is /// intentionally host-side rather than relying on a systemd path unit /// inside the container watching the bind-mounted file: `IN_MOVED_TO` /// (fired by the atomic rename) does not reliably propagate across the /// nspawn mount-namespace boundary, so the path-unit approach was /// silently broken (see `docs/gateway.md` for the failure analysis). /// /// The `systemd-run` call is best-effort — a failed reload is logged /// but not fatal. nginx will pick up the new include on its next /// 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 path = host_conf_path(); if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) { return Ok(()); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create dir {}", parent.display()))?; } let tmp = path.with_extension("conf.tmp"); std::fs::write(&tmp, &body) .with_context(|| format!("write tmp {}", tmp.display()))?; std::fs::rename(&tmp, &path).with_context(|| { format!( "rename {} -> {} (atomic publish)", tmp.display(), path.display() ) })?; // Trigger nginx reload from the host. Ignore errors — a failed // reload is recoverable (nginx keeps serving the previous config). reload_gateway_nginx(); Ok(()) } /// Send `nginx -s reload` to the gateway container via systemd-run. /// Runs non-interactively in a transient scope so it doesn't block /// c0re's polling loop. Best-effort: errors are logged, not bubbled. fn reload_gateway_nginx() { // `--machine=hive-gateway` targets the container by its nspawn // machine name (same as the nixos-container name). `--quiet` // suppresses the transient unit name echo. `--` separates // systemd-run args from the command. let status = std::process::Command::new("systemd-run") .args([ "--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload", ]) .status(); match status { Ok(s) if s.success() => { // systemd-run accepted the request; nginx reload runs // asynchronously inside the container and may still fail // silently, but that's acceptable given the best-effort contract. tracing::debug!("systemd-run accepted gateway nginx reload request"); } Ok(s) => { tracing::warn!( exit_code = ?s.code(), "gateway nginx reload exited non-zero; will pick up on next restart" ); } Err(e) => { tracing::warn!( error = %e, "failed to invoke systemd-run for gateway nginx reload" ); } } } #[cfg(test)] mod tests { use super::*; #[test] fn render_empty_is_header_only() { let body = render(&[]); assert!(body.starts_with("# Generated by hive-c0re")); // No location blocks when no agents. assert!(!body.contains("location")); } #[test] fn render_filters_manager() { let names: Vec = [MANAGER_NAME, "iris"] .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); assert!(!body.contains(&format!("/agent/{MANAGER_NAME}/"))); assert!(body.contains("/agent/iris/")); } #[test] 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 port = lifecycle::agent_web_port("iris"); assert!( body.contains(&format!("proxy_pass http://127.0.0.1:{port}/")), "expected TCP upstream for iris, got:\n{body}" ); } #[test] fn render_includes_proxy_headers() { let names = vec!["iris".to_owned()]; 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")); assert!(body.contains("proxy_set_header X-Forwarded-For")); } #[test] fn render_includes_forwarded_prefix() { let names = vec!["atlas".to_owned()]; let body = render(&names); assert!( body.contains("X-Forwarded-Prefix /agent/atlas"), "expected X-Forwarded-Prefix for atlas, got:\n{body}" ); } #[test] fn render_includes_error_pages() { let names = vec!["iris".to_owned()]; let body = render(&names); assert!(body.contains("proxy_intercept_errors on")); assert!(body.contains("__hive_agent_unreachable")); } #[test] fn render_includes_buffering_and_timeout() { let names = vec!["iris".to_owned()]; let body = render(&names); assert!(body.contains("proxy_buffering off")); assert!(body.contains("proxy_read_timeout 1d")); } }