diff --git a/docs/boundary.md b/docs/boundary.md index dc47a117..fe93de85 100644 --- a/docs/boundary.md +++ b/docs/boundary.md @@ -59,19 +59,3 @@ The `area:ops` issues followed this sequencing: runs as the unprivileged `hive-core` user and delegates root operations to `hive-priv`, a narrow socket-activated helper. See [`docs/security.md`](security.md) for the privilege boundary table. - -### hive-priv socket activation - -`hive-priv` is **always** socket-activated by the `hive-priv.socket` -systemd unit. The unit binds `/run/hive/priv.sock` with -`SocketGroup=hive-core` and mode `0660` and passes the ready listener -to the helper as fd 3 (`LISTEN_FDS`). The helper requires this and -bails if it isn't socket-activated — there is intentionally no -self-bind fallback. - -Dropping the old fallback removed a dev/prod divergence: when -`hive-priv` bound the socket itself it created the file owned by -root's primary group rather than `hive-core`, so a `hive-core` client -couldn't connect the way the socket unit's `SocketGroup` grant -intends. Requiring socket activation everywhere means dev and prod -take the exact same path and the group grant always holds. diff --git a/docs/persistence.md b/docs/persistence.md index d1f297b2..0a7dff4b 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -220,20 +220,6 @@ Under `/var/lib/hyperhive/agents//`: - `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-model` — single-line model name override file. -### Parent access to child state - -A parent agent gets each direct child's `state`, `harness`, and -`config` dirs bind-mounted **read-write** (`bind_child_agent_dirs` in -`lifecycle.rs`). The RW on `state` is deliberate, not an oversight: a -parent manages its children, which includes writing into a child's -state for recovery (e.g. seeding notes, clearing a stuck sentinel) as -well as reading it. `config` is RW because the parent authors proposed -config changes for the child (the approval flow commits into the -child's config repo), and `harness` is RW for the same management -reasons. Per-child isolation still holds: a container only ever has -its *own* dirs plus its direct children's bind-mounted, never a -sibling's. - Under `/var/lib/hyperhive/applied//` — the hive-c0re-only applied repo. Tracks `flake.nix` (module-only boilerplate; never edited after first spawn) + `agent.nix` (the actual config; the diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 0816ec4d..e3edb85a 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1100,10 +1100,9 @@ const HOST_META_ROOT: &str = "/var/lib/hyperhive/meta"; const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; /// Append bind flags for `child`'s state, harness, and config dirs into -/// `binds`, all read-write. The RW on `state` is deliberate (recovery), -/// not an oversight; see docs/persistence.md ("Parent access to child -/// state") for the rationale. Creates missing host-side directories so -/// nspawn doesn't refuse to start; missing dirs are non-fatal. +/// `binds`. All three are RW so the parent can read/write state and +/// submit config-change requests. Creates missing host-side directories +/// so nspawn doesn't refuse to start; missing dirs are non-fatal. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state"); let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness"); @@ -1221,9 +1220,8 @@ async fn set_nspawn_flags( }); // Topology-driven child mounts: every direct child of this agent gets - // its state, harness, and config dirs bind-mounted RW (parent reads + - // writes child state for recovery, and manages config). See - // `bind_child_agent_dirs`. + // its state, harness, and config dirs bind-mounted RW so the parent + // can read state and manage config. let direct_children = crate::topology::children_of(agent_name); for child in &direct_children { bind_child_agent_dirs(child, &mut binds); diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index a830c7da..cdfcb3a4 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -57,9 +57,9 @@ async fn main() -> Result<()> { } fn socket_listener() -> Result { - // hive-priv is ALWAYS socket-activated by the `hive-priv.socket` unit - // (fd 3 via LISTEN_FDS). There is intentionally no self-bind fallback, - // so dev and prod take the same path; see docs/boundary.md. + use std::os::unix::fs::PermissionsExt as _; + // Socket activation: systemd passes the socket as fd 3 when + // LISTEN_FDS >= 1 and LISTEN_PID matches our pid. let listen_fds: Option = std::env::var("LISTEN_FDS") .ok() .and_then(|s| s.parse().ok()); @@ -67,27 +67,35 @@ fn socket_listener() -> Result { .ok() .and_then(|s| s.parse().ok()); - let activated = - matches!(listen_fds, Some(n) if n >= 1) && listen_pid == Some(std::process::id()); - if !activated { - bail!( - "hive-priv requires systemd socket activation (expected LISTEN_FDS>=1 + \ - LISTEN_PID= for {PRIV_SOCK}); run it via the hive-priv.socket unit, \ - not directly" - ); + if let (Some(n), Some(p)) = (listen_fds, listen_pid) + && n >= 1 + && p == std::process::id() + { + // SAFETY: systemd has passed us a ready UnixListener on fd 3. + let std_listener = unsafe { + use std::os::unix::io::FromRawFd; + std::os::unix::net::UnixListener::from_raw_fd(3) + }; + std_listener + .set_nonblocking(true) + .context("set socket non-blocking")?; + let listener = + tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; + tracing::info!("using systemd-activated socket"); + return Ok(listener); } - // SAFETY: systemd has passed us a ready UnixListener on fd 3. - let std_listener = unsafe { - use std::os::unix::io::FromRawFd; - std::os::unix::net::UnixListener::from_raw_fd(3) - }; - std_listener - .set_nonblocking(true) - .context("set socket non-blocking")?; - let listener = - tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; - tracing::info!("using systemd-activated socket"); + // Fallback: bind the socket ourselves. + let path = Path::new(PRIV_SOCK); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let _ = std::fs::remove_file(path); + let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?; + // Mode 0660: only the hive-core group can connect. + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .context("chmod priv.sock")?; + tracing::info!(path = PRIV_SOCK, "bound priv socket"); Ok(listener) } diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index b4ee2907..d6425ebf 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -894,6 +894,8 @@ in # Why each entry is needed: # /etc/nixos-containers — writes .conf (bind mounts, # network isolation, nspawn flags) + # /run/hive — fallback socket bind if LISTEN_FDS is + # absent (normal path: socket-activated) # /run/hive-agent — chown/chmod per-agent socket directories # /run/systemd — container@ unit drop-ins (resource limits) # + machinectl / systemd-machined state @@ -912,6 +914,7 @@ in ProtectSystem = "strict"; ReadWritePaths = [ "/etc/nixos-containers" + "/run/hive" "/run/hive-agent" "/run/systemd" "/run/lock"