harness: opt-in UnixListener bind via HIVE_WEB_SOCKET (#784 phase 1)

phase 1 of #784 (unix-domain agent web UI for #14 prerequisite).
backwards-compatible: when HIVE_WEB_SOCKET is set + non-empty, bind a
UnixListener at that path instead of the legacy TCP bind on HIVE_PORT.
empty env var treated as unset so a stray HIVE_WEB_SOCKET= doesn't
trap an un-bindable empty path.

bind_unix helper:
- mkdir -p the socket parent (covers first-boot fresh /run/hive-agent/
  bind-mount target)
- best-effort unlink of stale socket (clean exit removes it, crash
  leaves it; bind(2) refuses to overwrite)
- mode 0o660 so gateway peers in the same unix group can connect (the
  bind-mount source dir ACL is the real gate; perms are defence in
  depth)

axum 0.8's Listener trait covers tokio::net::UnixListener directly
(no extra feature/dep required).

next phases (separate PRs):
- step 2: c0re bind-mounts /run/hive-agent/ + writes agent-sockets.json
  alongside agent-ports.json
- step 3: gateway proxy_pass http://unix:… (atlas)
- step 4: drop TCP bind once gateway no longer needs it
This commit is contained in:
damocles 2026-05-31 15:09:18 +02:00 committed by mara
commit 0ef79b8032

View file

@ -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<tokio::net::UnixListener> {
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
// ---------------------------------------------------------------------------