Watch
0
0
Fork
You've already forked hyperhive
0

run opted-in agents in a user namespace with a dedicated data owner

This commit is contained in:
atlas 2026-11-01 13:50:53 +01:00 • committed by mara
commit bfdf603d12

View file

@ -50,6 +50,9 @@ pub(crate) fn handle_write_nspawn_flags(
);
false
});
// After the salvage, so the mount points land in the root the start will
// snapshot.
let userns = crate::userns::activate(container, agent, binds)?;
write_nspawn_flags(
container,
binds,
@ -57,6 +60,7 @@ pub(crate) fn handle_write_nspawn_flags(
load_credentials,
&hive_facts,
ephemeral,
userns,
)?;
Ok((String::new(), String::new()))
}
@ -364,6 +368,7 @@ fn write_nspawn_flags(
load_credentials: &[CredentialMount],
hive_facts: &str,
ephemeral: bool,
userns: bool,
) -> Result<()> {
use std::fmt::Write as _;
use std::os::unix::fs::MetadataExt as _;
@ -371,21 +376,13 @@ fn write_nspawn_flags(
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
let lines: Vec<&str> = original
.lines()
.filter(|line| {
let t = line.trim_start();
!t.starts_with("EXTRA_NSPAWN_FLAGS=")
&& !t.starts_with("PRIVATE_NETWORK=")
&& !t.starts_with("HOST_ADDRESS=")
&& !t.starts_with("LOCAL_ADDRESS=")
&& !t.starts_with("HOST_ADDRESS6=")
&& !t.starts_with("LOCAL_ADDRESS6=")
&& !t.starts_with("HOST_BRIDGE=")
})
.filter(|line| !is_owned_var(line))
.collect();
let mut out = lines.join("\n");
if !out.is_empty() {
out.push('\n');
}
out.push_str(private_users_line(userns));
{
let iso = isolation;
out.push_str("PRIVATE_NETWORK=1\n");
@ -406,7 +403,7 @@ fn write_nspawn_flags(
out.push_str("LOCAL_ADDRESS6=\n");
let _ = writeln!(out, "HOST_BRIDGE={}", iso.bridge);
}
let flags_joined = nspawn_flags(binds, load_credentials, ephemeral).join(" ");
let flags_joined = nspawn_flags(binds, load_credentials, ephemeral, userns).join(" ");
let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
// Keeps the owner and mode `nixos-container` created the file with; this
// helper only rewrites its lines.
@ -432,24 +429,57 @@ fn write_nspawn_flags(
Ok(())
}
/// Whether a line of the container conf is one `write_nspawn_flags` writes
/// itself, and so drops before writing it again. Dropped for every container,
/// not only those it writes it for: a `PRIVATE_USERS=pick` left behind would
/// keep a container namespaced after it left the list.
fn is_owned_var(line: &str) -> bool {
let t = line.trim_start();
[
"EXTRA_NSPAWN_FLAGS=",
"PRIVATE_NETWORK=",
"PRIVATE_USERS=",
"HOST_ADDRESS=",
"LOCAL_ADDRESS=",
"HOST_ADDRESS6=",
"LOCAL_ADDRESS6=",
"HOST_BRIDGE=",
]
.iter()
.any(|var| t.starts_with(var))
}
/// The conf line that makes nixos-container pass `--private-users=pick`.
fn private_users_line(userns: bool) -> &'static str {
if userns { "PRIVATE_USERS=pick\n" } else { "" }
}
/// The flags `EXTRA_NSPAWN_FLAGS` carries, in the order nspawn needs them.
///
/// `--ephemeral` runs the container on a snapshot of its root and drops the
/// snapshot at stop; `crate::rootfs` has made the root fit for that before
/// the flag is asked for. The git overlays and credentials follow the binds
/// they refer to.
///
/// `userns` mounts every bind `owneridmap` and idmaps the root instead of
/// chowning it, so a different `pick` range never walks the tree.
fn nspawn_flags(
binds: &[BindMount],
load_credentials: &[CredentialMount],
ephemeral: bool,
userns: bool,
) -> Vec<String> {
let mut flags: Vec<String> = ephemeral
.then(|| "--ephemeral".to_owned())
.into_iter()
.collect();
if userns {
flags.push("--private-users-ownership=map".to_owned());
}
let idmap = if userns { ":owneridmap" } else { "" };
flags.extend(binds.iter().map(|b| {
let flag = if b.read_only { "--bind-ro" } else { "--bind" };
format!("{flag}={}:{}", b.host_path, b.container_path)
format!("{flag}={}:{}{idmap}", b.host_path, b.container_path)
}));
flags.extend(git_overlay_flags(binds));
// Credential forwarding: nspawn loads each host secret into the
@ -621,9 +651,9 @@ pub(crate) fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &
mod tests {
use super::{
BindMount, HiveFacts, NetworkIsolation, git_overlay_flags, handle_write_nspawn_flags,
hive_priv_exe, limits_dropin_body, nspawn_flags, publish_file, remove_service_dropin,
render_hive_facts, rootfs_check_command, validate_container_system_name,
write_bridge_dns_marker_in, write_resource_limits,
hive_priv_exe, is_owned_var, limits_dropin_body, nspawn_flags, private_users_line,
publish_file, remove_service_dropin, render_hive_facts, rootfs_check_command,
validate_container_system_name, write_bridge_dns_marker_in, write_resource_limits,
};
use crate::shared::test_support::{entries, scratch};
@ -1072,14 +1102,55 @@ mod tests {
#[test]
fn ephemeral_is_passed_only_when_asked() {
let binds = [bind("/agents/atlas/work")];
let with = nspawn_flags(&binds, &[], true);
let with = nspawn_flags(&binds, &[], true, false);
assert_eq!(with[0], "--ephemeral", "{with:?}");
assert!(with.iter().any(|f| f.starts_with("--bind-ro=")), "{with:?}");
let without = nspawn_flags(&binds, &[], false);
let without = nspawn_flags(&binds, &[], false, false);
assert!(!without.iter().any(|f| f == "--ephemeral"), "{without:?}");
assert_eq!(without.len() + 1, with.len());
}
/// Every bind, read-only ones included, is mounted `owneridmap` for a
/// namespaced agent and none is for any other; the root is idmapped, not
/// chowned. The `false` arm is the control that the lookup finds the binds.
#[test]
fn binds_are_owneridmap_only_for_a_namespaced_agent() {
let binds = [bind("/knowledge"), bind("/agents/a/config")];
let plain = nspawn_flags(&binds, &[], false, false);
assert!(
plain.iter().any(|f| f.starts_with("--bind-ro=")),
"{plain:?}"
);
assert!(
plain
.iter()
.all(|f| !f.contains("idmap") && !f.contains("ownership")),
"{plain:?}"
);
let userns = nspawn_flags(&binds, &[], false, true);
assert!(
userns.contains(&"--private-users-ownership=map".to_owned()),
"{userns:?}"
);
let mounts: Vec<&String> = userns.iter().filter(|f| f.starts_with("--bind")).collect();
assert_eq!(mounts.len(), 2, "{userns:?}");
assert!(
mounts.iter().all(|f| f.ends_with(":owneridmap")),
"{userns:?}"
);
}
/// `PRIVATE_USERS=` is dropped from the conf whatever the agent now is, so
/// leaving the list cannot leave the line behind. `AUTO_START=` is the
/// control: a line that is not ours stays.
#[test]
fn the_private_users_line_is_ours_to_drop_and_to_write() {
assert!(is_owned_var("PRIVATE_USERS=pick"));
assert!(!is_owned_var("AUTO_START=1"));
assert_eq!(private_users_line(true), "PRIVATE_USERS=pick\n");
assert_eq!(private_users_line(false), "");
}
/// The swarm's forge and matrix containers are not the hive's: every
/// request that names a container as a string refuses them before
/// touching the filesystem. Control: an agent container name passes.