//! Minimal privileged helper for hive-c0re. //! //! Runs as root. Exposes a narrow unix socket at `/run/hive/priv.sock` //! that accepts `PrivRequest` JSON lines and executes only the //! operations that genuinely require root. All coordination logic, //! broker, HTTP, and scheduling stay in the unprivileged hive-c0re //! process. //! //! **Security model**: every request is validated against a strict //! container-name allowlist before any filesystem or process operation. //! Only containers whose names match the hive convention (`h-*`, //! the manager container, or known sibling service containers) are //! accepted. Every variant maps to a single known operation — no //! arbitrary command pass-through. //! //! **Socket activation**: when systemd passes the listener socket via //! `LISTEN_FDS=1` + `LISTEN_PID=`, the inherited fd 3 is used //! instead of binding a fresh socket. use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd}; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_priv_sock::{ AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, AgentTmpfilesEntry, BindMount, CredentialMount, InfraAction, InfraContainer, JournalQuery, META_DIR, MIGRATE_STAGING_ROOT, NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, SIBLING_CONTAINERS, }; use serde::Serialize; use tokio::io::{AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; use tokio::net::{UnixListener, UnixStream}; use tokio::process::Command; /// Root of the per-agent unix-socket dirs on the host. const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); let listener = socket_listener()?; tracing::info!("hive-priv listening"); loop { match listener.accept().await { Ok((stream, _)) => { tokio::spawn(handle(stream)); } Err(e) => { tracing::error!(error = %e, "accept failed"); } } } } 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. let listen_fds: Option = std::env::var("LISTEN_FDS") .ok() .and_then(|s| s.parse().ok()); let listen_pid: Option = std::env::var("LISTEN_PID") .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" ); } // 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"); Ok(listener) } /// Ancillary-data buffer sized and aligned for one `SCM_RIGHTS` message. /// /// `CMSG_SPACE` is not a `const fn`, so the size is a literal with room /// to spare (24 bytes are needed for a single descriptor on x86-64). The /// union member gives the `cmsghdr` alignment `CMSG_FIRSTHDR` requires — /// a bare `[u8; N]` is only byte-aligned and would be undefined behaviour /// to walk. #[repr(C)] union CmsgSpace { _align: libc::cmsghdr, bytes: [u8; 32], } /// One `recvmsg` into `buf`, returning the bytes read plus any file /// descriptors that rode along as `SCM_RIGHTS`. /// /// Why not a plain read: ancillary data is attached to a *specific* /// `recvmsg` call, so a buffered line reader cannot surface it — it /// reads the bytes and silently drops the descriptor. /// /// `MSG_CMSG_CLOEXEC` is not optional: without it a received descriptor /// is inherited by every `btrfs` / `nixos-container` child this helper /// later spawns. fn recv_with_fds(sock: RawFd, buf: &mut [u8]) -> std::io::Result<(usize, Vec)> { const FD_SIZE: usize = std::mem::size_of::(); let mut iov = libc::iovec { iov_base: buf.as_mut_ptr().cast(), iov_len: buf.len(), }; let mut cmsg = CmsgSpace { bytes: [0; 32] }; // SAFETY: msghdr is a plain C struct with no invalid bit patterns; // every field we care about is set immediately below. let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; msg.msg_iov = &raw mut iov; msg.msg_iovlen = 1; msg.msg_control = std::ptr::addr_of_mut!(cmsg.bytes).cast(); msg.msg_controllen = 32; // SAFETY: `msg` points at a live iovec covering `buf` and a live, // correctly aligned control buffer of the length we just declared. let n = unsafe { libc::recvmsg(sock, &raw mut msg, libc::MSG_CMSG_CLOEXEC) }; if n < 0 { return Err(std::io::Error::last_os_error()); } // Take ownership of every descriptor the kernel attached, even ones // this protocol never expects: an `OwnedFd` we drop is closed, an // fd we fail to claim is leaked for the lifetime of the process. let mut fds = Vec::new(); // SAFETY: `msg` was just filled in by a successful `recvmsg`. let mut cmsgp = unsafe { libc::CMSG_FIRSTHDR(&raw const msg) }; while !cmsgp.is_null() { // SAFETY: CMSG_FIRSTHDR / CMSG_NXTHDR only ever return a pointer // to a complete header inside the control buffer. let hdr = unsafe { std::ptr::read_unaligned(cmsgp) }; if hdr.cmsg_level == libc::SOL_SOCKET && hdr.cmsg_type == libc::SCM_RIGHTS { // SAFETY: same, and CMSG_LEN(0) is the header's own length. let payload = hdr.cmsg_len as usize - unsafe { libc::CMSG_LEN(0) } as usize; let count = payload / FD_SIZE; // SAFETY: CMSG_DATA points at `payload` bytes of descriptors. let data = unsafe { libc::CMSG_DATA(cmsgp) }; for i in 0..count { // Copied out byte-wise rather than read through a // `*const RawFd`: the control buffer is only guaranteed // `cmsghdr`-aligned, so casting to a more strictly // aligned pointer would be unsound even where it happens // to work. let mut raw = [0u8; FD_SIZE]; // SAFETY: i < count, so this reads inside the payload. unsafe { std::ptr::copy_nonoverlapping(data.add(i * FD_SIZE), raw.as_mut_ptr(), FD_SIZE); } // SAFETY: the kernel just created this descriptor for // us — we are its only owner. fds.push(unsafe { OwnedFd::from_raw_fd(RawFd::from_ne_bytes(raw)) }); } } // SAFETY: `cmsgp` came from this same message. cmsgp = unsafe { libc::CMSG_NXTHDR(&raw const msg, cmsgp) }; } // `n >= 0` was checked above, so the conversion cannot fail; going // through `try_from` keeps it a cast-free, lint-clean widening. let read = usize::try_from(n).unwrap_or_default(); Ok((read, fds)) } /// Reads newline-delimited requests off one connection, pairing each /// with the descriptor that arrived with it. /// /// The pairing is deliberately trivial, because the protocol is: /// `hive-sock-client` connects per request, so a connection carries one /// line and at most one descriptor. The loop below still handles several /// sequential requests (the server always has), but it refuses to guess /// — a second descriptor arriving before its line is a protocol error, /// not something to queue and hope about. struct Requests<'a> { sock: &'a UnixStream, buf: Vec, fd: Option, } impl Requests<'_> { /// Next complete request line and its descriptor, or `None` at EOF. async fn next(&mut self) -> Result)>> { loop { if let Some(nl) = self.buf.iter().position(|&b| b == b'\n') { let line: Vec = self.buf.drain(..=nl).take(nl).collect(); let line = String::from_utf8(line).context("request line was not valid UTF-8")?; return Ok(Some((line, self.fd.take()))); } let mut chunk = [0u8; 8192]; let raw = self.sock.as_raw_fd(); let (n, fds) = self .sock .async_io(tokio::io::Interest::READABLE, || { recv_with_fds(raw, &mut chunk) }) .await .context("recvmsg on the priv socket")?; for fd in fds { if self.fd.replace(fd).is_some() { bail!("more than one file descriptor passed for a single request"); } } if n == 0 { if !self.buf.is_empty() { bail!("connection closed mid-request ({} bytes)", self.buf.len()); } return Ok(None); } self.buf.extend_from_slice(&chunk[..n]); } } } async fn handle(stream: UnixStream) { let (reader, mut writer) = stream.into_split(); let mut requests = Requests { sock: reader.as_ref(), buf: Vec::new(), fd: None, }; loop { let (line, fd) = match requests.next().await { Ok(Some(req)) => req, Ok(None) => break, Err(e) => { tracing::warn!(error = %format!("{e:#}"), "reading request failed"); break; } }; let resp = dispatch(&line, fd, &mut writer).await; // Write the terminal PrivResponse as a PrivEvent::Done. Wire-identical // to a bare PrivResponse (untagged), so old hive-c0re callers that // deserialise directly to PrivResponse continue to work. let event = PrivEvent::Done(resp); let mut json = serde_json::to_string(&event).unwrap_or_else(|e| { format!("{{\"ok\":false,\"stdout\":\"\",\"stderr\":\"\",\"error\":\"serialise failed: {e}\"}}") }); json.push('\n'); if let Err(e) = writer.write_all(json.as_bytes()).await { tracing::warn!(error = %e, "write response failed"); break; } } } /// Reject a request whose descriptor and operation disagree, in either /// direction. /// /// No guessing when the caller didn't say: an op that streams into a /// passed descriptor cannot invent one, and an op that takes none must /// not silently accept one. Returning the `Err` here drops the /// `OwnedFd`, which closes it. fn check_fd_agreement(req: &PrivRequest, fd: Option<&OwnedFd>) -> Result<()> { let wants_fd = matches!(req, PrivRequest::SendAgentSnapshotToFd { .. }); match (wants_fd, fd.is_some()) { (true, false) => bail!("this operation requires a passed file descriptor, none arrived"), (false, true) => bail!("this operation does not take a passed file descriptor"), _ => Ok(()), } } async fn dispatch(line: &str, fd: Option, writer: &mut OwnedWriteHalf) -> PrivResponse { match run(line, fd, writer).await { Ok((stdout, stderr)) => PrivResponse { ok: true, stdout, stderr, error: None, }, Err(e) => PrivResponse { ok: false, stdout: String::new(), stderr: String::new(), error: Some(format!("{e:#}")), }, } } /// Parse one request line, check it agrees with the descriptor that /// arrived with it, and execute it. /// /// Split out of [`dispatch`] so the three failure modes collapse into one /// `Result` instead of three nested matches building the same struct. async fn run( line: &str, fd: Option, writer: &mut OwnedWriteHalf, ) -> Result<(String, String)> { let req = serde_json::from_str::(line).context("parse request")?; check_fd_agreement(&req, fd.as_ref())?; exec(req, fd, writer).await } /// Write one `PrivEvent::Line` to the client. Best-effort: a write /// failure is logged but doesn't abort the running subprocess. async fn write_line_event(writer: &mut OwnedWriteHalf, stream: PrivStream, data: &str) { let event = PrivEvent::Line(PrivStreamLine { stream, data: data.to_owned(), }); if let Ok(mut json) = serde_json::to_string(&event) { json.push('\n'); if let Err(e) = writer.write_all(json.as_bytes()).await { tracing::warn!(error = %e, "write_line_event: write failed"); } } } /// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success. /// For streaming ops (`CreateContainer`/`UpdateContainer` with `stream: true`) /// output lines are forwarded to `writer` as `PrivEvent::Line` messages and /// the returned strings are empty. // One match arm per priv op — a flat 1:1 dispatch table. The length tracks // the op count, not complexity; splitting it would just scatter the mapping. #[allow(clippy::too_many_lines)] /// Execute one validated request. /// /// `fd` is the descriptor that arrived with this request, already checked /// against the operation by [`check_fd_agreement`]: `Some` exactly for /// the variants that stream into a caller-supplied descriptor, `None` /// for every other operation. async fn exec( req: PrivRequest, fd: Option, writer: &mut OwnedWriteHalf, ) -> Result<(String, String)> { match req { PrivRequest::StartContainer { ref name } => { validate_container_name(name)?; let machine = container_system_name(name); // Clear any start-limit lockout left by earlier failures so a // now-correct start isn't blocked. nixos-container start does not // do this itself. Best-effort: if the unit doesn't exist yet // (first-time create) reset-failed is a no-op and we proceed. let _ = Command::new("systemctl") .args(["reset-failed", &format!("container@{machine}.service")]) .status() .await; container_run(&["start", &machine]).await } PrivRequest::StopContainer { ref name } => { validate_container_name(name)?; stop_and_release(&container_system_name(name)).await } PrivRequest::KillContainer { ref name } => { validate_container_name(name)?; // nixos-container has no kill verb. Use machinectl to send SIGKILL // to all processes in the container — the right semantics for a // forced shutdown after a graceful stop has already been attempted. let machine = container_system_name(name); machinectl_run(&["kill", &machine, "--signal=SIGKILL"]).await } PrivRequest::UpdateContainer { ref name, stream } => { container_flake_action("update", name, stream, writer).await } PrivRequest::CreateContainer { ref name, stream } => { container_flake_action("create", name, stream, writer).await } PrivRequest::DestroyContainer { ref name } => { validate_container_name(name)?; container_run(&["destroy", &container_system_name(name)]).await } PrivRequest::ListContainers => container_run(&["list"]).await, PrivRequest::ReadContainerJournal { ref container, ref query, } => { validate_container_system_name(container)?; read_container_journal(container, query).await } PrivRequest::WriteNspawnFlags { ref container, ref binds, ref isolation, ref load_credentials, } => handle_write_nspawn_flags(container, binds, isolation.as_ref(), load_credentials), PrivRequest::WriteResourceLimits { ref container, ref memory_max, ref cpu_quota, cpu_weight, io_weight, } => write_resource_limits(container, memory_max, cpu_quota, cpu_weight, io_weight), PrivRequest::RemoveServiceDropin { ref container } => remove_service_dropin(container), PrivRequest::DaemonReload => daemon_reload().await, PrivRequest::ReloadGatewayNginx => sync_gateway_nginx().await, PrivRequest::RunForgeAdmin { ref args } => { for arg in args { validate_forge_admin_arg(arg)?; } run_forge_admin(args).await } PrivRequest::SetAgentPaused { ref agent_name, paused, } => { validate_agent_name(agent_name)?; set_agent_paused(agent_name, paused) } PrivRequest::WriteAgentForgeToken { ref agent_name, ref token, } => { validate_agent_name(agent_name)?; write_agent_state_file(agent_name, "forge-token", &format!("{token}\n")) } PrivRequest::WriteAgentMatrixToken { ref agent_name, ref token, ref account, ref homeserver, } => { validate_agent_name(agent_name)?; // Build the token filename. `None` → the hive account's // `matrix-token`; `Some(a)` → `matrix-token-`. The account // suffix MUST be validated as a plain identifier (no `/`, `.`, // `..`) before it goes into the filename, or a crafted account // could traverse out of the state dir — `write_agent_state_file` // trusts its `filename` argument. let filename = match account { None => "matrix-token".to_owned(), Some(a) => { validate_name_chars(a)?; format!("matrix-token-{a}") } }; let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?; // For an extra account, persist its homeserver in a sidecar // (`matrix-account-.json`) so the daemon can auto-discover the // account without a static `matrixAccounts` config entry. Only // when both `account` and `homeserver` are present; the account // suffix is already validated above. if let (Some(a), Some(hs)) = (account, homeserver) { let meta = serde_json::to_string(&MatrixAccountSidecar { homeserver: hs.as_str(), }) .context("serialize matrix account sidecar")?; write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?; } Ok(res) } PrivRequest::WriteAgentGithubToken { ref agent_name, ref token, } => { validate_agent_name(agent_name)?; write_agent_state_file(agent_name, "github-token", &format!("{token}\n")) } PrivRequest::WriteAgentExtraForgeAccount { ref agent_name, ref label, ref base_url, ref token, } => { validate_agent_name(agent_name)?; validate_name_chars(label)?; let res = write_agent_state_file( agent_name, &format!("forge-{label}-token"), &format!("{token}\n"), )?; // Sidecar carries the base URL — there's no host-side nix config // for extra forges, so this is the only place it's persisted. let meta = serde_json::to_string(&ForgeSidecar { base_url: base_url.as_str(), }) .context("serialize forge account sidecar")?; write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?; Ok(res) } PrivRequest::DeleteAgentExtraForgeAccount { ref agent_name, ref label, } => { validate_agent_name(agent_name)?; validate_name_chars(label)?; delete_agent_state_file(agent_name, &format!("forge-{label}-token"))?; delete_agent_state_file(agent_name, &format!("forge-{label}.json")) } PrivRequest::RestartMatrixDaemon { ref agent_name } => { restart_matrix_daemon(agent_name).await } PrivRequest::RegisterCiRunner { ref token } => register_ci_runner(token).await, PrivRequest::ControlInfraContainer { container, action } => { control_infra_container(container, action).await } PrivRequest::EnsureAgentSubvolume { ref agent_name } => { validate_agent_name(agent_name)?; ensure_agent_subvolume(agent_name).await } PrivRequest::DeleteAgentSubvolume { ref agent_name } => { validate_agent_name(agent_name)?; delete_agent_subvolume(agent_name).await } PrivRequest::EnsureBtrfsQuota => ensure_btrfs_quota().await, PrivRequest::ReadSubvolumeUsage { ref agent_name } => { validate_agent_name(agent_name)?; read_subvolume_usage(agent_name).await } PrivRequest::SetSubvolumeQuota { ref agent_name, limit_bytes, } => { validate_agent_name(agent_name)?; set_subvolume_quota(agent_name, limit_bytes).await } PrivRequest::UpgradeAgentSubvolume { ref agent_name } => { validate_agent_name(agent_name)?; upgrade_agent_subvolume(agent_name).await } PrivRequest::SnapshotAgentSubvolume { ref agent_name, ref snapshot_name, } => { validate_agent_name(agent_name)?; validate_snapshot_name(snapshot_name)?; snapshot_agent_subvolume(agent_name, snapshot_name).await } PrivRequest::DeleteAgentSnapshot { ref agent_name, ref snapshot_name, } => { validate_agent_name(agent_name)?; validate_snapshot_name(snapshot_name)?; delete_agent_snapshot(agent_name, snapshot_name).await } PrivRequest::SendAgentSnapshotToFile { ref agent_name, ref snapshot_name, ref parent_snapshot_name, ref dest_file_name, } => { validate_agent_name(agent_name)?; validate_snapshot_name(snapshot_name)?; if let Some(parent) = parent_snapshot_name { validate_snapshot_name(parent)?; } validate_credential_name(dest_file_name)?; send_agent_snapshot_to_file( agent_name, snapshot_name, parent_snapshot_name.as_deref(), dest_file_name, ) .await } PrivRequest::SendAgentSnapshotToFd { ref agent_name, ref snapshot_name, ref parent_snapshot_name, } => { validate_agent_name(agent_name)?; validate_snapshot_name(snapshot_name)?; if let Some(parent) = parent_snapshot_name { validate_snapshot_name(parent)?; } let dest = fd.context("no descriptor to stream into")?; send_agent_snapshot_to_fd( agent_name, snapshot_name, parent_snapshot_name.as_deref(), dest, ) .await } PrivRequest::SyncAgentTmpfiles { ref agents } => sync_agent_tmpfiles(agents).await, } } /// Shared body for `CreateContainer` / `UpdateContainer`: validate the /// name, build the toplevel ourselves, and run /// `nixos-container … --system-path ` (streaming line /// events to `writer` when `stream` is set). /// /// **Both verbs build explicitly now — not just `update`.** The first cut /// of this fix only rewrote `update`, reasoning that `create` was already /// safe: it wraps its whole action in an exclusive `flock` before calling /// `nixos-container`'s own `buildFlake()`, and once `update` stopped /// writing to `buildFlake()`'s shared `.tmp` out-link, concurrent /// `create`s were the only remaining writers — mutually excluded by that /// lock. True, but it leaves `create`'s safety resting on an internal /// implementation detail of a script we don't own (its current locking /// behavior, which could change upstream without notice) instead of on /// something we control. Building here for both verbs removes `buildFlake()` /// from the picture entirely — there's no shared `.tmp` left to race on, /// so there's nothing left to reason about staying in sync with. async fn container_flake_action( verb: &str, name: &str, stream: bool, writer: &mut OwnedWriteHalf, ) -> Result<(String, String)> { validate_container_name(name)?; // The build is the multi-minute phase of this operation — give it the // same live-line treatment `container_run_streaming` gives // `nixos-container` itself when the caller asked for it. Without // this, moving the build out of the streamed `nixos-container` call // (which is the whole point of this fix) would silently regress every // UI that shows build progress: nothing until the longest phase // finishes, then everything at once. let toplevel = nix_build_toplevel(name, stream.then_some(&mut *writer)).await?; let args = [ verb, &container_system_name(name), "--system-path", &toplevel, ]; if stream { container_run_streaming(&args, writer).await } else { container_run(&args).await } } /// The explicit `nixosConfigurations..config.system.build.toplevel` /// flake attr path — same construction `hive-c0re`'s own /// `lifecycle::prebuild_toplevel` uses, kept here as a pure function so /// the exact string shape is unit-tested without needing to run `nix`. fn toplevel_attr(name: &str) -> String { format!("{META_DIR}#nixosConfigurations.{name}.config.system.build.toplevel") } /// Build `nixosConfigurations..config.system.build.toplevel` and /// return the resulting store path. /// /// **Why hive-priv builds this itself instead of letting `nixos-container` /// do it**: `nixos-container`'s own `buildFlake()` (invoked whenever /// `--system-path` isn't passed) builds to a *hardcoded relative path* — /// `$systemPath` is only ever assigned from the CLI flag or from its own /// build result, so with no flag it stays `undef` and `"$systemPath.tmp"` /// interpolates to the bare string `.tmp` in whatever the caller's cwd /// is. `buildFlake()` itself takes no lock at all: `create` wraps its /// *whole action* in an exclusive `flock` before calling it, but `update` /// used to call it with no lock at all, so that `create`-side lock never /// protected against a concurrent `update` clobbering the same `.tmp`. /// hive-priv never sets a per-call cwd, so with /// `services.hyperhive.c0re.buildSlots` > 1, two concurrent calls (any mix /// of `create`/`update`) could share that one `.tmp`: one's /// `readlink(".tmp")` resolving to the *other's* build output, handing an /// agent's container the wrong agent's closure — the "agent container /// gets closure of other agent" mystery bug. /// /// Building the toplevel here and passing the resolved store path via /// `--system-path` for *every* call means `buildFlake()` never runs at /// all, for either verb — no shared `.tmp` left to race on, no locking /// invariant of a script we don't own to keep track of. `--no-link` /// avoids a competing out-link race of our own; we only need the store /// path, not a GC root (the store path is safe from collection for as /// long as it takes `nixos-container` to register it against the /// container's own profile, same window every other consumer of a /// `--print-out-paths` result already relies on). /// /// **Forwards stderr live, captures stdout silently — deliberately not /// symmetric.** This build is the multi-minute phase of a `create`/ /// `update`, and it used to run *inside* `nixos-container`'s own /// `--flake` invocation, which streams every line to `writer` in real /// time. Buffering it here (`Command::output()`, as this function first /// shipped) regressed that: nothing on the wire — dashboard or /// `journalctl -f` alike — until the whole build finishes, then /// everything at once. So both pipes are drained concurrently (see the /// in-body comments for why *both*, and why *concurrently*), but only /// stderr — where nix's own progress goes — is forwarded to `writer` and /// journald as it arrives, matching [`container_run_streaming`]'s shape. /// stdout is different: `--print-out-paths` writes *only* the final store /// path there, once, at the end — forwarding it the same way would risk /// interleaving a progress line into the value this function hands back /// as `--system-path`, trading a closure-mixup bug for a corrupted- /// argument one. So stdout lines are accumulated silently and only /// consulted after the exit status is known to be success. /// /// `writer` is `None` for the non-streaming call shape (`stream: false`); /// stderr still logs to journald either way, just without the /// `PrivEvent::Line` forwarding. async fn nix_build_toplevel(name: &str, mut writer: Option<&mut OwnedWriteHalf>) -> Result { use tokio::io::AsyncBufReadExt as _; let attr = toplevel_attr(name); let args = [ "--extra-experimental-features", "nix-command flakes", "build", "--no-link", "--print-out-paths", &attr, ]; let mut child = Command::new("nix") .args(args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .with_context(|| format!("invoke nix build {attr}"))?; let stdout = child.stdout.take().expect("stdout piped"); let stderr = child.stderr.take().expect("stderr piped"); let mut stdout_lines = BufReader::new(stdout).lines(); let mut stderr_lines = BufReader::new(stderr).lines(); let mut stdout_buf = String::new(); let mut stderr_buf = String::new(); // ⚠️ Both pipes are drained concurrently even though only one is // streamed: reading stderr alone would let stdout fill its pipe // buffer and deadlock the child on a build with enough stdout output // to fill it. // // ⚠️ Each stream's EOF is tracked separately rather than breaking on // the first `None`: `next_line()` on a closed stream returns // `Ok(None)` immediately and forever, so a loop that keeps polling a // finished stream spins hot until the other one ends too. let mut stdout_done = false; let mut stderr_done = false; while !(stdout_done && stderr_done) { tokio::select! { line = stdout_lines.next_line(), if !stdout_done => { match line { // Captured, never streamed — this is the store path. Ok(Some(l)) => { stdout_buf.push_str(&l); stdout_buf.push('\n'); } Ok(None) => stdout_done = true, Err(e) => { tracing::warn!(error = %e, "nix build stdout read error"); stdout_done = true; } } } line = stderr_lines.next_line(), if !stderr_done => { match line { // Streamed as it arrives — the progress the dashboard // and `journalctl -f` were missing. Ok(Some(l)) => { tracing::warn!(target: "nix-build-toplevel", "{l}"); if let Some(w) = writer.as_deref_mut() { write_line_event(w, PrivStream::Stderr, &l).await; } if !stderr_buf.is_empty() { stderr_buf.push('\n'); } stderr_buf.push_str(&l); } Ok(None) => stderr_done = true, Err(e) => { tracing::warn!(error = %e, "nix build stderr read error"); stderr_done = true; } } } } } // ⚠️ Success is decided by the exit status, not by "we parsed a // path" — a build can print to stdout and still fail. let status = child .wait() .await .with_context(|| format!("wait nix build {attr}"))?; if !status.success() { bail!( "nix build {attr} failed ({status}): {}", stderr_buf.lines().last().unwrap_or("").trim() ); } // `--print-out-paths` prints one line *per output*, not one line // total: `nix build --no-link --print-out-paths nixpkgs#openssl` // prints two (`…-bin`, `…-man`). `config.system.build.toplevel` is // single-output today, so this is one line in practice — but a bare // `.trim()` would silently hand a multi-line string on to // `--system-path` the day that ever changes, which is the same // corrupted-argument failure this function exists to avoid. Require // exactly one line and error otherwise, so a future multi-output // attr fails loudly here instead of downstream in `nixos-container`. let lines: Vec<&str> = stdout_buf.lines().collect(); let [path] = lines[..] else { bail!( "nix build {attr} produced {} output path(s), expected exactly 1: {stdout_buf:?}", lines.len() ); }; Ok(path.to_owned()) } /// `WriteNspawnFlags` — validate the container + every bind path + every /// credential entry, then write the container's nspawn flag overrides. fn handle_write_nspawn_flags( container: &str, binds: &[BindMount], isolation: Option<&NetworkIsolation>, load_credentials: &[CredentialMount], ) -> Result<(String, String)> { validate_container_system_name(container)?; for bind in binds { validate_bind_path(&bind.host_path)?; validate_bind_path(&bind.container_path)?; } for cred in load_credentials { validate_credential_name(&cred.name)?; // Same path rules as binds (absolute, no colon/newline/quote/null): // the colon ban is essential since `--load-credential=name:path` // uses `:` as the name/path separator. validate_bind_path(&cred.host_path)?; } write_nspawn_flags(container, binds, isolation, load_credentials)?; Ok((String::new(), String::new())) } /// A btrfs snapshot label must start with `hive-` — this doubles as an /// allow-list: only names hivectl itself constructs (or an operator who /// knows the convention) can reach the `btrfs subvolume snapshot`/`delete` /// shellouts, so an arbitrary caller can't use the snapshot ops to probe or /// churn unrelated paths under `AGENT_STATE_ROOT`. Beyond the prefix, the /// same charset restriction as [`validate_credential_name`] applies (it's /// interpolated straight into a filesystem path). fn validate_snapshot_name(name: &str) -> Result<()> { if !name.starts_with("hive-") { bail!("invalid snapshot label {name:?}: must start with \"hive-\""); } validate_credential_name(name) } /// A systemd credential id must be a short token — restrict to /// `[A-Za-z0-9_-]` (no `.`) so it can't inject extra `--load-credential` /// argv or break the `name:path` shape. `.` is deliberately excluded, not /// just a bare `..`: this name gets interpolated into filesystem paths /// (snapshot labels) and there's no legitimate need for a dot in either a /// systemd credential id or a `hive-`-prefixed snapshot label — we're /// defining this token format from scratch, so keep it maximally strict /// rather than allow-then-patch each traversal-adjacent character /// (mara: "we are making up the rules here, lets go strict"). fn validate_credential_name(name: &str) -> Result<()> { if name.is_empty() || !name .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) { bail!("invalid credential name {name:?}: must be non-empty [A-Za-z0-9_-]"); } Ok(()) } /// `RemoveServiceDropin` — remove the container service's drop-in dir /// if present (idempotent). fn remove_service_dropin(container: &str) -> Result<(String, String)> { validate_container_system_name(container)?; let dir = format!("/run/systemd/system/container@{container}.service.d"); if Path::new(&dir).exists() { std::fs::remove_dir_all(&dir).with_context(|| format!("remove {dir}"))?; } Ok((String::new(), String::new())) } /// `WriteResourceLimits` — drop the systemd resource settings into the /// container service's drop-in dir, together with a /// `ConditionPathIsDirectory=` guard on the agent's MCP runtime dir. /// /// Two different kinds of setting land in the same file. `MemoryMax=` / /// `CPUQuota=` are hard caps that throttle even on an idle host; /// `CPUWeight=` / `IOWeight=` are cgroup v2 relative shares that only /// decide who yields *under contention*. A weight of `None` means "not /// configured" and omits the line, so a hive-c0re built before the weights /// existed keeps producing the old two-line drop-in. /// /// The condition causes systemd to *skip* (not *fail*) the unit when the /// bind-mount source dir is absent — result is `condition`, which does not /// increment the start-limit counter. This is belt-and-braces on top of /// the tmpfiles.d entries written by `SyncAgentTmpfiles`: in the unlikely /// event the dir is missing at start time, the unit idles rather than /// restart-looping into `start-limit-hit`. fn write_resource_limits( container: &str, memory_max: &str, cpu_quota: &str, cpu_weight: Option, io_weight: Option, ) -> Result<(String, String)> { validate_container_system_name(container)?; // Derive the logical agent name (strip h- prefix) to form the runtime // dir path. Falls back to the full container name for infra containers // that don't use the h- prefix. let logical = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); let runtime_dir = format!("{AGENT_RUNTIME_ROOT}/{logical}"); let dir = format!("/run/systemd/system/container@{container}.service.d"); std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?; let path = format!("{dir}/hyperhive-limits.conf"); let content = limits_dropin_body(&runtime_dir, memory_max, cpu_quota, cpu_weight, io_weight); std::fs::write(&path, content).with_context(|| format!("write {path}"))?; Ok((String::new(), String::new())) } /// How long a window the start-limit counts over, and how many starts it /// allows inside it. /// /// `container@.service` sets `Restart=on-failure` and **no** start limit, so /// systemd's defaults apply: 5 starts per 10s, `RestartSec` 100ms. That /// makes the bound depend on *how fast* a container dies — one that fails /// instantly trips the limit in under a second, one that takes longer than /// ~2s never trips it and restarts forever. Whether an agent gets bounded /// is not meant to be a function of its failure speed. /// /// The window has to exceed the worst-case time to burn the burst, or the /// counter ages out between attempts and the limit is again unreachable: /// `TimeoutStartSec` is 1min, so `BURST` slow failures plus their backoff /// can span several minutes. 10min covers that with room. /// /// Giving up is cheap here **because it is not terminal** — hive-c0re's /// reconcile sweep retries later, and `reset-failed` (see `StartContainer`) /// clears the latch first. That is what makes a tight burst safe. const START_LIMIT_INTERVAL_SEC: u32 = 600; /// One start plus two retries — the operator's ruling was "retry once or /// twice", with the reconcile sweep as the slow path after that. const START_LIMIT_BURST: u32 = 3; /// Backoff between those retries. The 100ms default is for processes that /// respawn instantly; a container that just failed to boot gains nothing /// from being retried a tenth of a second later. const RESTART_SEC: u32 = 5; /// Render the body of `hyperhive-limits.conf`. /// /// `[Unit]`: the condition is checked at start time — it skips (not fails) /// the unit when the MCP socket dir is absent, avoiding restart loops — /// plus the bounded start limit (see the constants above; `StartLimit*` are /// `[Unit]` settings since systemd 229 and are silently ignored under /// `[Service]`). /// `[Service]`: the restart backoff, the hard caps, then the relative /// weights. A weight of `None` means "not configured" and omits its line /// entirely, so a request from a hive-c0re built before the weights /// existed — or one whose nix option is `null` — renders no weight lines. fn limits_dropin_body( runtime_dir: &str, memory_max: &str, cpu_quota: &str, cpu_weight: Option, io_weight: Option, ) -> String { // Built as two possibly-empty lines rather than pushed onto the // string: `format!` appended to a `String` trips clippy::pedantic's // `format_push_string`, and a `write!` would need an unwrap. let cpu_weight_line = cpu_weight.map_or_else(String::new, |w| format!("CPUWeight={w}\n")); let io_weight_line = io_weight.map_or_else(String::new, |w| format!("IOWeight={w}\n")); format!( "[Unit]\n\ ConditionPathIsDirectory={runtime_dir}\n\ StartLimitIntervalSec={START_LIMIT_INTERVAL_SEC}\n\ StartLimitBurst={START_LIMIT_BURST}\n\ \n\ [Service]\n\ RestartSec={RESTART_SEC}\n\ MemoryMax={memory_max}\n\ CPUQuota={cpu_quota}\n\ {cpu_weight_line}{io_weight_line}" ) } /// `DaemonReload` — `systemctl daemon-reload` on the host. async fn daemon_reload() -> Result<(String, String)> { let out = Command::new("systemctl") .arg("daemon-reload") .output() .await .context("invoke systemctl daemon-reload")?; if !out.status.success() { bail!( "systemctl daemon-reload failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok((String::new(), String::new())) } /// `RestartMatrixDaemon` — restart the matrix daemon unit inside the /// agent's container. async fn restart_matrix_daemon(agent_name: &str) -> Result<(String, String)> { validate_agent_name(agent_name)?; let machine = format!("--machine=h-{agent_name}"); let unit = "hive-matrix-daemon.service"; let out = Command::new("systemctl") .args([&machine, "restart", unit]) .output() .await .with_context(|| format!("systemctl restart {unit} in container h-{agent_name}"))?; if !out.status.success() { bail!( "systemctl restart {unit} in h-{agent_name} exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(( String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), )) } /// Host path to the hive-ci runner's persisted registration credentials. /// /// Paired with `hive-c0re`'s `forge::ci_runner::RUNNER_FILE`, which reads the /// same file to decide whether a runner is registered and whether it still /// names the configured forge host. Deliberately duplicated rather than shared: /// `hive-priv` is the minimal root helper and does not depend on `hive-c0re`. const RUNNER_CREDENTIALS: &str = "/var/lib/nixos-containers/hive-ci/var/lib/gitea-runner/hive/.runner"; /// Delete the runner's persisted credentials so upstream's `ExecStartPre` takes /// its **absence** branch on the next start. /// /// Absence is the state we want, so `NotFound` is success. Anything else — a /// permission error above all — is NOT swallowed: it means the file is still /// there, the restart will take upstream's already-registered branch, and the /// caller would return `Ok` for a registration that never happened. That is the /// same shape as a precondition that "passes" because it could not read the file /// it was checking, and it is worth failing loudly to avoid. /// /// Split from [`register_ci_runner`] purely so this rule is testable without a /// container or a `systemctl`. fn clear_runner_credentials(path: &str) -> Result<()> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => { Err(anyhow::Error::new(e).context(format!("remove stale runner credentials {path}"))) } } } /// `RegisterCiRunner` — write the runner registration token to the host-side /// `/run/hive-ci/runner-token` env-file, then restart the in-container runner /// so it re-registers. The forge admin token never enters the container; only /// the registration token c0re passes here is written, and it lands on a host /// path bind-mounted read-only into hive-ci. async fn register_ci_runner(token: &str) -> Result<(String, String)> { use std::os::unix::fs::PermissionsExt as _; // Reject anything that could corrupt the `KEY=VALUE` env-file or smuggle a // second line — a forge registration token is an opaque single-line string. if token.is_empty() || token.contains(['\n', '\r', '\0']) { bail!("ci runner registration token empty or contains control characters"); } let token_path = "/run/hive-ci/runner-token"; // In-place truncate+write of the existing inode (mirrors the prefetch's // `echo > $FILE`), NOT a temp+rename: nspawn pins this file's inode into // hive-ci at container start, so a rename would leave the running runner // reading the old content. Format + perms match the tmpfiles seed and the // prefetch: `TOKEN=`, mode 0600, root-owned. std::fs::write(token_path, format!("TOKEN={token}\n")) .with_context(|| format!("write {token_path}"))?; std::fs::set_permissions(token_path, std::fs::Permissions::from_mode(0o600)) .with_context(|| format!("chmod {token_path}"))?; // Remove the persisted credentials BEFORE restarting, or the restart is a // no-op as far as registration goes. // // Upstream's `ExecStartPre` only re-registers when `.runner` is absent, the // labels changed, or the *registration token hash* changed — never when the // instance URL changed. c0re only calls this helper once it has already // decided the existing credentials are absent or stale // (`forge::ci_runner::ensure_ci_runner_registered` returns early otherwise), // so by the time we are here a re-registration is exactly what is wanted and // deleting the file is the narrow way to guarantee it happens. // // Writing a fresh token is NOT sufficient on its own: whether the hash // changes depends on whether the forge mints a new registration token per // request or hands back a stable one, which is Forgejo's behaviour to // choose and change. Gating our remediation on the absence branch — the one // upstream evaluates unconditionally — makes that question moot instead of // load-bearing. // // See [`clear_runner_credentials`] for why absence is the branch we aim at // and why only `NotFound` counts as success. clear_runner_credentials(RUNNER_CREDENTIALS)?; // Restart the in-container runner so it reads the new token and registers. let out = Command::new("systemctl") .args(["--machine=hive-ci", "restart", "gitea-runner-hive.service"]) .output() .await .context("systemctl restart gitea-runner-hive.service in hive-ci")?; if !out.status.success() { bail!( "systemctl restart gitea-runner-hive.service in hive-ci exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(( String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), )) } /// `ControlInfraContainer` — start/stop/restart a hive infrastructure /// service via `systemctl `. The [`InfraContainer`] enum is /// the allowlist: serde already rejected any unknown / unsafe name /// (hive-c0re has no variant, so a stop can't sever the daemon socket) at /// deserialisation, so no root-side `.contains()` check is needed here. /// Serves both the hive-wide `hivectl stop`/`start` flow and an /// `infra_admin` agent's `restart` (action = Restart). /// /// ⚠️ The unit is derived from the variant, never sent by the caller — /// which is what keeps this from being a general `systemctl` pass-through. /// It is not always `container@.service`: the gateway resolves to the /// host's `nginx.service`. async fn control_infra_container( container: InfraContainer, action: InfraAction, ) -> Result<(String, String)> { let verb = action.systemctl_verb(); let unit = container.service_unit(); let out = Command::new("systemctl") .args([verb, &unit]) .output() .await .with_context(|| format!("systemctl {verb} {unit}"))?; if !out.status.success() { bail!( "systemctl {verb} {unit} exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } tracing::info!(target: "infra-control", "{verb} {unit}"); Ok(( String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), )) } /// Create/overwrite `dir/filename` at 0600 without following a symlink at the /// leaf, returning the open fd for the caller to `fchown`. `filename` must be a /// single plain component (no `/`, `.`, `..`) — the leaf sits in an /// agent-writable dir, so `O_NOFOLLOW` refuses a planted symlink (`ELOOP`) /// instead of letting this root-privileged write/chmod be redirected at another /// file; `O_WRONLY` refuses a directory leaf (`EISDIR`); `O_TRUNC` keeps the /// overwrite semantics for an existing regular file. `.mode(0o600)` sets the /// create mode; the explicit `fchmod` after (on the fd, not a re-resolved path) /// tightens an already-existing file and dodges umask. The returned fd is the /// exact inode the write hit, so the caller's `fchown` is TOCTOU-immune. fn write_state_file_nofollow(dir: &Path, filename: &str, content: &str) -> Result { use std::io::Write as _; use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; if filename.is_empty() || filename == "." || filename == ".." || filename.contains('/') { bail!("write_state_file_nofollow: refusing non-plain filename {filename:?}"); } let path = dir.join(filename); let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .custom_flags(libc::O_NOFOLLOW) .open(&path) .with_context(|| format!("open (no-follow) {}", path.display()))?; file.write_all(content.as_bytes()) .with_context(|| format!("write {}", path.display()))?; file.set_permissions(std::fs::Permissions::from_mode(0o600)) .with_context(|| format!("chmod 600 {}", path.display()))?; Ok(file) } /// Sidecar written alongside an extra matrix account's token /// (`matrix-account-.json`) so `hive-matrix-mcp` can auto-discover /// the account's homeserver without a static `matrixAccounts` config /// entry. Read side: `hive-matrix-mcp/src/accounts.rs`'s /// `read_account_homeserver` (deliberately reads via a bare /// `serde_json::Value` rather than this shape — that side treats a /// malformed/missing sidecar as "skip this account" rather than an /// error, so it stays loosely typed; this side is the one place the /// file is written, so it gets the precise shape). #[derive(Serialize)] struct MatrixAccountSidecar<'a> { homeserver: &'a str, } /// Sidecar written alongside a dashboard-provisioned extra forge /// account's token (`forge-