Compare commits

...
5 changed files with 61 additions and 40 deletions

View file

@ -59,3 +59,19 @@ The `area:ops` issues followed this sequencing:
runs as the unprivileged `hive-core` user and delegates root runs as the unprivileged `hive-core` user and delegates root
operations to `hive-priv`, a narrow socket-activated helper. See operations to `hive-priv`, a narrow socket-activated helper. See
[`docs/security.md`](security.md) for the privilege boundary table. [`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.

View file

@ -220,6 +220,20 @@ Under `/var/lib/hyperhive/agents/<name>/`:
- `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-turn-stats.sqlite` — per-turn timing stats.
- `hyperhive-model` — single-line model name override file. - `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/<name>/` — the hive-c0re-only Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never applied repo. Tracks `flake.nix` (module-only boilerplate; never
edited after first spawn) + `agent.nix` (the actual config; the edited after first spawn) + `agent.nix` (the actual config; the

View file

@ -1100,9 +1100,10 @@ const HOST_META_ROOT: &str = "/var/lib/hyperhive/meta";
const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
/// Append bind flags for `child`'s state, harness, and config dirs into /// Append bind flags for `child`'s state, harness, and config dirs into
/// `binds`. All three are RW so the parent can read/write state and /// `binds`, all read-write. The RW on `state` is deliberate (recovery),
/// submit config-change requests. Creates missing host-side directories /// not an oversight; see docs/persistence.md ("Parent access to child
/// so nspawn doesn't refuse to start; missing dirs are non-fatal. /// state") for the rationale. 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<BindMount>) { fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state"); let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state");
let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness"); let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness");
@ -1220,8 +1221,9 @@ async fn set_nspawn_flags(
}); });
// Topology-driven child mounts: every direct child of this agent gets // Topology-driven child mounts: every direct child of this agent gets
// its state, harness, and config dirs bind-mounted RW so the parent // its state, harness, and config dirs bind-mounted RW (parent reads +
// can read state and manage config. // writes child state for recovery, and manages config). See
// `bind_child_agent_dirs`.
let direct_children = crate::topology::children_of(agent_name); let direct_children = crate::topology::children_of(agent_name);
for child in &direct_children { for child in &direct_children {
bind_child_agent_dirs(child, &mut binds); bind_child_agent_dirs(child, &mut binds);

View file

@ -57,9 +57,9 @@ async fn main() -> Result<()> {
} }
fn socket_listener() -> Result<UnixListener> { fn socket_listener() -> Result<UnixListener> {
use std::os::unix::fs::PermissionsExt as _; // hive-priv is ALWAYS socket-activated by the `hive-priv.socket` unit
// Socket activation: systemd passes the socket as fd 3 when // (fd 3 via LISTEN_FDS). There is intentionally no self-bind fallback,
// LISTEN_FDS >= 1 and LISTEN_PID matches our pid. // so dev and prod take the same path; see docs/boundary.md.
let listen_fds: Option<i32> = std::env::var("LISTEN_FDS") let listen_fds: Option<i32> = std::env::var("LISTEN_FDS")
.ok() .ok()
.and_then(|s| s.parse().ok()); .and_then(|s| s.parse().ok());
@ -67,10 +67,16 @@ fn socket_listener() -> Result<UnixListener> {
.ok() .ok()
.and_then(|s| s.parse().ok()); .and_then(|s| s.parse().ok());
if let (Some(n), Some(p)) = (listen_fds, listen_pid) let activated =
&& n >= 1 matches!(listen_fds, Some(n) if n >= 1) && listen_pid == Some(std::process::id());
&& p == std::process::id() if !activated {
{ bail!(
"hive-priv requires systemd socket activation (expected LISTEN_FDS>=1 + \
LISTEN_PID=<self> for {PRIV_SOCK}); run it via the hive-priv.socket unit, \
not directly"
);
}
// SAFETY: systemd has passed us a ready UnixListener on fd 3. // SAFETY: systemd has passed us a ready UnixListener on fd 3.
let std_listener = unsafe { let std_listener = unsafe {
use std::os::unix::io::FromRawFd; use std::os::unix::io::FromRawFd;
@ -82,20 +88,6 @@ fn socket_listener() -> Result<UnixListener> {
let listener = let listener =
tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?;
tracing::info!("using systemd-activated socket"); tracing::info!("using systemd-activated socket");
return Ok(listener);
}
// 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) Ok(listener)
} }

View file

@ -894,8 +894,6 @@ in
# Why each entry is needed: # Why each entry is needed:
# /etc/nixos-containers — writes <container>.conf (bind mounts, # /etc/nixos-containers — writes <container>.conf (bind mounts,
# network isolation, nspawn flags) # 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/hive-agent — chown/chmod per-agent socket directories
# /run/systemd — container@ unit drop-ins (resource limits) # /run/systemd — container@ unit drop-ins (resource limits)
# + machinectl / systemd-machined state # + machinectl / systemd-machined state
@ -914,7 +912,6 @@ in
ProtectSystem = "strict"; ProtectSystem = "strict";
ReadWritePaths = [ ReadWritePaths = [
"/etc/nixos-containers" "/etc/nixos-containers"
"/run/hive"
"/run/hive-agent" "/run/hive-agent"
"/run/systemd" "/run/systemd"
"/run/lock" "/run/lock"