From 0456206e5213b17f5c0aca0fb46115694b15b80b Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 4 Jul 2026 22:01:25 +0200 Subject: [PATCH 1/3] fix(#2245): drop TCP fallback from gateway nginx upstream Agents run in private netns (always-on isolation). The TCP fallback to 127.0.0.1: was unreachable from the gateway's host netns regardless of whether the per-agent socket marker existed. Remove the conditional entirely: always use the unix socket path. If the socket is not yet bound, nginx returns 502 which is already handled by the error_page 502 503 504 = /__hive_agent_unreachable directive in every location block. Also removes the unused lifecycle::agent_web_port call and the now-misleading '.bound state' mention from the render doc comment. --- hive-c0re/src/gateway_nginx.rs | 51 +++++++++++++++++----------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index e84f7a22..c6e709d0 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -1,7 +1,7 @@ //! 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. -//! UDS vs TCP upstream selection, reload trigger (`systemd-run +//! UDS upstream selection, reload trigger (`systemd-run //! --machine=hive-gateway`), and idempotency: //! `docs/gateway.md::Per-agent unix-socket upstream`. @@ -14,7 +14,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::priv_client; use crate::agent_sockets; -use crate::lifecycle; /// Set when `write` publishes a new agents.conf; cleared when /// `reload_gateway_nginx` submits the reload command successfully. @@ -74,12 +73,11 @@ const PROXY_HEADER_BLOCK: &str = " proxy_http_version 1.1; /// 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 -/// 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. +/// Output is deterministic for the same (sorted) set of names: +/// 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], frontend_dir: Option<&str>) -> String { let mut out = String::from( "# Generated by hive-c0re \u{2014} do not edit.\ @@ -87,24 +85,20 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { \n# Reload triggered by hive-c0re via systemd-run --machine=hive-gateway.\n", ); for name in names { - let port = lifecycle::agent_web_port(name); // Two upstream forms because named locations (split mode's // `@_dynamic`) forbid a URI part on `proxy_pass`. The // legacy prefix-location path keeps the trailing `/` so nginx // strips `/agent//` automatically; the named-location // path strips the prefix via `rewrite` and uses a bare upstream. - let (upstream_prefix, upstream_bare) = if agent_sockets::ready_marker_for(name).exists() { - let sock = agent_sockets::socket_path_for(name).display().to_string(); - ( - format!("http://unix:{sock}:/"), - format!("http://unix:{sock}:"), - ) - } else { - ( - format!("http://127.0.0.1:{port}/"), - format!("http://127.0.0.1:{port}"), - ) - }; + // + // Always use the unix socket path. The old TCP fallback + // (`127.0.0.1:`) was removed: agents run in private netns + // (always-on isolation) so the host loopback is unreachable from + // the gateway's netns. When the socket is not yet bound, nginx + // returns 502 which is handled by the error_page directive below. + let sock = agent_sockets::socket_path_for(name).display().to_string(); + let upstream_prefix = format!("http://unix:{sock}:/"); + let upstream_bare = format!("http://unix:{sock}:"); if let Some(frontend) = frontend_dir { // Split mode: try to serve files from the nix-store dist first; @@ -303,14 +297,19 @@ mod tests { } #[test] - fn render_tcp_upstream_when_no_bound_marker() { - // No .bound file on disk → falls back to TCP loopback. + fn render_uds_upstream_unconditional() { + // TCP fallback removed — always UDS regardless of socket marker. let names = vec!["iris".to_owned()]; let body = render(&names, None); - let port = lifecycle::agent_web_port("iris"); + // UDS form present assert!( - body.contains(&format!("proxy_pass http://127.0.0.1:{port}/")), - "expected TCP upstream for iris, got:\n{body}" + body.contains("proxy_pass http://unix:"), + "expected UDS upstream for iris, got:\n{body}" + ); + // No TCP loopback fallback + assert!( + !body.contains("127.0.0.1"), + "unexpected TCP loopback in output:\n{body}" ); } From 32e4c687b8a83dd2ff5a69012d5cfe121d07881f Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 4 Jul 2026 22:16:06 +0200 Subject: [PATCH 2/3] docs(gateway): remove stale TCP loopback fallback section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TCP fallback was removed from gateway_nginx.rs in the previous commit. Update docs/gateway.md to match: - Step 4: remove 'TCP loopback for agents not yet rebuilt' — always UDS now - Remove the 'TCP loopback fallback' subsection entirely --- docs/gateway.md | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/docs/gateway.md b/docs/gateway.md index 952c6cce..29b3cd09 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -103,10 +103,10 @@ now set unconditionally for every agent. The mechanism: (Legacy name `.bound` also accepted during the transition window.) 4. **Gateway side**. `gateway_nginx::write` generates `/var/lib/hyperhive/gateway/agents.conf` — a plain nginx include - file with one `location /agent//` block per agent. UDS - upstream (`http://unix:/run/hive-agent//web.sock:/`) when - `hyperhive-socket-bound` marker present; TCP loopback for agents - that haven't yet been rebuilt under the new config. + file with one `location /agent//` block per agent. Always + a UDS upstream (`http://unix:/run/hive-agent//web.sock:/`); + if the socket is not yet bound, nginx returns 502 caught by the + `error_page 502 503 504 = /__hive_agent_unreachable` directive. The gateway container bind-mounts `/var/lib/hyperhive/gateway/` at `/run/hive-state/`; nginx includes `/run/hive-state/agents.conf`. After each write, c0re triggers the appropriate nginx action inside @@ -127,17 +127,6 @@ idempotent — skips the rename when content is unchanged. Failed reloads are retried automatically on subsequent poll ticks via `gateway_nginx::reload_if_pending`. -## TCP loopback fallback - -While an agent's unix-socket marker is absent, the gateway routes its -`/agent//` traffic to a TCP loopback upstream in `agents.conf`. The -port is derived on the fly from `lifecycle::agent_web_port(name)` — a pure -FNV-1a hash of the name, reproducible from the name alone, no name -special-cased (so no on-disk port map is needed). Once the agent binds its -unix socket — every agent does, via `HIVE_WEB_SOCKET` — the gateway -switches to the socket upstream from `agent-sockets.json`. The root agent's -UI is routed at `/agent/root/`. - `agents.conf` uses atomic `.tmp` + `rename()` writes so a crashing c0re process never leaves a partial or unparseable file behind. From 2ddabdc410d42dcff5c2e1b2329a2d8afee34a5e Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 4 Jul 2026 22:20:01 +0200 Subject: [PATCH 3/3] fixup! fix(#2245): drop TCP fallback from gateway nginx upstream --- hive-c0re/src/gateway_nginx.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index c6e709d0..673d70a2 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -90,12 +90,8 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { // legacy prefix-location path keeps the trailing `/` so nginx // strips `/agent//` automatically; the named-location // path strips the prefix via `rewrite` and uses a bare upstream. - // - // Always use the unix socket path. The old TCP fallback - // (`127.0.0.1:`) was removed: agents run in private netns - // (always-on isolation) so the host loopback is unreachable from - // the gateway's netns. When the socket is not yet bound, nginx - // returns 502 which is handled by the error_page directive below. + // When the socket is not yet bound, nginx returns 502, caught by + // the error_page directive below. let sock = agent_sockets::socket_path_for(name).display().to_string(); let upstream_prefix = format!("http://unix:{sock}:/"); let upstream_bare = format!("http://unix:{sock}:"); @@ -298,7 +294,6 @@ mod tests { #[test] fn render_uds_upstream_unconditional() { - // TCP fallback removed — always UDS regardless of socket marker. let names = vec!["iris".to_owned()]; let body = render(&names, None); // UDS form present