Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-priv/src/files.rs

501 lines
21 KiB
Rust

//! Root-side file ops in agent-owned or agent-reachable directories:
//! `SetAgentPaused` (the pause marker in the agent's `harness/`),
//! `EnsureAgentSocketDir` (`/run/hive-agent/<name>`) 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.
pub(crate) 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/<name>` if missing.
pub(crate) fn ensure_agent_socket_dir(
name: &str,
private_users: Option<bool>,
) -> Result<(String, String)> {
ensure_socket_dir_in(Path::new(SOCKET_DIR_ROOT), name)?;
// A namespaced agent's activation cannot chown the dir through the id
// mapping, so the data owner, which is the agent user inside, gets it here.
if let Some(owner) = crate::userns::owner_for_agent(name, private_users)? {
let path = Path::new(SOCKET_DIR_ROOT).join(name);
std::os::unix::fs::lchown(&path, Some(owner), Some(owner))
.with_context(|| format!("chown {}", path.display()))?;
}
Ok((String::new(), String::new()))
}
/// Create `<root>/<name>` 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<String> = 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();
}
}