Compare commits

..
3 changed files with 78 additions and 41 deletions

View file

@ -1,8 +1,8 @@
//! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to //! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to
//! `agent_ports.rs`; same atomic `<path>.tmp` + `rename()` shape so //! `agent_ports.rs`; same atomic `<path>.tmp` + `rename()` shape so
//! the gateway's nginx worker never reads a partial file. Includes //! the gateway's nginx worker never reads a partial file. Manager
//! manager and sub-agents so the gateway can route //! excluded from the map (manager UI routes via the dashboard
//! `/agent/<name>/` for all containers with a bound unix socket. //! upstream, not per-agent `/agent/<name>/`).
//! //!
//! Full mechanism — per-agent subdir bind-mount, `hyperhive-socket-bound` //! Full mechanism — per-agent subdir bind-mount, `hyperhive-socket-bound`
//! marker gate, gateway UDS upstream, transition vs `agent-ports.json`, //! marker gate, gateway UDS upstream, transition vs `agent-ports.json`,
@ -67,9 +67,14 @@ pub fn socket_path_for(name: &str) -> PathBuf {
} }
/// Compute the agent-socket map for the given logical agent names. /// Compute the agent-socket map for the given logical agent names.
/// Includes manager and sub-agents. Filters by `READY_MARKER` /// Sub-agents only — manager is filtered out at the call boundary
/// presence: only agents whose harness has actually bound the unix /// for the same reason it's filtered from `agent_ports::build_map`
/// socket appear in the map. Without this, the gateway would /// (manager UI is routed via the c0re dashboard upstream, not via
/// `/agent/<name>/`).
///
/// Also filters by `READY_MARKER` presence: only agents whose
/// harness has actually bound the unix socket (and dropped the
/// marker) appear in the map. Without this, the gateway would
/// `proxy_pass` to a non-existent socket for every sub-agent that /// `proxy_pass` to a non-existent socket for every sub-agent that
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`. /// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
/// ///
@ -100,6 +105,7 @@ where
{ {
names names
.iter() .iter()
.filter(|n| n.as_str() != MANAGER_NAME)
.filter(|n| is_ready(n)) .filter(|n| is_ready(n))
.map(|n| (n.clone(), socket_path_for(n))) .map(|n| (n.clone(), socket_path_for(n)))
.collect() .collect()
@ -235,13 +241,18 @@ mod tests {
} }
#[test] #[test]
fn build_map_includes_manager() { fn build_map_filters_manager() {
// Use `MANAGER_NAME` in the input so the assert actually
// exercises the filter path — a literal `"hm1nd"` would pass
// trivially if the constant ever changed and the filter
// silently became a no-op. All-ready predicate bypasses the
// marker check so we exercise the manager filter in isolation.
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"] let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
.iter() .iter()
.map(|s| (*s).to_owned()) .map(|s| (*s).to_owned())
.collect(); .collect();
let map = build_map_with(&names, |_| true); let map = build_map_with(&names, |_| true);
assert!(map.contains_key(MANAGER_NAME)); assert!(!map.contains_key(MANAGER_NAME));
assert!(map.contains_key("iris")); assert!(map.contains_key("iris"));
assert!(map.contains_key("argus")); assert!(map.contains_key("argus"));
} }
@ -332,4 +343,3 @@ mod tests {
assert!(body.contains("\"/run/hive-agent/iris/web.sock\"")); assert!(body.contains("\"/run/hive-agent/iris/web.sock\""));
} }
} }

View file

@ -1174,31 +1174,60 @@ fn set_nspawn_flags(
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?; std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
} // Per-agent socket subdir. Bind-mounts `/run/hive-agent/<name>/`
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the // into the container at the same path so the harness's
// container so the harness can bind `web.sock` there and the host-side // `HIVE_WEB_SOCKET` bind has a stable location both sides can
// gateway sees it. Subdir bind (not socket file) keeps the inode // see. Sub-agents only — the manager's UI is served at `/`
// visible after the harness unlinks a stale socket on rebind. // via the c0re dashboard upstream, not via `/agent/<name>/`,
// Applies to manager and sub-agents alike. // so it never needs the per-agent socket dir.
//
// Bind-mounting the SUBDIR (not the socket file) is mandatory:
// the harness's `bind_unix` helper unlinks any stale socket
// before calling `bind(2)`, and a file bind-mount drops its
// host-side anchor on unlink — the rebind would land in the
// container's private namespace, invisible to the gateway.
// Dir bind keeps the same dir inode visible on both sides, so
// the new `web.sock` shows up on the host the moment the
// harness binds it.
//
// Per-agent dir (rather than a shared `/run/hive-agent/`
// mount) means the agent's container only sees its own
// subdir — never siblings'. See `docs/gateway.md::Per-agent
// unix-socket upstream`.
//
// mkdir source defensively: nspawn refuses to start when the
// bind source is missing, and on a fresh host `/run/hive-agent/`
// doesn't exist yet.
let socket_dir = crate::agent_sockets::agent_dir_for(agent_name); let socket_dir = crate::agent_sockets::agent_dir_for(agent_name);
std::fs::create_dir_all(&socket_dir) std::fs::create_dir_all(&socket_dir)
.with_context(|| format!("create {}", socket_dir.display()))?; .with_context(|| format!("create {}", socket_dir.display()))?;
// Chown to the agent user so the non-root harness can bind(2) here. // chown to the in-container agent user so its harness can
// Falls back to 0777 on first spawn when uid lookup returns None // `bind(2)` web.sock here. `create_dir_all` lands the dir at
// (container /etc/passwd not yet rendered). // 0755 root:root and the harness runs as the non-root agent
// user; without this chown the bind fails with EACCES, the
// gateway's agent-sockets.json stays empty, and the agent
// looks unreachable. uid resolution can return None on the
// very first spawn (container's /etc/passwd not yet rendered)
// — fall back to a permissive 0777 in that window so the
// first harness boot still binds. nspawn shares uids with the
// host (no PrivateUsers), so the in-container uid is the same
// uid we chown to here.
if let Some((uid, gid)) = agent_uid_gid(agent_name) { if let Some((uid, gid)) = agent_uid_gid(agent_name) {
std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid)) std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid))
.with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?; .with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?;
} else { } else {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777)) std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777))
.with_context(|| format!("chmod 0777 {}", socket_dir.display()))?; .with_context(|| {
format!("chmod 0777 {} (uid lookup failed)", socket_dir.display())
})?;
} }
let _ = write!( let _ = write!(
binds, binds,
" --bind={socket_dir}:{socket_dir}", " --bind={socket_dir}:{socket_dir}",
socket_dir = socket_dir.display(), socket_dir = socket_dir.display(),
); );
}
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original let mut lines: Vec<String> = original
.lines() .lines()
@ -1440,4 +1469,3 @@ mod tests {
); );
} }
} }

View file

@ -95,9 +95,12 @@ in
4. eventually drop this option once every agent is on unix and 4. eventually drop this option once every agent is on unix and
the TCP fallback is removed from the harness. the TCP fallback is removed from the harness.
Sub-agents only: the manager always has `HIVE_WEB_SOCKET` set Sub-agent-only by design: the manager's UI serves at `/` via
unconditionally in the `isManager` env block, so this toggle the c0re dashboard upstream, not via `/agent/<name>/`, so this
has no effect when `hyperhive.role = "manager"`. option has no effect when `hyperhive.role = "manager"` (the
env var is set unconditionally for clarity, but the manager's
web UI doesn't route through the gateway's per-agent unix
upstream its bind socket would just sit unused).
''; '';
}; };
@ -1322,9 +1325,6 @@ in
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
HIVE_PORT = "8875"; HIVE_PORT = "8875";
HIVE_LABEL = "hm1nd"; HIVE_LABEL = "hm1nd";
# Manager always uses a unix socket so the gateway can route
# /agent/<name>/ to it the same way it routes sub-agents.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
}; };
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
@ -1343,4 +1343,3 @@ in
system.stateVersion = "25.11"; system.stateVersion = "25.11";
}; };
} }