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:
parent
911238ad73
commit
6e190eeef1
1 changed files with 62 additions and 10 deletions
|
|
@ -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)
|
||||
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 _;
|
||||
for entry in agents {
|
||||
validate_agent_name(&entry.name)?;
|
||||
}
|
||||
|
||||
// Build tmpfiles.d content. Root dirs first, then per-agent.
|
||||
let mut content =
|
||||
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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
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
|
||||
// always sees a complete file.
|
||||
|
|
@ -3103,11 +3112,11 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement,
|
||||
clear_runner_credentials, contains_secret_shaped_run, git_overlay_flags,
|
||||
limits_dropin_body, matrix_token_filename, partial_name, redact_secret_line,
|
||||
remove_marker_in, single_output_path, toplevel_attr, validate_account_name,
|
||||
write_agent_dir_file, write_state_file_nofollow,
|
||||
AgentTmpfilesEntry, BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest,
|
||||
agent_tmpfiles_content, check_fd_agreement, clear_runner_credentials,
|
||||
contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, matrix_token_filename,
|
||||
partial_name, redact_secret_line, remove_marker_in, single_output_path, toplevel_attr,
|
||||
validate_account_name, write_agent_dir_file, write_state_file_nofollow,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
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
|
||||
/// `hive-c0re`'s own `lifecycle::prebuild_toplevel` construction, since
|
||||
/// that step's whole point is warming the store for this later build.
|
||||
|
|
|
|||
Loading…
Reference in a new issue