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)
|
/// - `/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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue