//! Root-side file ops in agent-owned or agent-reachable directories: //! `SetAgentPaused` (the pause marker in the agent's `harness/`), //! `EnsureAgentSocketDir` (`/run/hive-agent/`) and the legacy //! `SyncAgentTmpfiles`, which only removes the tmpfiles file older builds //! wrote. use std::os::fd::AsRawFd as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_priv_sock::{AGENT_STATE_ROOT, PAUSED_MARKER_FILE}; use crate::shared::{StagedFile, ensure_plain_filename, open_dir, validate_agent_name}; /// Root of the per-agent unix-socket dirs on the host. const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; /// `SetAgentPaused`. pub(crate) fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, String)> { validate_agent_name(agent_name)?; set_agent_paused(agent_name, paused) } /// Create or remove an agent's pause marker under its harness dir. The /// marker is written empty and chowned to the harness dir's owner (the /// agent), matching how the harness itself would have created it. /// /// Both directions are idempotent: re-pausing truncates the existing empty /// marker rather than failing, and a `NotFound` on removal is the /// already-resumed case, not an error. fn set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, String)> { let harness_dir = PathBuf::from(AGENT_STATE_ROOT) .join(agent_name) .join("harness"); if paused { return write_agent_dir_file(agent_name, &harness_dir, PAUSED_MARKER_FILE, ""); } remove_marker_in(&harness_dir, PAUSED_MARKER_FILE)?; tracing::info!(agent = %agent_name, "cleared pause marker"); Ok((String::new(), String::new())) } /// Unlink `dir/filename`, treating "already gone" as success. /// /// `remove_file` unlinks the leaf itself and never follows a symlink, so an /// agent-planted link at the marker path cannot redirect this root unlink /// at another file — the same threat [`StagedFile`] closes on the create side. fn remove_marker_in(dir: &Path, filename: &str) -> Result<()> { let path = dir.join(filename); match std::fs::remove_file(&path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e).with_context(|| format!("remove {}", path.display())), } } /// Write `content` to `dir/filename` as root, chowning the result to `dir`'s /// owner so the agent process can read it back. Its caller is the pause /// marker, in `harness/`, a directory owned by the agent, which is precisely /// why it needs hive-priv at all. /// /// The file is published through a [`StagedFile`], so a reader woken by its /// appearance cannot catch it empty or half-written: several of these paths /// have a `systemd.path` unit watching them, and one of those triggers on the /// file existing rather than changing. fn write_agent_dir_file( agent_name: &str, dir: &Path, filename: &str, content: &str, ) -> Result<(String, String)> { use std::os::fd::AsRawFd as _; use std::os::unix::fs::MetadataExt as _; // Checked before `create_dir_all`, so a bad leaf does no filesystem work. ensure_plain_filename("write_agent_dir_file", filename)?; let state_dir = dir.to_path_buf(); // NOTE: `create_dir_all` is normally a no-op — lifecycle creates and chowns // the state dir during spawn. On the rare edge where the dir doesn't exist // yet (container being provisioned for the first time), the newly created dir // is root:root. The `stat state_dir` chown below will then see uid=0 and // leave the file root-owned (0600). The agent won't be able to read it until // its lifecycle completes. std::fs::create_dir_all(&state_dir) .with_context(|| format!("create state dir {}", state_dir.display()))?; // Security-critical: the (state/-owning) agent may plant a symlink or // hardlink in this dir, and `StagedFile` neither follows nor reuses one, // so this root-privileged create/write/chmod/chown can't be redirected at // an arbitrary file. let path = state_dir.join(filename); let dir_fd = open_dir(&state_dir)?; let staged = StagedFile::write(&dir_fd, &state_dir, filename, content.as_bytes(), 0o600)?; // Chown to the state dir's owner so the agent process can read the file. // fchown on the temp's fd — TOCTOU-immune (the inode the write hit, never a // swapped path). If stat fails (e.g. dir just created, owner is root), the // file stays root-owned and 0600 — still unreadable by others, just not // agent-readable. Log a warning so operators can diagnose. match dir_fd.metadata() { Ok(meta) => { // SAFETY: the temp's fd is open and owned by `staged` for the whole // call; `fchown` only mutates that inode's uid/gid. let rc = unsafe { libc::fchown(staged.file().as_raw_fd(), meta.uid(), meta.gid()) }; if rc != 0 { let e = std::io::Error::last_os_error(); tracing::warn!( agent = %agent_name, path = %path.display(), error = %e, "write_agent_dir_file: fchown failed" ); } } Err(e) => { tracing::warn!( agent = %agent_name, error = %e, "write_agent_dir_file: stat state_dir failed, leaving file root-owned" ); } } // After the chown, never before: the file must never be visible under its // final name while still root-owned. A failed publish drops `staged`, // which removes the temp and the credential in it. staged.publish()?; tracing::info!( agent = %agent_name, dir = %state_dir.display(), file = %filename, "wrote agent file" ); Ok((String::new(), String::new())) } /// Written by older hive-priv builds. Nothing writes it now, but left on disk /// systemd-tmpfiles would still apply it at every boot. const LEGACY_TMPFILES_PATH: &str = "/etc/tmpfiles.d/hyperhive-agents.conf"; /// Unlink [`LEGACY_TMPFILES_PATH`]; already absent is success. Run at every /// hive-priv start and by the legacy `SyncAgentTmpfiles` request. pub(crate) fn remove_legacy_tmpfiles() -> Result<()> { match std::fs::remove_file(LEGACY_TMPFILES_PATH) { Ok(()) => { tracing::info!("removed legacy {LEGACY_TMPFILES_PATH}"); Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e).with_context(|| format!("remove {LEGACY_TMPFILES_PATH}")), } } /// `EnsureAgentSocketDir` — create `/run/hive-agent/` if missing. pub(crate) fn ensure_agent_socket_dir(name: &str) -> Result<(String, String)> { ensure_socket_dir_in(Path::new(SOCKET_DIR_ROOT), name)?; Ok((String::new(), String::new())) } /// Create `/` as a `0751` directory owned by the caller (root in /// production), or accept an existing directory untouched. /// `name` must be an agent ident; it is validated here, before any path is /// built from it. /// /// The result is an nspawn bind source, so a symlink here would bind a host /// path of the planter's choosing into the container. Every step is relative /// to an `O_DIRECTORY|O_NOFOLLOW` fd for `root`, and an existing entry is /// checked with `AT_SYMLINK_NOFOLLOW` and refused unless it is a directory. /// /// An existing directory keeps its owner and mode: the container's activation /// hands it to the agent user, and re-asserting root here would undo that on /// every start. fn ensure_socket_dir_in(root: &Path, name: &str) -> Result<()> { use std::os::unix::fs::OpenOptionsExt as _; validate_agent_name(name)?; let path = root.join(name); let c_name = std::ffi::CString::new(name) .with_context(|| format!("agent name {name:?} contains a NUL byte"))?; let root_dir = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(root) .with_context(|| format!("open (no-follow) {}", root.display()))?; // SAFETY: `root_dir` is an open directory fd and `c_name` NUL-terminated. if unsafe { libc::mkdirat(root_dir.as_raw_fd(), c_name.as_ptr(), 0o751) } == 0 { // mkdirat's mode is masked by the umask; the mode is part of the // contract, so it is set explicitly. // SAFETY: as above. let rc = unsafe { libc::fchmodat( root_dir.as_raw_fd(), c_name.as_ptr(), 0o751, libc::AT_SYMLINK_NOFOLLOW, ) }; if rc != 0 { return Err(std::io::Error::last_os_error()) .with_context(|| format!("chmod 0751 {}", path.display())); } tracing::info!(path = %path.display(), "created agent socket dir"); return Ok(()); } let err = std::io::Error::last_os_error(); if err.kind() != std::io::ErrorKind::AlreadyExists { return Err(err).with_context(|| format!("create {}", path.display())); } // SAFETY: `stat` is plain old data; the zeroed value is only read after // `fstatat` has filled it in. let mut st: libc::stat = unsafe { std::mem::zeroed() }; // SAFETY: as for `mkdirat`; `st` is a valid, writable `stat`. let rc = unsafe { libc::fstatat( root_dir.as_raw_fd(), c_name.as_ptr(), &raw mut st, libc::AT_SYMLINK_NOFOLLOW, ) }; if rc != 0 { return Err(std::io::Error::last_os_error()) .with_context(|| format!("stat (no-follow) {}", path.display())); } if st.st_mode & libc::S_IFMT != libc::S_IFDIR { bail!( "{} exists but is not a directory; refusing it as a bind source", path.display() ); } Ok(()) } #[cfg(test)] mod tests { use super::{PAUSED_MARKER_FILE, ensure_socket_dir_in, remove_marker_in, write_agent_dir_file}; use crate::shared::test_support::{entries, scratch}; use crate::shared::{StagedFile, open_dir}; use std::path::PathBuf; #[test] fn rejects_non_plain_filenames() { let dir = scratch(); let dir_fd = open_dir(&dir).unwrap(); for bad in ["", ".", "..", "a/b", "/etc/passwd", "../escape", "sub/tok"] { assert!( StagedFile::write(&dir_fd, &dir, bad, b"x", 0o600).is_err(), "must reject filename {bad:?}" ); // The wrapper's own check runs before its `create_dir_all`. // Pointing it at a directory that does not exist yet is what makes // this arm bite: the property worth pinning is that a bad leaf // fails BEFORE any root-privileged filesystem work. let absent = dir.join("never-created"); assert!( write_agent_dir_file("a", &absent, bad, "x").is_err(), "wrapper must reject filename {bad:?}" ); assert!( !absent.exists(), "rejected write created a directory for {bad:?}" ); } std::fs::remove_dir_all(&dir).ok(); } /// A published file arrives complete and alone. The residue arm is the /// load-bearing one: a stranded temp holds the same secret, is owned by /// root, and nothing else in the system would ever remove it. #[test] fn publishing_leaves_only_the_finished_file() { use std::os::unix::fs::PermissionsExt as _; let dir = scratch(); let names = |d: &PathBuf| { let mut v: Vec = std::fs::read_dir(d) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); v.sort(); v }; assert!(names(&dir).is_empty(), "control: scratch starts empty"); let path = dir.join("matrix-token-alice"); for content in ["SECRET", "ROTATED"] { write_agent_dir_file("a", &dir, "matrix-token-alice", content).unwrap(); assert_eq!(names(&dir), ["matrix-token-alice"], "temp left behind"); assert_eq!(std::fs::read_to_string(&path).unwrap(), content); } let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "credential must be 0600 once published"); std::fs::remove_dir_all(&dir).ok(); } /// A failed publish must not strand the temp. #[test] fn a_failed_publish_removes_the_temp() { let dir = scratch(); // A directory at the destination fails the `rename` (EISDIR) without // needing to drop privileges; the write and chown ahead of it still run. std::fs::create_dir(dir.join("forge-token")).unwrap(); let err = write_agent_dir_file("a", &dir, "forge-token", "SECRET") .expect_err("publishing onto a directory must fail"); assert!( format!("{err:#}").contains("publishing"), "must fail AT the publish -- failing earlier leaves no temp to \ clean up and makes the next assertion vacuous. got: {err:#}" ); assert_eq!( entries(&dir), ["forge-token"], "temp still holds the secret after a failed publish" ); std::fs::remove_dir_all(&dir).ok(); } /// The agent plants a symlink where the token goes. The rename replaces /// the link; the root-privileged write never reaches its target. #[test] fn a_symlink_at_the_name_is_replaced_not_followed() { let dir = scratch(); let target = dir.join("target"); std::fs::write(&target, "original").unwrap(); std::os::unix::fs::symlink(&target, dir.join("forge-token")).unwrap(); write_agent_dir_file("a", &dir, "forge-token", "SECRET").unwrap(); assert_eq!( std::fs::read_to_string(&target).unwrap(), "original", "symlink target must be untouched" ); let meta = std::fs::symlink_metadata(dir.join("forge-token")).unwrap(); assert!(meta.file_type().is_file(), "the link must be replaced"); assert_eq!( std::fs::read_to_string(dir.join("forge-token")).unwrap(), "SECRET" ); std::fs::remove_dir_all(&dir).ok(); } /// A missing socket dir is created `0751`, owned by the caller (root in /// production), which is the mode the container's activation expects to /// hand over and the one dialers traverse. #[test] fn socket_dir_created_0751_when_absent() { use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; let dir = scratch(); ensure_socket_dir_in(&dir, "probe").unwrap(); let meta = std::fs::symlink_metadata(dir.join("probe")).unwrap(); assert!(meta.is_dir()); assert_eq!(meta.permissions().mode() & 0o7777, 0o751); // SAFETY: `geteuid` has no preconditions. assert_eq!(meta.uid(), unsafe { libc::geteuid() }); std::fs::remove_dir_all(&dir).ok(); } /// An existing directory keeps its mode and contents: by the second start /// it belongs to the agent user, and a live socket may sit inside it. #[test] fn socket_dir_existing_directory_left_alone() { use std::os::unix::fs::PermissionsExt as _; let dir = scratch(); let sub = dir.join("probe"); std::fs::create_dir(&sub).unwrap(); std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::write(sub.join("web.sock"), "").unwrap(); ensure_socket_dir_in(&dir, "probe").unwrap(); let mode = std::fs::metadata(&sub).unwrap().permissions().mode() & 0o7777; assert_eq!(mode, 0o700, "an existing dir's mode must not be reset"); assert!(sub.join("web.sock").exists()); std::fs::remove_dir_all(&dir).ok(); } /// A symlink at the leaf would make nspawn bind wherever it points; it is /// refused, whether it points at a directory or nowhere. #[test] fn socket_dir_refuses_symlink_leaf() { use std::os::unix::fs::PermissionsExt as _; let dir = scratch(); let elsewhere = dir.join("elsewhere"); std::fs::create_dir(&elsewhere).unwrap(); std::fs::set_permissions(&elsewhere, std::fs::Permissions::from_mode(0o700)).unwrap(); std::os::unix::fs::symlink(&elsewhere, dir.join("probe")).unwrap(); std::os::unix::fs::symlink(dir.join("missing"), dir.join("dangling")).unwrap(); assert!(ensure_socket_dir_in(&dir, "probe").is_err()); assert!(ensure_socket_dir_in(&dir, "dangling").is_err()); let mode = std::fs::metadata(&elsewhere).unwrap().permissions().mode() & 0o7777; assert_eq!(mode, 0o700, "the symlink target must be untouched"); assert!(!dir.join("missing").exists()); std::fs::remove_dir_all(&dir).ok(); } /// A regular file where the dir should be is refused, not bound. #[test] fn socket_dir_refuses_non_directory_leaf() { let dir = scratch(); std::fs::write(dir.join("probe"), "x").unwrap(); let err = ensure_socket_dir_in(&dir, "probe").unwrap_err(); assert!(format!("{err:#}").contains("not a directory"), "{err:#}"); std::fs::remove_dir_all(&dir).ok(); } /// A symlinked root would place the new dir in a tree of the planter's /// choosing; the `O_NOFOLLOW` open refuses it before anything is created. #[test] fn socket_dir_refuses_symlinked_root() { let dir = scratch(); let elsewhere = dir.join("elsewhere"); std::fs::create_dir(&elsewhere).unwrap(); let root = dir.join("root"); std::os::unix::fs::symlink(&elsewhere, &root).unwrap(); assert!(ensure_socket_dir_in(&root, "probe").is_err()); assert!(!elsewhere.join("probe").exists()); std::fs::remove_dir_all(&dir).ok(); } /// Names that are not a plain agent ident are refused by validation, /// before anything is created. Runs against a scratch parent, so the /// refusal cannot come from a missing `/run/hive-agent`; `.`/`..` exist /// as directories and `Atlas` would be created, so each bad name gets /// through only if validation is gone. Control: a plain name succeeds /// in the same parent. #[test] fn socket_dir_refuses_bogus_names() { let dir = scratch(); for bad in ["", ".", "..", "../etc", "a/b", "Atlas", "a b", "a\0b"] { let err = ensure_socket_dir_in(&dir, bad).unwrap_err(); assert!( format!("{err:#}").contains("invalid name"), "agent name {bad:?} must be refused by validation, got: {err:#}" ); } assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); ensure_socket_dir_in(&dir, "atlas").unwrap(); assert!(dir.join("atlas").is_dir()); std::fs::remove_dir_all(&dir).ok(); } /// The pause marker round-trips through the same root-only path the /// credential writes use, and BOTH directions are idempotent — the /// dashboard toggle and `hivectl pause|resume` fire blind, without /// reading the current state first. #[test] fn pause_marker_create_and_remove_are_idempotent() { let dir = scratch(); let path = dir.join(PAUSED_MARKER_FILE); for _ in 0..2 { write_agent_dir_file("a", &dir, PAUSED_MARKER_FILE, "").unwrap(); assert!(path.exists(), "marker must exist after pause"); assert_eq!(std::fs::read_to_string(&path).unwrap(), ""); } for _ in 0..2 { remove_marker_in(&dir, PAUSED_MARKER_FILE).unwrap(); assert!(!path.exists(), "marker must be gone after resume"); } std::fs::remove_dir_all(&dir).ok(); } /// A resume must never follow an agent-planted symlink at the marker /// path: this unlink runs as root, so following it would let an agent /// delete an arbitrary file on the host. #[test] fn resume_unlinks_the_symlink_not_its_target() { let dir = scratch(); let target = dir.join("target"); std::fs::write(&target, "original").unwrap(); let link = dir.join(PAUSED_MARKER_FILE); std::os::unix::fs::symlink(&target, &link).unwrap(); remove_marker_in(&dir, PAUSED_MARKER_FILE).unwrap(); // `exists()` follows the link, so it can't tell "link removed" from // "target removed, dangling link left" — stat the link itself. assert!( std::fs::symlink_metadata(&link).is_err(), "the link itself must be unlinked" ); assert_eq!( std::fs::read_to_string(&target).unwrap(), "original", "symlink target must survive the root unlink" ); std::fs::remove_dir_all(&dir).ok(); } }