Files
ecr/src/namespace.rs
T
vhaudiquet d64da0671b fix: --kernel =PATH syntax, exit-code cleanup, bind warning in VM mode
- --kernel took an optional value greedily, swallowing the DISTRO
  positional: 'ecr --kernel alpine' failed with a missing-argument error
  and 'ecr --kernel alpine -- cmd' pulled a garbage OCI ref. require
  =PATH syntax for the kernel path instead.
- setup_namespaces returns the child's exit code (or 128+signal) instead
  of calling process::exit, so the extracted rootfs tempdir is cleaned
  up before exiting instead of leaking into /tmp.
- warn that --bind/--bind-rw are ignored when booting with --kernel.
2026-09-20 22:37:11 +02:00

509 lines
18 KiB
Rust

use anyhow::{anyhow, Context, Result};
use nix::sched::{clone, CloneFlags};
use nix::sys::signal::Signal;
use nix::unistd::{getgid, getuid, Pid};
/// RAII wrapper that closes a raw file descriptor on drop.
/// Guarantees all pipe fds are closed on every return path, including
/// clone() and setup_user_namespace() failures.
struct AutoCloseFd(i32);
impl AutoCloseFd {
fn raw(&self) -> i32 {
self.0
}
}
impl Drop for AutoCloseFd {
fn drop(&mut self) {
unsafe {
libc::close(self.0);
}
}
}
/// Clone flags for namespace creation
const CLONE_FLAGS: CloneFlags = CloneFlags::CLONE_NEWUSER
.union(CloneFlags::CLONE_NEWPID)
.union(CloneFlags::CLONE_NEWNS)
.union(CloneFlags::CLONE_NEWUTS);
/// Check if user namespaces are available
pub fn check_user_namespace() -> Result<()> {
// Check kernel.unprivileged_userns_clone on systems that have it
if let Ok(content) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") {
if content.trim() == "0" {
return Err(anyhow!(
"User namespaces not available\n\n\
Enable with:\n\
sysctl -w kernel.unprivileged_userns_clone=1\n\n\
Or check AppArmor profile restrictions."
));
}
}
// Check max_user_namespaces
if let Ok(content) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") {
if let Ok(max) = content.trim().parse::<u32>() {
if max == 0 {
return Err(anyhow!(
"User namespaces not available\n\n\
Enable with:\n\
sysctl -w user.max_user_namespaces=10000"
));
}
}
}
Ok(())
}
/// Setup namespaces and run the provided function inside them.
/// Returns the child's exit code (0 on success, 128+signal when killed by a
/// signal). Setup failures are returned as Err.
pub fn setup_namespaces<F>(f: F) -> Result<i32>
where
F: FnOnce() -> Result<()> + Send + 'static,
{
// sync pipe: parent signals child to proceed after UID/GID mapping
// done pipe: child signals parent it has finished (or exec'd the shell)
// error pipe: child writes the anyhow error chain to the parent on failure.
// The write end is O_CLOEXEC so it is automatically closed when execvp
// succeeds — the parent then reads EOF and knows there was no error.
//
// All six fds are wrapped in AutoCloseFd so they are closed on every return
// path, including clone() and setup_user_namespace() failures.
let (parent_read, parent_write, child_read, child_write, error_read, error_write);
unsafe {
let mut fds: [i32; 2] = [-1, -1];
if libc::pipe(fds.as_mut_ptr()) != 0 {
return Err(anyhow!("Failed to create sync pipe"));
}
parent_read = AutoCloseFd(fds[0]);
parent_write = AutoCloseFd(fds[1]);
// parent_read/write auto-closed if subsequent pipes fail ↑
if libc::pipe(fds.as_mut_ptr()) != 0 {
return Err(anyhow!("Failed to create done pipe"));
}
child_read = AutoCloseFd(fds[0]);
child_write = AutoCloseFd(fds[1]);
// O_CLOEXEC on the write end: if execve succeeds the kernel closes cw
// atomically, the parent's read(child_read) gets EOF immediately, and
// waitpid becomes the real wait. The done-pipe is then only used on
// the error path (f() returned Err before execve was reached).
libc::fcntl(child_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC);
if libc::pipe(fds.as_mut_ptr()) != 0 {
return Err(anyhow!("Failed to create error pipe"));
}
error_read = AutoCloseFd(fds[0]);
error_write = AutoCloseFd(fds[1]);
// Same treatment for error_write: auto-closed on exec (no error),
// written explicitly on the error path before the child exits.
libc::fcntl(error_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC);
}
// Stack for the child process
let stack_size = crate::utils::CHILD_STACK_SIZE;
let mut stack = vec![0u8; stack_size];
// Wrap f in Option to allow taking it once inside the child closure
let mut f = Some(f);
// Extract raw fds for the child closure. The child is a clone of the
// parent process and gets its own copies of all open fds; the parent's
// AutoCloseFd wrappers independently manage the parent's copies.
let pr = parent_read.raw();
let pw = parent_write.raw();
let cr = child_read.raw();
let cw = child_write.raw();
let er = error_read.raw();
let ew = error_write.raw();
// Clone with new namespaces
let pid = unsafe {
clone(
Box::new(move || {
// Close unused pipe ends in the child
libc::close(pw);
libc::close(cr);
libc::close(er);
// Wait for parent to set up UID/GID mappings
let mut buf = [0u8; 1];
libc::read(pr, buf.as_mut_ptr() as *mut libc::c_void, 1);
libc::close(pr);
// Run the function
let result = if let Some(func) = f.take() {
func()
} else {
Err(anyhow!("Function already called"))
};
// On failure, write the full error chain to the error pipe
// before signalling done, so the parent can reconstruct it.
if let Err(ref e) = result {
let msg = format!("{:#}", e);
let bytes = msg.as_bytes();
libc::write(ew, bytes.as_ptr() as *const libc::c_void, bytes.len());
}
libc::close(ew);
// Signal completion
libc::write(cw, c"done".as_ptr() as *const libc::c_void, 4);
libc::close(cw);
if result.is_ok() {
0
} else {
1
}
}),
&mut stack,
CLONE_FLAGS,
Some(Signal::SIGCHLD as i32),
)
}
.context("Failed to clone with new namespaces")?;
// clone() failure: all six AutoCloseFds drop here, closing every fd. ✓
// Parent: drop the child-side ends now that clone has succeeded.
// The child process has its own copies; dropping here closes the parent's.
drop(parent_read);
drop(child_write);
drop(error_write);
// Set up UID/GID mappings for the child
// setup_user_namespace failure: parent_write, child_read, error_read
// auto-closed by AutoCloseFd drop. ✓
setup_user_namespace(pid)?;
// Signal child to proceed
unsafe {
libc::write(parent_write.raw(), c"go".as_ptr() as *const libc::c_void, 2);
}
drop(parent_write);
// Wait for child to complete (or the exec'd shell to exit)
let mut buf = [0u8; 4];
unsafe {
libc::read(child_read.raw(), buf.as_mut_ptr() as *mut libc::c_void, 4);
}
drop(child_read);
// Read the error message written by the child, if any.
// error_write was either closed explicitly (on error) or auto-closed via
// O_CLOEXEC (on successful exec), so this read always terminates.
let child_error: Option<String> = unsafe {
let mut error_bytes = Vec::new();
let mut tmp = [0u8; crate::utils::ERROR_BUFFER_SIZE];
loop {
let n = libc::read(
error_read.raw(),
tmp.as_mut_ptr() as *mut libc::c_void,
tmp.len(),
);
if n <= 0 {
break;
}
error_bytes.extend_from_slice(&tmp[..n as usize]);
}
if error_bytes.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&error_bytes).into_owned())
}
};
drop(error_read);
// Wait for child process
let status = nix::sys::wait::waitpid(pid, None)?;
match status {
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
nix::sys::wait::WaitStatus::Exited(_, code) => {
// If the child reported an error (e.g., setup failure), return it.
// Otherwise, just forward the exit code without an error message.
if let Some(msg) = child_error {
Err(anyhow!("{}", msg))
} else {
Ok(code)
}
}
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
_ => Ok(0),
}
}
/// Set up UID/GID mappings for user namespace
fn setup_user_namespace(pid: Pid) -> Result<()> {
let uid = getuid();
let gid = getgid();
// Get the subordinate UID/GID ranges from /etc/subuid and /etc/subgid
// For unprivileged users, we need to use these ranges
let (sub_uid_start, sub_uid_count) = get_subuid_range(uid)?;
let (sub_gid_start, sub_gid_count) = get_subgid_range(gid)?;
// We map UID 0 (root inside the namespace) to the host user, then map
// IDs 1..sub_uid_count-1 to the subordinate range. A count of 0 or 1
// leaves no subordinate IDs to map and indicates a malformed /etc/subuid.
if sub_uid_count < 2 {
return Err(anyhow!(
"subuid count {} for uid {} is too small (need at least 2); \
check /etc/subuid",
sub_uid_count,
uid
));
}
if sub_gid_count < 2 {
return Err(anyhow!(
"subgid count {} for gid {} is too small (need at least 2); \
check /etc/subgid",
sub_gid_count,
gid
));
}
// Allow setgroups so apt and other tools can drop privileges
let setgroups_path = format!("/proc/{}/setgroups", pid);
std::fs::write(&setgroups_path, "allow\n")
.with_context(|| format!("Failed to write {}", setgroups_path))?;
// Use newuidmap and newgidmap for setting up mappings
// These are setuid binaries that allow unprivileged users to map subuid/subgid ranges
let pid_str = pid.to_string();
// newuidmap format: newuidmap pid ns_start host_start count ...
// Map current user to root (0), then subordinate UIDs starting from 1
let uid_result = std::process::Command::new("newuidmap")
.arg(&pid_str)
.arg("0")
.arg(uid.to_string())
.arg("1")
.arg("1")
.arg(sub_uid_start.to_string())
.arg((sub_uid_count - 1).to_string())
.status()
.context("Failed to execute newuidmap")?;
if !uid_result.success() {
return Err(anyhow!(
"newuidmap failed - ensure subuid entry exists in /etc/subuid"
));
}
// newgidmap format: newgidmap pid ns_start host_start count ...
// Map current group to root (0), then subordinate GIDs starting from 1
let gid_result = std::process::Command::new("newgidmap")
.arg(&pid_str)
.arg("0")
.arg(gid.to_string())
.arg("1")
.arg("1")
.arg(sub_gid_start.to_string())
.arg((sub_gid_count - 1).to_string())
.status()
.context("Failed to execute newgidmap")?;
if !gid_result.success() {
return Err(anyhow!(
"newgidmap failed - ensure subgid entry exists in /etc/subgid"
));
}
Ok(())
}
/// Get subordinate UID range for a user from /etc/subuid
fn get_subuid_range(uid: nix::unistd::Uid) -> Result<(u32, u32)> {
let content = std::fs::read_to_string("/etc/subuid").context("Failed to read /etc/subuid")?;
let username =
users::get_user_by_uid(uid.as_raw()).map(|u| u.name().to_string_lossy().to_string());
for line in content.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 3 {
// Check if this line matches our user (by name or UID)
let matches = parts[0] == username.as_deref().unwrap_or("")
|| parts[0].parse::<u32>().ok() == Some(uid.as_raw());
if matches {
let start: u32 = parts[1].parse().context("Invalid subuid start")?;
let count: u32 = parts[2].parse().context("Invalid subuid count")?;
return Ok((start, count));
}
}
}
Err(anyhow!(
"No subuid entry found for user {} (uid {}). \
Add one to /etc/subuid, e.g.:\n {}:100000:65536",
username.as_deref().unwrap_or("<unknown>"),
uid,
username.as_deref().unwrap_or(&uid.to_string()),
))
}
/// Get subordinate GID range for a group from /etc/subgid
fn get_subgid_range(gid: nix::unistd::Gid) -> Result<(u32, u32)> {
let content = std::fs::read_to_string("/etc/subgid").context("Failed to read /etc/subgid")?;
let groupname =
users::get_group_by_gid(gid.as_raw()).map(|g| g.name().to_string_lossy().to_string());
for line in content.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 3 {
// Check if this line matches our group (by name or GID)
let matches = parts[0] == groupname.as_deref().unwrap_or("")
|| parts[0].parse::<u32>().ok() == Some(gid.as_raw());
if matches {
let start: u32 = parts[1].parse().context("Invalid subgid start")?;
let count: u32 = parts[2].parse().context("Invalid subgid count")?;
return Ok((start, count));
}
}
}
Err(anyhow!(
"No subgid entry found for group {} (gid {}). \
Add one to /etc/subgid, e.g.:\n {}:100000:65536",
groupname.as_deref().unwrap_or("<unknown>"),
gid,
groupname.as_deref().unwrap_or(&gid.to_string()),
))
}
/// Set hostname in UTS namespace
pub fn set_hostname(distro: &str) -> Result<()> {
use nix::unistd::sethostname;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// Seed from both the current time and the PID so each invocation gets a
// distinct suffix even when called in rapid succession. Time alone is
// not sufficient: truncating nanoseconds to u8 in a tight loop produces
// the same byte every iteration.
let mut hasher = DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
// Use HOSTNAME_SUFFIX_BITS for entropy (6 hex chars = 24 bits)
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
let random_suffix: String = (0..suffix_len)
.map(|_| {
// Knuth multiplicative LCG — each step advances the full 64-bit state.
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
chars[(state >> 33) as usize % chars.len()] as char
})
.collect();
let hostname = format!("ecr-{}-{}", distro, random_suffix);
sethostname(&hostname).with_context(|| format!("Failed to set hostname to {}", hostname))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::hash::{Hash, Hasher};
#[test]
fn test_check_user_namespace_returns_ok() {
// This test verifies the function runs without panicking
// On most modern Linux systems with user namespaces enabled, this should pass
let result = check_user_namespace();
// We can't assert success because it depends on system configuration
// But we can verify it doesn't panic and returns a Result
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_hostname_format() {
// Test that hostname generation produces valid format
use std::collections::HashSet;
let mut hostnames = HashSet::new();
for _ in 0..100 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
let random_suffix: String = (0..suffix_len)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
chars[(state >> 33) as usize % chars.len()] as char
})
.collect();
let hostname = format!("ecr-test-{}", random_suffix);
// Verify hostname format
assert!(hostname.starts_with("ecr-test-"));
assert!(hostname.len() > 9); // "ecr-test-" + at least 1 char
assert!(hostname
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
hostnames.insert(hostname);
}
// With 100 iterations and good entropy, we should get many unique hostnames
assert!(
hostnames.len() > 50,
"Expected many unique hostnames, got {}",
hostnames.len()
);
}
#[test]
fn test_set_hostname_uniqueness() {
// Verify that rapid consecutive calls produce different hostnames
use std::collections::HashSet;
let mut hostnames = Vec::new();
for _ in 0..10 {
// Simulate the hostname generation logic
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
let random_suffix: String = (0..suffix_len)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
chars[(state >> 33) as usize % chars.len()] as char
})
.collect();
hostnames.push(format!("ecr-test-{}", random_suffix));
}
let unique: HashSet<_> = hostnames.iter().collect();
// Most hostnames should be unique (high entropy)
assert!(unique.len() >= 8, "Expected mostly unique hostnames");
}
}