hive-priv: make the tmpfiles modes assertable

#4195 fixed `d /run/hyperhive` to 0751, matching hive-c0re.service's own
RuntimeDirectoryMode and docs/trust-boundary/boundary.md. Nothing stops
it drifting back: the mode is a string literal inside a function that
writes a file and then shells out to systemd-tmpfiles, and a test can do
neither of those things.

`agent_tmpfiles_content` splits the pure content builder out so the modes
can be asserted at all. Two tests:

- /run/hyperhive is 0751, and explicitly NOT 0750 — the regression #4195
  fixed. 0750 denies the `o=--x` traversal a `hive-admin` member who is
  not in `hive-core` needs to reach host.sock, before the socket's own
  0660 gate is ever consulted.
- the per-agent socket dir line, and that the body grows with the roster.

A mode nobody can assert is a mode that drifts. This adds no behaviour
change of its own; it pins the one #4195 landed.

Refs #4195.
This commit is contained in:
atlas 2026-09-11 08:22:30 +02:00 committed by mara
commit 6e190eeef1

View file

@ -3007,13 +3007,14 @@ fn write_bridge_dns_marker(container: &str, isolation: &NetworkIsolation) -> Res
/// - `/run/hive-agent/<name>` (web socket dir, bind-mounted into container) /// - `/run/hive-agent/<name>` (web socket dir, bind-mounted into container)
const TMPFILES_PATH: &str = "/etc/tmpfiles.d/hyperhive-agents.conf"; const TMPFILES_PATH: &str = "/etc/tmpfiles.d/hyperhive-agents.conf";
async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, String)> { /// The `tmpfiles.d` body, built without touching the filesystem.
///
/// Split out of `sync_agent_tmpfiles` so the modes below can be asserted: the
/// caller writes the file and then shells out to `systemd-tmpfiles`, neither of
/// which a test can do, and a mode nobody can assert is a mode that drifts.
fn agent_tmpfiles_content(agents: &[AgentTmpfilesEntry]) -> String {
use std::fmt::Write as _; use std::fmt::Write as _;
for entry in agents {
validate_agent_name(&entry.name)?;
}
// Build tmpfiles.d content. Root dirs first, then per-agent.
let mut content = let mut content =
String::from("# managed by hive-c0re — do not edit (regenerated on spawn/destroy)\n"); String::from("# managed by hive-c0re — do not edit (regenerated on spawn/destroy)\n");
// Parent dirs — created with permissive mode so hive-c0re can make subdirs. // Parent dirs — created with permissive mode so hive-c0re can make subdirs.
@ -3075,6 +3076,14 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S
writeln!(content, "d {SOCKET_DIR_ROOT}/{name} 0777 root root -").ok(); writeln!(content, "d {SOCKET_DIR_ROOT}/{name} 0777 root root -").ok();
} }
} }
content
}
async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, String)> {
for entry in agents {
validate_agent_name(&entry.name)?;
}
let content = agent_tmpfiles_content(agents);
// Atomic write: write to a tmp file then rename so a concurrent reader // Atomic write: write to a tmp file then rename so a concurrent reader
// always sees a complete file. // always sees a complete file.
@ -3103,11 +3112,11 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, AgentTmpfilesEntry, BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest,
clear_runner_credentials, contains_secret_shaped_run, git_overlay_flags, agent_tmpfiles_content, check_fd_agreement, clear_runner_credentials,
limits_dropin_body, matrix_token_filename, partial_name, redact_secret_line, contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, matrix_token_filename,
remove_marker_in, single_output_path, toplevel_attr, validate_account_name, partial_name, redact_secret_line, remove_marker_in, single_output_path, toplevel_attr,
write_agent_dir_file, write_state_file_nofollow, validate_account_name, write_agent_dir_file, write_state_file_nofollow,
}; };
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@ -3120,6 +3129,49 @@ mod tests {
} }
} }
/// `/run/hyperhive` is declared twice — here and as `hive-c0re.service`'s
/// `RuntimeDirectory`/`RuntimeDirectoryMode`. When the two disagree,
/// whichever runs last wins, and the loser's mode is silently re-applied on
/// every agent spawn. `0751` is the value the socket's own `0660
/// hive-admin` gate depends on: `o=--x` is what lets an admin who is not in
/// `hive-core` traverse to it at all.
#[test]
fn hyperhive_runtime_dir_keeps_the_traversable_mode() {
let out = agent_tmpfiles_content(&[]);
assert!(
out.contains("d /run/hyperhive 0751 hive-core hive-core -\n"),
"{out}"
);
// The specific regression: 0750 denies traversal before host.sock's own
// permissions are consulted.
assert!(!out.contains("d /run/hyperhive 0750"), "{out}");
}
/// Control for the assertion above: an empty roster still emits the parent
/// dirs, and a populated one adds per-agent lines — so a `contains` check
/// over this body is reading a body that was actually built.
#[test]
fn tmpfiles_body_grows_with_the_roster() {
let empty = agent_tmpfiles_content(&[]);
let one = agent_tmpfiles_content(&[AgentTmpfilesEntry {
name: "probe".to_owned(),
uid: Some(1234),
gid: Some(1234),
}]);
assert!(one.len() > empty.len(), "empty={empty}\none={one}");
assert!(
one.contains("d /run/hive-agent/probe 0751 1234 1234 -\n"),
"{one}"
);
// Every per-agent line the builder emits, so no mode here is
// unasserted — an unpinned mode is one that drifts.
assert!(
one.contains("d /run/hyperhive/agents/probe 0755 hive-core hive-core -\n"),
"{one}"
);
assert!(!empty.contains("probe"), "{empty}");
}
/// Pins the exact attr path we hand to `nix build` — it has to match /// Pins the exact attr path we hand to `nix build` — it has to match
/// `hive-c0re`'s own `lifecycle::prebuild_toplevel` construction, since /// `hive-c0re`'s own `lifecycle::prebuild_toplevel` construction, since
/// that step's whole point is warming the store for this later build. /// that step's whole point is warming the store for this later build.