diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 4e117c1e..39d0fdc5 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -7,7 +7,7 @@ use std::convert::Infallible; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; @@ -78,7 +78,19 @@ pub type Flavor = mcp::Flavor; /// # Errors /// -/// Returns an error if the TCP listener cannot bind to the given port. +/// Returns an error if neither the TCP listener (default) nor the +/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or +/// if `HIVE_STATIC_DIR` is missing. +/// +/// # Binding modes (#784 phase 1) +/// +/// When `HIVE_WEB_SOCKET` is set + non-empty, bind a `UnixListener` +/// at that path so the gateway can `proxy_pass unix:…` instead of +/// reaching us over a TCP loopback that won't work post-#14 (private +/// netns). When the env var is unset, fall back to TCP bind on `port` +/// — the legacy path the gateway's `agent-ports.json` map drives. The +/// gateway can transition to socket upstreams independently of any +/// agent re-binding because the env var is opt-in per agent. pub async fn serve( label: String, port: u16, @@ -138,13 +150,54 @@ pub async fn serve( // hyperhive.frontend.mergedDist in nix). .fallback_service(ServeDir::new(&static_dir)) .with_state(state); + // `HIVE_WEB_SOCKET` opt-in (#784 phase 1): when set, bind a + // `UnixListener` at the given path. Empty string treated as + // unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an + // un-bindable empty path. Falls through to the TCP path below + // otherwise. + if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET") + && !socket_path.is_empty() + { + let path = PathBuf::from(socket_path); + let listener = bind_unix(&path)?; + tracing::info!(socket = %path.display(), "web UI listening on unix socket"); + axum::serve(listener, app).await?; + return Ok(()); + } let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; - tracing::info!(%port, "web UI listening"); + tracing::info!(%port, "web UI listening on tcp"); axum::serve(listener, app).await?; Ok(()) } +/// Bind a `UnixListener` at `path`. Best-effort unlinks any stale +/// socket left over from a previous (crashed) harness — clean exit +/// removes it, but `bind(2)` refuses to overwrite an existing file. +/// Also `mkdir -p` the parent so a freshly-created `/run/hive-agent/` +/// bind-mount target works on first boot. +/// +/// Permissions: mode `0o660` so peers in the same unix group (the +/// gateway container, when bind-mounting the socket dir with a +/// shared group) can `connect(2)`. The bind-mount source dir's +/// ownership + ACL is the real access gate; this is defence-in-depth. +fn bind_unix(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create socket parent dir {}", parent.display()))?; + } + // Best-effort: ENOENT is fine (no stale file); any other error + // surfaces via the bind below with a clearer "AddrInUse" / perms + // message than a partial cleanup would. + let _ = std::fs::remove_file(path); + let listener = tokio::net::UnixListener::bind(path) + .with_context(|| format!("bind unix socket at {}", path.display()))?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .with_context(|| format!("set perms on {}", path.display()))?; + Ok(listener) +} + // --------------------------------------------------------------------------- // Static assets + state snapshot // ---------------------------------------------------------------------------