265 lines
10 KiB
Rust
265 lines
10 KiB
Rust
//! Runtime nginx include-file generator for the gateway's per-agent
|
|
//! `/agent/<name>/` 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 `<path>.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/<name>/` 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<String> = [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"));
|
|
}
|
|
}
|