#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) const MAX_NAMESPACE_SOCKETS: usize = crate::egress_proxy::MAX_EGRESS_TCP_PORTS + 1;
pub(crate) const NAMESPACE_HANDLE_COUNT: usize = 3;
pub(crate) const MAX_SESSION_NETNS_FDS: usize = MAX_NAMESPACE_SOCKETS + NAMESPACE_HANDLE_COUNT;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreationStep {
Unshare = 1,
IdentityMaps = 2,
Loopback = 3,
LocalRoute = 4,
TcpListener = 5,
Resolver = 6,
NamespaceHandles = 7,
HandOff = 8,
PidNamespace = 9,
Holder = 10,
}
impl CreationStep {
const ALL: [CreationStep; 10] = [
Self::Unshare,
Self::IdentityMaps,
Self::Loopback,
Self::LocalRoute,
Self::TcpListener,
Self::Resolver,
Self::NamespaceHandles,
Self::HandOff,
Self::PidNamespace,
Self::Holder,
];
pub(crate) fn from_code(code: u32) -> Option<Self> {
Self::ALL.into_iter().find(|step| *step as u32 == code)
}
#[must_use]
pub fn describe(self) -> &'static str {
match self {
Self::Unshare => "unshare(CLONE_NEWUSER | CLONE_NEWNET)",
Self::IdentityMaps => "writing uid_map/gid_map",
Self::Loopback => "bringing lo up",
Self::LocalRoute => "installing the local default route",
Self::TcpListener => "binding a TCP listener inside the namespace",
Self::Resolver => "binding UDP 53 inside the namespace",
Self::NamespaceHandles => {
"opening /proc/self/ns/user, /proc/self/ns/net and /proc/self/ns/pid_for_children"
}
Self::HandOff => "handing the namespace to the runtime",
Self::PidNamespace => "unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID)",
Self::Holder => "forking the PID namespace's holder process",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NamespaceAttemptError {
Step { step: CreationStep, errno: i32 },
Creator(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EgressNamespaceBlocker {
CapabilityGrantMissing,
KernelSupportMissing,
CreatorUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EgressNamespaceFailure {
pub blocker: EgressNamespaceBlocker,
pub failed_call: String,
pub errno: Option<i32>,
pub userns_grant: crate::sealed::UsernsGrant,
pub attachment: crate::sealed::ProfileAttachment,
}
impl EgressNamespaceFailure {
#[must_use]
pub fn summary(&self) -> String {
match self.errno {
Some(errno) => format!("{} failed with {}", self.failed_call, errno_label(errno)),
None => self.failed_call.clone(),
}
}
#[must_use]
pub fn reason(&self) -> String {
let what = match self.errno {
Some(errno) => format!(
"{} failed with {} ({})",
self.failed_call,
errno_label(errno),
errno_words(errno)
),
None => self.failed_call.clone(),
};
format!("{what}. {} {}", self.cause(), self.readings())
}
fn step(&self) -> Option<CreationStep> {
self.errno?;
CreationStep::ALL
.into_iter()
.find(|step| step.describe() == self.failed_call)
}
fn cause(&self) -> String {
match self.blocker {
EgressNamespaceBlocker::KernelSupportMissing => {
kernel_cause(self.step(), self.errno).to_string()
}
EgressNamespaceBlocker::CreatorUnavailable => {
"Likely cause: the namespace creator could not run or stopped before reporting — \
a process or descriptor limit, or memory, ran out, or a policy killed it (its \
account then reads `killed by signal`) — so whether this host can create \
the namespace is unknown, and the capsule is refused rather than run without \
it. Re-run once the limit is relieved, or allow the creator's syscalls."
.to_string()
}
EgressNamespaceBlocker::CapabilityGrantMissing
if self.step() == Some(CreationStep::Holder) =>
{
format!(
"{} Likely cause: a process limit (RLIMIT_NPROC, or the pids.max of the cgroup \
mur runs in) or memory ran out. Re-run once the limit is relieved.",
step_context(CreationStep::Holder).unwrap_or_default()
)
}
EgressNamespaceBlocker::CapabilityGrantMissing => {
let cause = grant_cause(self.userns_grant, &self.attachment);
match self.step().and_then(step_context) {
Some(context) => format!("{context} {cause}"),
None => cause,
}
}
}
}
fn readings(&self) -> String {
host_readings(self.userns_grant, &self.attachment)
}
}
pub(crate) fn host_readings(
userns_grant: crate::sealed::UsernsGrant,
attachment: &crate::sealed::ProfileAttachment,
) -> String {
use crate::sealed::{ProfileAttachment, UsernsGrant};
let restriction = match userns_grant {
UsernsGrant::ApparmorAbsent => "AppArmor not enabled",
UsernsGrant::RestrictionDisabledHostWide => {
"kernel.apparmor_restrict_unprivileged_userns=0"
}
UsernsGrant::ProfileConfining | UsernsGrant::Withheld => {
"kernel.apparmor_restrict_unprivileged_userns=1"
}
};
let profile = match attachment {
ProfileAttachment::Sealed { profile } | ProfileAttachment::Other { profile } => {
profile.as_str()
}
ProfileAttachment::Unconfined => "unconfined",
ProfileAttachment::NotReported => "not reported",
};
format!("Host readings: {restriction}; AppArmor profile confining mur: {profile}.")
}
fn step_context(step: CreationStep) -> Option<&'static str> {
match step {
CreationStep::Unshare => None,
CreationStep::IdentityMaps => Some(
"The namespace was created and could not be owned: writing its identity map was \
refused.",
),
CreationStep::Loopback | CreationStep::LocalRoute => Some(
"The namespace was created and owned, and configuring it was refused — the mark of a \
confinement that withholds CAP_NET_ADMIN inside the namespace.",
),
CreationStep::PidNamespace => Some(
"The user and network namespaces were created without CLONE_NEWPID, and creating a \
PID namespace with them was refused — the mark of a seccomp profile or confinement \
that permits unshare but not CLONE_NEWPID.",
),
CreationStep::Holder => Some(
"The namespaces were created, and forking the PID namespace's first process failed.",
),
CreationStep::TcpListener
| CreationStep::Resolver
| CreationStep::NamespaceHandles
| CreationStep::HandOff => {
Some("The namespace was created and configured, and handing it to the runtime failed.")
}
}
}
fn grant_cause(
userns_grant: crate::sealed::UsernsGrant,
attachment: &crate::sealed::ProfileAttachment,
) -> String {
use crate::sealed::{
ProfileAttachment, UsernsGrant, SEALED_APPARMOR_PROFILE_NAME, SEALED_APPARMOR_PROFILE_PATH,
};
const CONTAINER_REMEDY: &str = "Inside a container, the container runtime's seccomp \
profile is the usual blocker: it refuses unshare(CLONE_NEWUSER) and setns whatever \
capabilities the container holds. Run the container with a seccomp profile that permits \
them (Docker: `--security-opt seccomp=<file>`; Kubernetes: `seccompProfile: \
Localhost`), or create the network namespace outside the container and run mur inside \
it.";
match (userns_grant, attachment) {
(UsernsGrant::ApparmorAbsent, _) => format!(
"Likely cause: not AppArmor, which is not enabled on this host — a container \
runtime's seccomp profile, or another security module's policy. \
{CONTAINER_REMEDY} On a bare host, check the policy of the security module in use."
),
(_, ProfileAttachment::Other { profile }) => format!(
"Likely cause: the AppArmor profile `{profile}` confines mur. \
kernel.apparmor_restrict_unprivileged_userns does not govern a process another \
profile confines: that profile, and a container runtime's seccomp profile, decide \
whether it may create a user namespace. Run the container with an AppArmor profile \
and a seccomp profile that permit unshare(CLONE_NEWUSER) and setns (Docker: \
`--security-opt apparmor=<profile> --security-opt seccomp=<file>`; Kubernetes: \
`appArmorProfile` and `seccompProfile: Localhost`), or create the network namespace \
outside the container and run mur inside it."
),
(UsernsGrant::ProfileConfining, _) | (_, ProfileAttachment::Sealed { .. }) => format!(
"Likely cause: the {SEALED_APPARMOR_PROFILE_NAME} AppArmor profile confines mur and \
the creation still failed, so the profile the kernel has loaded may be stale. Reload \
it with `sudo apparmor_parser -r {SEALED_APPARMOR_PROFILE_PATH}` (for a checkout \
build, re-run `scripts/install-dev-apparmor.sh`), and check `mur doctor`'s \
comparison of the installed profile with the one this build ships."
),
(UsernsGrant::RestrictionDisabledHostWide, _) => format!(
"Likely cause: not AppArmor, whose unprivileged-userns restriction is off on this host \
— a container runtime's seccomp profile, or another security module's policy. \
{CONTAINER_REMEDY}"
),
(UsernsGrant::Withheld, _) => format!(
"Likely cause: AppArmor's unprivileged-userns restriction \
(kernel.apparmor_restrict_unprivileged_userns=1) applies to this binary, which no \
{SEALED_APPARMOR_PROFILE_NAME} profile confines. Install and load the profile shipped \
with mur, `sudo install -m 644 packaging/apparmor/{SEALED_APPARMOR_PROFILE_NAME} \
{SEALED_APPARMOR_PROFILE_PATH} && sudo apparmor_parser -r \
{SEALED_APPARMOR_PROFILE_PATH}` (or re-run the mur installer as root), then re-run. \
Building out of a checkout, where the binary sits at ./target/{{debug,release}}/mur \
and no shipped profile attaches to it: run `scripts/install-dev-apparmor.sh`, which \
generates and loads the same grant for those two paths. LAST RESORT, only where a \
profile genuinely cannot be loaded: `sudo sysctl -w \
kernel.apparmor_restrict_unprivileged_userns=0` — this removes unprivileged-userns \
hardening from every program on the machine, not just from mur, and is not the \
configuration murmur ships."
),
}
}
fn kernel_cause(step: Option<CreationStep>, errno: Option<i32>) -> &'static str {
if step == Some(CreationStep::PidNamespace) {
return match errno {
Some(libc::ENOSPC) => {
"Likely cause: the kernel's limit on PID namespaces — user.max_pid_namespaces is \
0, or every namespace it allows is in use. Raise it where it is set \
(`sudo sysctl -w user.max_pid_namespaces=10000`), then re-run."
}
_ => {
"Likely cause: this kernel does not provide PID namespaces (built with \
CONFIG_PID_NS=n). Run mur on a kernel built with CONFIG_PID_NS=y."
}
};
}
match errno {
Some(libc::ENOSPC) => {
"Likely cause: the kernel's limit on user namespaces — user.max_user_namespaces is 0, \
or every namespace it allows is in use. Raise it where it is set, on the host or in \
the user namespace mur runs in (`sudo sysctl -w user.max_user_namespaces=10000`), \
then re-run."
}
Some(libc::EUSERS) => {
"Likely cause: the kernel's nesting limit for user namespaces — mur already runs \
inside as many nested user namespaces as the kernel allows. Run it fewer levels \
deep."
}
_ => {
"Likely cause: this kernel does not provide user namespaces (built with \
CONFIG_USER_NS=n). Run mur on a kernel built with CONFIG_USER_NS=y."
}
}
}
pub(crate) fn errno_name(errno: i32) -> Option<&'static str> {
Some(match errno {
libc::EPERM => "EPERM",
libc::ENOENT => "ENOENT",
libc::EACCES => "EACCES",
libc::EINVAL => "EINVAL",
libc::ENOSPC => "ENOSPC",
libc::ENOSYS => "ENOSYS",
libc::EUSERS => "EUSERS",
libc::ENOMEM => "ENOMEM",
libc::EAGAIN => "EAGAIN",
libc::EADDRINUSE => "EADDRINUSE",
libc::EMFILE => "EMFILE",
libc::ENFILE => "ENFILE",
libc::EROFS => "EROFS",
libc::ENOTDIR => "ENOTDIR",
libc::EBUSY => "EBUSY",
_ => return None,
})
}
pub(crate) fn errno_label(errno: i32) -> String {
errno_name(errno).map_or_else(|| format!("errno {errno}"), str::to_string)
}
pub(crate) fn errno_words(errno: i32) -> String {
let rendered = std::io::Error::from_raw_os_error(errno).to_string();
rendered
.strip_suffix(&format!(" (os error {errno})"))
.map_or_else(|| rendered.clone(), str::to_string)
}
pub(crate) fn egress_namespace_failure(
is_linux: bool,
attempt: Result<(), NamespaceAttemptError>,
userns_grant: crate::sealed::UsernsGrant,
attachment: crate::sealed::ProfileAttachment,
) -> Option<EgressNamespaceFailure> {
if !is_linux {
return None;
}
attempt
.err()
.map(|error| failure_of(error, userns_grant, attachment))
}
fn failure_of(
error: NamespaceAttemptError,
userns_grant: crate::sealed::UsernsGrant,
attachment: crate::sealed::ProfileAttachment,
) -> EgressNamespaceFailure {
let (blocker, failed_call, errno) = match error {
NamespaceAttemptError::Step { step, errno } => {
let blocker = match (step, errno) {
(
CreationStep::Unshare,
libc::ENOSPC | libc::EINVAL | libc::ENOSYS | libc::EUSERS,
)
| (CreationStep::PidNamespace, libc::ENOSPC | libc::EINVAL) => {
EgressNamespaceBlocker::KernelSupportMissing
}
_ => EgressNamespaceBlocker::CapabilityGrantMissing,
};
(blocker, step.describe().to_string(), Some(errno))
}
NamespaceAttemptError::Creator(detail) => {
(EgressNamespaceBlocker::CreatorUnavailable, detail, None)
}
};
EgressNamespaceFailure {
blocker,
failed_call,
errno,
userns_grant,
attachment,
}
}
#[must_use]
pub fn detect_egress_namespace_failure() -> Option<EgressNamespaceFailure> {
crate::sandbox::HostProbe::probe().egress_namespace_failure()
}
pub fn skip_without_egress_namespace(test_name: &str) -> bool {
match detect_egress_namespace_failure() {
Some(failure) => {
crate::runtime_err!("[SKIP-HOST] {test_name}: {}", failure.reason());
true
}
None => false,
}
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn host_pid_in_session(runtime_pid: u32, namespace_pid: i32) -> Option<i32> {
host_pids_in_sessions(runtime_pid, namespace_pid)
.into_iter()
.next()
}
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn host_pid_in_session(_runtime_pid: u32, namespace_pid: i32) -> Option<i32> {
(namespace_pid > 0).then_some(namespace_pid)
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn host_pids_in_sessions(runtime_pid: u32, namespace_pid: i32) -> Vec<i32> {
let Ok(fds) = std::fs::read_dir(format!("/proc/{runtime_pid}/fd")) else {
return Vec::new();
};
let held: Vec<std::path::PathBuf> = fds
.filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok())
.filter(|target| target.to_string_lossy().starts_with("pid:["))
.collect();
if held.is_empty() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir("/proc") else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
.filter(|pid| {
let in_session = std::fs::read_link(format!("/proc/{pid}/ns/pid"))
.is_ok_and(|namespace| held.contains(&namespace));
in_session
&& std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(|status| {
status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.and_then(|fields| fields.split_whitespace().last())
.and_then(|last| last.parse::<i32>().ok())
== Some(namespace_pid)
})
})
.collect()
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn host_pid_in_workdir(workdir: &std::path::Path, namespace_pid: i32) -> Option<i32> {
let workdir = std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf());
std::fs::read_dir("/proc")
.ok()?
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
.find(|pid| {
std::fs::read_link(format!("/proc/{pid}/cwd")).is_ok_and(|cwd| cwd == workdir)
&& std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(|status| {
let fields: Vec<&str> = status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.map(|fields| fields.split_whitespace().collect())
.unwrap_or_default();
fields.len() >= 2
&& fields.last().and_then(|last| last.parse::<i32>().ok())
== Some(namespace_pid)
})
})
}
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn host_pid_in_workdir(_workdir: &std::path::Path, namespace_pid: i32) -> Option<i32> {
(namespace_pid > 0).then_some(namespace_pid)
}
pub fn check_egress_namespace(
can_spawn_subprocess: bool,
failure: Option<&EgressNamespaceFailure>,
) -> Result<(), crate::errors::RuntimeError> {
match failure {
Some(failure) if can_spawn_subprocess => Err(unavailable(failure)),
_ => Ok(()),
}
}
fn unavailable(failure: &EgressNamespaceFailure) -> crate::errors::RuntimeError {
crate::errors::RuntimeError::EgressNamespaceUnavailable {
blocker: failure.blocker,
reason: failure.reason(),
required_by: Vec::new(),
}
}
fn session_netns_creation_error(
error: NamespaceAttemptError,
userns_grant: crate::sealed::UsernsGrant,
attachment: crate::sealed::ProfileAttachment,
) -> String {
match error {
NamespaceAttemptError::Creator(detail) => {
format!("sandbox: could not create this session's network namespace: {detail}")
}
step => format!(
"sandbox: E-CAP-005: {}",
unavailable(&failure_of(step, userns_grant, attachment))
),
}
}
#[derive(Debug, Clone)]
pub(crate) struct CapsuleNetnsPlan {
pub(crate) tcp_ports: Vec<u16>,
pub(crate) uid: u32,
pub(crate) gid: u32,
#[cfg(test)]
pub(crate) force_pid_namespace_failure: bool,
}
#[cfg(test)]
thread_local! {
pub(crate) static FORCE_PID_NAMESPACE_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(test)]
pub(crate) struct ForcePidNamespaceFailure;
#[cfg(test)]
impl ForcePidNamespaceFailure {
pub(crate) fn arm() -> Self {
FORCE_PID_NAMESPACE_FAILURE.with(|flag| flag.set(true));
Self
}
}
#[cfg(test)]
impl Drop for ForcePidNamespaceFailure {
fn drop(&mut self) {
FORCE_PID_NAMESPACE_FAILURE.with(|flag| flag.set(false));
}
}
impl CapsuleNetnsPlan {
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub(crate) fn resolve(tcp_ports: Vec<u16>) -> Self {
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
Self {
tcp_ports,
uid,
gid,
#[cfg(test)]
force_pid_namespace_failure: FORCE_PID_NAMESPACE_FAILURE.with(std::cell::Cell::get),
}
}
pub(crate) fn socket_count(&self) -> usize {
self.tcp_ports.len() + 1
}
}
#[derive(Debug, Default)]
pub(crate) struct SessionNetns {
state: std::sync::Mutex<SessionNetnsState>,
#[cfg(test)]
creations: std::sync::atomic::AtomicUsize,
}
#[derive(Debug, Default)]
enum SessionNetnsState {
#[default]
NotYetCreated,
#[cfg(target_os = "linux")]
Established(linux::EstablishedNetns),
Ended,
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
pub(crate) struct SessionNetnsJoin {
user_ns: std::sync::Arc<std::os::fd::OwnedFd>,
net_ns: std::sync::Arc<std::os::fd::OwnedFd>,
pid_ns: std::sync::Arc<std::os::fd::OwnedFd>,
}
#[cfg(target_os = "linux")]
impl SessionNetnsJoin {
pub(crate) fn raw_fds(&self) -> (std::os::fd::RawFd, std::os::fd::RawFd) {
use std::os::fd::AsRawFd;
(self.user_ns.as_raw_fd(), self.net_ns.as_raw_fd())
}
pub(crate) fn pid_ns_fd(&self) -> std::os::fd::RawFd {
use std::os::fd::AsRawFd;
self.pid_ns.as_raw_fd()
}
}
impl SessionNetns {
pub(crate) fn new() -> Self {
Self::default()
}
#[cfg(target_os = "linux")]
pub(crate) fn establish(
&self,
tcp_ports: &[u16],
policy: crate::egress_proxy::EgressPolicy,
) -> Result<SessionNetnsJoin, String> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match &*state {
SessionNetnsState::Established(established) => return Ok(established.join()),
SessionNetnsState::Ended => {
return Err(
"sandbox: this session has ended, and its network namespace was released \
with it; no further command runs in it"
.to_string(),
)
}
SessionNetnsState::NotYetCreated => {}
}
let plan = CapsuleNetnsPlan::resolve(tcp_ports.to_vec());
let created = linux::create_session_netns(&plan).map_err(|error| {
session_netns_creation_error(
error,
crate::sealed::userns_grant(),
crate::sealed::inspect_profile_attachment(),
)
})?;
let established = created.serve(policy).map_err(|detail| {
format!("sandbox: could not create this session's network namespace: {detail}")
})?;
let join = established.join();
*state = SessionNetnsState::Established(established);
#[cfg(test)]
self.creations
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(join)
}
pub(crate) fn end(&self) {
let previous = std::mem::replace(
&mut *self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
SessionNetnsState::Ended,
);
#[cfg(target_os = "linux")]
if let SessionNetnsState::Established(established) = previous {
established.release();
}
#[cfg(not(target_os = "linux"))]
drop(previous);
}
#[cfg(test)]
pub(crate) fn creations(&self) -> usize {
self.creations.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Drop for SessionNetns {
fn drop(&mut self) {
self.end();
}
}
pub(crate) const STEP_RECORD_LEN: usize = 8;
pub(crate) fn encode_step_record(code: u32, errno: i32) -> [u8; STEP_RECORD_LEN] {
let mut record = [0u8; STEP_RECORD_LEN];
record[..4].copy_from_slice(&code.to_ne_bytes());
record[4..].copy_from_slice(&errno.to_ne_bytes());
record
}
pub(crate) fn decode_step_record(record: &[u8; STEP_RECORD_LEN]) -> (u32, i32) {
(
u32::from_ne_bytes([record[0], record[1], record[2], record[3]]),
i32::from_ne_bytes([record[4], record[5], record[6], record[7]]),
)
}
#[cfg(target_os = "linux")]
pub(crate) use linux::{create_session_netns, join_session_netns, reap_child, send_fds};
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
mod linux {
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::sync::Arc;
use super::{
decode_step_record, encode_step_record, CapsuleNetnsPlan, CreationStep,
NamespaceAttemptError, SessionNetnsJoin, MAX_NAMESPACE_SOCKETS, MAX_SESSION_NETNS_FDS,
NAMESPACE_HANDLE_COUNT, STEP_RECORD_LEN,
};
const RTM_NEWROUTE: u16 = 24;
const NLM_F_REQUEST: u16 = 0x001;
const NLM_F_ACK: u16 = 0x004;
const NLM_F_EXCL: u16 = 0x200;
const NLM_F_CREATE: u16 = 0x400;
const NLMSG_ERROR: u16 = 2;
const RT_TABLE_LOCAL: u8 = 255;
const RTPROT_BOOT: u8 = 3;
const RT_SCOPE_HOST: u8 = 254;
const RTN_LOCAL: u8 = 2;
const RTA_OIF: u16 = 4;
const LOOPBACK_IFINDEX: u32 = 1;
const NLMSGHDR_LEN: usize = 16;
const RTMSG_LEN: usize = 12;
const RTATTR_OIF_LEN: usize = 8;
const ROUTE_REQUEST_LEN: usize = NLMSGHDR_LEN + RTMSG_LEN + RTATTR_OIF_LEN;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct CreationFailure {
step: CreationStep,
errno: i32,
}
impl CreationFailure {
unsafe fn at(step: CreationStep) -> Self {
let errno = unsafe { *libc::__errno_location() };
Self { step, errno }
}
fn encode(self) -> [u8; STEP_RECORD_LEN] {
encode_step_record(self.step as u32, self.errno)
}
fn decode(record: &[u8; STEP_RECORD_LEN]) -> Option<Self> {
let (code, errno) = decode_step_record(record);
let step = CreationStep::from_code(code)?;
Some(Self { step, errno })
}
}
impl From<CreationFailure> for NamespaceAttemptError {
fn from(failure: CreationFailure) -> Self {
NamespaceAttemptError::Step {
step: failure.step,
errno: failure.errno,
}
}
}
unsafe fn create_capsule_netns(
plan: &CapsuleNetnsPlan,
sock_fd: RawFd,
lifeline_fd: RawFd,
lifeline_write_fd: RawFd,
) -> Result<(), CreationFailure> {
unsafe {
#[cfg(test)]
let forced = plan.force_pid_namespace_failure;
#[cfg(not(test))]
let forced = false;
let pid_namespace_refused = if forced {
Some(libc::EPERM)
} else if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET | libc::CLONE_NEWPID)
== 0
{
None
} else {
Some(*libc::__errno_location())
};
if let Some(errno) = pid_namespace_refused {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
return Err(CreationFailure::at(CreationStep::Unshare));
}
return Err(CreationFailure {
step: CreationStep::PidNamespace,
errno,
});
}
if write_identity_maps(plan.uid, plan.gid).is_err() {
return Err(CreationFailure::at(CreationStep::IdentityMaps));
}
libc::close(lifeline_write_fd);
let holder = libc::fork();
if holder < 0 {
return Err(CreationFailure::at(CreationStep::Holder));
}
if holder == 0 {
run_holder(lifeline_fd);
}
if bring_loopback_up().is_err() {
return Err(CreationFailure::at(CreationStep::Loopback));
}
if add_local_default_route().is_err() {
return Err(CreationFailure::at(CreationStep::LocalRoute));
}
let mut fds = [-1 as RawFd; MAX_SESSION_NETNS_FDS];
let mut count = 0usize;
for port in &plan.tcp_ports {
let fd = bind_tcp_listener(*port);
if fd < 0 {
let failure = CreationFailure::at(CreationStep::TcpListener);
close_all(&fds[..count]);
return Err(failure);
}
fds[count] = fd;
count += 1;
}
let resolver = bind_dns_socket();
if resolver < 0 {
let failure = CreationFailure::at(CreationStep::Resolver);
close_all(&fds[..count]);
return Err(failure);
}
fds[count] = resolver;
count += 1;
for namespace in [
c"/proc/self/ns/user",
c"/proc/self/ns/net",
c"/proc/self/ns/pid_for_children",
] {
let fd = libc::open(namespace.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC);
if fd < 0 {
let failure = CreationFailure::at(CreationStep::NamespaceHandles);
close_all(&fds[..count]);
return Err(failure);
}
fds[count] = fd;
count += 1;
}
let sent = send_fds(sock_fd, &fds[..count]);
let failure = CreationFailure::at(CreationStep::HandOff);
close_all(&fds[..count]);
sent.map_err(|()| failure)
}
}
unsafe fn run_holder(lifeline_fd: RawFd) -> ! {
unsafe {
let mut ignore: libc::sigaction = std::mem::zeroed();
ignore.sa_sigaction = libc::SIG_IGN;
libc::sigaction(libc::SIGCHLD, &ignore, std::ptr::null_mut());
libc::prctl(libc::PR_SET_NAME, c"mur-pidns-init".as_ptr());
libc::chdir(c"/".as_ptr());
let lifeline = lifeline_fd as libc::c_uint;
if lifeline > 0 {
crate::cgroup::close_range(0, lifeline - 1);
}
crate::cgroup::close_range(lifeline + 1, libc::c_uint::MAX);
let mut byte = 0u8;
loop {
let read = libc::read(lifeline_fd, (&raw mut byte).cast(), 1);
if read < 0 && *libc::__errno_location() == libc::EINTR {
continue;
}
if read > 0 {
continue;
}
break;
}
libc::_exit(0)
}
}
pub(crate) unsafe fn join_session_netns(user_fd: RawFd, net_fd: RawFd) -> io::Result<()> {
unsafe {
if libc::setns(user_fd, libc::CLONE_NEWUSER) != 0 {
return Err(io::Error::last_os_error());
}
if libc::setns(net_fd, libc::CLONE_NEWNET) != 0 {
return Err(io::Error::last_os_error());
}
}
Ok(())
}
pub(crate) struct CreatedNetns {
sockets: Vec<OwnedFd>,
user_ns: OwnedFd,
net_ns: OwnedFd,
pid_ns: OwnedFd,
lifeline: OwnedFd,
}
impl CreatedNetns {
pub(crate) fn namespace_handles(&self) -> (RawFd, RawFd) {
(self.user_ns.as_raw_fd(), self.net_ns.as_raw_fd())
}
pub(crate) fn pid_namespace_handle(&self) -> RawFd {
self.pid_ns.as_raw_fd()
}
pub(super) fn serve(
self,
policy: crate::egress_proxy::EgressPolicy,
) -> Result<EstablishedNetns, String> {
let proxy = crate::egress_proxy::start_egress_proxy(self.sockets, policy)
.map_err(|error| format!("starting the session's egress proxy failed: {error}"))?;
Ok(EstablishedNetns {
user_ns: Arc::new(self.user_ns),
net_ns: Arc::new(self.net_ns),
pid_ns: Arc::new(self.pid_ns),
lifeline: self.lifeline,
proxy,
})
}
}
#[derive(Debug)]
pub(super) struct EstablishedNetns {
user_ns: Arc<OwnedFd>,
net_ns: Arc<OwnedFd>,
pid_ns: Arc<OwnedFd>,
lifeline: OwnedFd,
proxy: crate::egress_proxy::EgressProxyHandle,
}
impl EstablishedNetns {
pub(super) fn join(&self) -> SessionNetnsJoin {
SessionNetnsJoin {
user_ns: Arc::clone(&self.user_ns),
net_ns: Arc::clone(&self.net_ns),
pid_ns: Arc::clone(&self.pid_ns),
}
}
pub(super) fn release(self) {
self.proxy.shutdown();
drop(self.lifeline);
}
}
pub(crate) fn create_session_netns(
plan: &CapsuleNetnsPlan,
) -> Result<CreatedNetns, NamespaceAttemptError> {
if plan.socket_count() > MAX_NAMESPACE_SOCKETS {
return Err(NamespaceAttemptError::Creator(format!(
"{} namespace sockets requested, more than the {MAX_NAMESPACE_SOCKETS} one \
hand-off carries",
plan.socket_count()
)));
}
let mut pair = [0i32; 2];
let rc = unsafe {
libc::socketpair(
libc::AF_UNIX,
libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
0,
pair.as_mut_ptr(),
)
};
if rc != 0 {
return Err(NamespaceAttemptError::Creator(format!(
"socketpair() for the namespace hand-off failed: {}",
io::Error::last_os_error()
)));
}
let (parent_end, creator_end) =
unsafe { (OwnedFd::from_raw_fd(pair[0]), OwnedFd::from_raw_fd(pair[1])) };
let creator_fd = creator_end.as_raw_fd();
let mut lifeline = [0i32; 2];
if unsafe { libc::pipe2(lifeline.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(NamespaceAttemptError::Creator(format!(
"pipe2() for the PID namespace holder's lifeline failed: {}",
io::Error::last_os_error()
)));
}
let (lifeline_read, lifeline_write) = unsafe {
(
OwnedFd::from_raw_fd(lifeline[0]),
OwnedFd::from_raw_fd(lifeline[1]),
)
};
let lifeline_fd = lifeline_read.as_raw_fd();
let lifeline_write_fd = lifeline_write.as_raw_fd();
let pid = unsafe { libc::fork() };
if pid < 0 {
return Err(NamespaceAttemptError::Creator(format!(
"fork() for the namespace creator failed: {}",
io::Error::last_os_error()
)));
}
if pid == 0 {
unsafe {
match create_capsule_netns(plan, creator_fd, lifeline_fd, lifeline_write_fd) {
Ok(()) => libc::_exit(0),
Err(failure) => {
let record = failure.encode();
libc::send(creator_fd, record.as_ptr().cast(), record.len(), 0);
libc::_exit(1);
}
}
}
}
drop(creator_end);
drop(lifeline_read);
let status = reap_child(pid).map_err(|error| {
NamespaceAttemptError::Creator(format!(
"waiting for the namespace creator failed: {error}"
))
})?;
match receive_session_netns(parent_end.as_raw_fd(), plan.socket_count())
.map_err(NamespaceAttemptError::Creator)?
{
Received::Handles(mut fds) => {
let pid_ns = fds.pop().expect("the count was checked on receipt");
let net_ns = fds.pop().expect("the count was checked on receipt");
let user_ns = fds.pop().expect("the count was checked on receipt");
Ok(CreatedNetns {
sockets: fds,
user_ns,
net_ns,
pid_ns,
lifeline: lifeline_write,
})
}
Received::Failure(failure) => Err(failure.into()),
Received::Nothing => Err(NamespaceAttemptError::Creator(format!(
"the namespace creator exited without handing anything over ({status})"
))),
}
}
pub(crate) fn reap_child(pid: libc::pid_t) -> io::Result<String> {
let mut status: libc::c_int = 0;
loop {
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
if waited == pid {
break;
}
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
Ok(if libc::WIFEXITED(status) {
format!("exit status {}", libc::WEXITSTATUS(status))
} else if libc::WIFSIGNALED(status) {
format!("killed by signal {}", libc::WTERMSIG(status))
} else {
format!("wait status {status}")
})
}
#[cfg(test)]
pub(super) fn decode_failure_record(step: u32, errno: i32) -> Option<NamespaceAttemptError> {
let record = encode_step_record(step, errno);
CreationFailure::decode(&record).map(|failure| {
assert_eq!(failure.encode(), record);
failure.into()
})
}
enum Received {
Handles(Vec<OwnedFd>),
Failure(CreationFailure),
Nothing,
}
unsafe fn write_identity_maps(uid: u32, gid: u32) -> Result<(), ()> {
unsafe {
let previous = crate::sealed::make_dumpable_for_map_writes();
let _ = crate::sealed::write_decimal_map(c"/proc/self/setgroups", None, 0);
let mapped = crate::sealed::write_decimal_map(c"/proc/self/uid_map", Some(uid), uid)
.is_ok()
&& crate::sealed::write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
crate::sealed::restore_dumpable(previous);
if mapped {
Ok(())
} else {
Err(())
}
}
}
unsafe fn bring_loopback_up() -> Result<(), ()> {
unsafe {
let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
if sock < 0 {
return Err(());
}
let mut ifr: libc::ifreq = std::mem::zeroed();
ifr.ifr_name[0] = b'l' as libc::c_char;
ifr.ifr_name[1] = b'o' as libc::c_char;
if libc::ioctl(sock, libc::SIOCGIFFLAGS, &mut ifr) < 0 {
libc::close(sock);
return Err(());
}
ifr.ifr_ifru.ifru_flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
let rc = libc::ioctl(sock, libc::SIOCSIFFLAGS, &ifr);
libc::close(sock);
if rc < 0 {
return Err(());
}
Ok(())
}
}
unsafe fn add_local_default_route() -> Result<(), ()> {
unsafe {
let mut request = [0u8; ROUTE_REQUEST_LEN];
request[0..4].copy_from_slice(&(ROUTE_REQUEST_LEN as u32).to_ne_bytes());
request[4..6].copy_from_slice(&RTM_NEWROUTE.to_ne_bytes());
request[6..8].copy_from_slice(
&(NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL).to_ne_bytes(),
);
request[8..12].copy_from_slice(&1u32.to_ne_bytes()); request[12..16].copy_from_slice(&0u32.to_ne_bytes());
let rtm = NLMSGHDR_LEN;
request[rtm] = libc::AF_INET as u8;
request[rtm + 1] = 0; request[rtm + 2] = 0; request[rtm + 3] = 0; request[rtm + 4] = RT_TABLE_LOCAL;
request[rtm + 5] = RTPROT_BOOT;
request[rtm + 6] = RT_SCOPE_HOST;
request[rtm + 7] = RTN_LOCAL;
request[rtm + 8..rtm + 12].copy_from_slice(&0u32.to_ne_bytes());
let attr = rtm + RTMSG_LEN;
request[attr..attr + 2].copy_from_slice(&(RTATTR_OIF_LEN as u16).to_ne_bytes());
request[attr + 2..attr + 4].copy_from_slice(&RTA_OIF.to_ne_bytes());
request[attr + 4..attr + 8].copy_from_slice(&LOOPBACK_IFINDEX.to_ne_bytes());
let sock = libc::socket(
libc::AF_NETLINK,
libc::SOCK_RAW | libc::SOCK_CLOEXEC,
libc::NETLINK_ROUTE,
);
if sock < 0 {
return Err(());
}
let mut address: libc::sockaddr_nl = std::mem::zeroed();
address.nl_family = libc::AF_NETLINK as u16;
let sent = libc::sendto(
sock,
request.as_ptr().cast(),
ROUTE_REQUEST_LEN,
0,
std::ptr::addr_of!(address).cast(),
std::mem::size_of::<libc::sockaddr_nl>() as libc::socklen_t,
);
if sent < 0 {
libc::close(sock);
return Err(());
}
let mut response = [0u8; 256];
let received = libc::recv(sock, response.as_mut_ptr().cast(), response.len(), 0);
libc::close(sock);
if received < (NLMSGHDR_LEN + 4) as isize {
return Err(());
}
if u16::from_ne_bytes([response[4], response[5]]) == NLMSG_ERROR {
let code = i32::from_ne_bytes([
response[NLMSGHDR_LEN],
response[NLMSGHDR_LEN + 1],
response[NLMSGHDR_LEN + 2],
response[NLMSGHDR_LEN + 3],
]);
if code != 0 {
return Err(());
}
}
Ok(())
}
}
unsafe fn bind_tcp_listener(port: u16) -> RawFd {
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0);
if fd < 0 {
return -1;
}
let mut address: libc::sockaddr_in = std::mem::zeroed();
address.sin_family = libc::AF_INET as libc::sa_family_t;
address.sin_port = port.to_be();
address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
if libc::bind(
fd,
std::ptr::addr_of!(address).cast(),
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
) != 0
|| libc::listen(fd, 128) != 0
{
libc::close(fd);
return -1;
}
fd
}
}
unsafe fn bind_dns_socket() -> RawFd {
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
if fd < 0 {
return -1;
}
let enable: libc::c_int = 1;
if libc::setsockopt(
fd,
libc::IPPROTO_IP,
libc::IP_PKTINFO,
std::ptr::addr_of!(enable).cast(),
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
) != 0
{
libc::close(fd);
return -1;
}
let mut address: libc::sockaddr_in = std::mem::zeroed();
address.sin_family = libc::AF_INET as libc::sa_family_t;
address.sin_port = super::DNS_PORT_BE;
address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
if libc::bind(
fd,
std::ptr::addr_of!(address).cast(),
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
) != 0
{
libc::close(fd);
return -1;
}
fd
}
}
unsafe fn close_all(fds: &[RawFd]) {
for fd in fds {
if *fd >= 0 {
unsafe { libc::close(*fd) };
}
}
}
const CMSG_BUFFER_LEN: usize = 128;
const _: () = assert!(
unsafe {
libc::CMSG_SPACE((MAX_SESSION_NETNS_FDS * std::mem::size_of::<RawFd>()) as u32) as usize
} <= CMSG_BUFFER_LEN,
"CMSG_BUFFER_LEN is too small to hold MAX_SESSION_NETNS_FDS descriptors plus the \
cmsghdr; raise it to match"
);
#[repr(C, align(8))]
struct CmsgBuffer([u8; CMSG_BUFFER_LEN]);
pub(crate) unsafe fn send_fds(sock_fd: RawFd, fds: &[RawFd]) -> Result<(), ()> {
unsafe {
let mut payload = [0u8; 1];
let mut iov = libc::iovec {
iov_base: payload.as_mut_ptr().cast(),
iov_len: payload.len(),
};
let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
let bytes = std::mem::size_of_val(fds) as u32;
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.0.as_mut_ptr().cast();
msg.msg_controllen = libc::CMSG_SPACE(bytes) as _;
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
return Err(());
}
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(bytes) as _;
std::ptr::copy_nonoverlapping(
fds.as_ptr().cast::<u8>(),
libc::CMSG_DATA(cmsg),
bytes as usize,
);
if libc::sendmsg(sock_fd, &msg, 0) < 0 {
return Err(());
}
Ok(())
}
}
fn receive_session_netns(sock_fd: RawFd, expected_sockets: usize) -> Result<Received, String> {
unsafe {
let mut payload = [0u8; STEP_RECORD_LEN];
let mut iov = libc::iovec {
iov_base: payload.as_mut_ptr().cast(),
iov_len: payload.len(),
};
let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.0.as_mut_ptr().cast();
msg.msg_controllen = control.0.len() as _;
let received = libc::recvmsg(
sock_fd,
&mut msg,
libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
);
if received < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
return Ok(Received::Nothing);
}
return Err(format!("receiving the namespace hand-off failed: {error}"));
}
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
if received as usize == STEP_RECORD_LEN {
if let Some(failure) = CreationFailure::decode(&payload) {
return Ok(Received::Failure(failure));
}
}
return Ok(Received::Nothing);
}
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
return Err(
"the namespace hand-off did not arrive as an SCM_RIGHTS message".to_string(),
);
}
let bytes = (*cmsg).cmsg_len - libc::CMSG_LEN(0) as usize;
let count = bytes / std::mem::size_of::<RawFd>();
let data = libc::CMSG_DATA(cmsg).cast::<RawFd>();
let mut fds = Vec::with_capacity(count);
for index in 0..count {
fds.push(OwnedFd::from_raw_fd(std::ptr::read_unaligned(
data.add(index),
)));
}
if msg.msg_flags & libc::MSG_CTRUNC != 0 {
return Err("the namespace hand-off was truncated".to_string());
}
if count != expected_sockets + NAMESPACE_HANDLE_COUNT {
return Err(format!(
"the namespace creator handed over {count} descriptors; {} were expected \
({expected_sockets} sockets and {NAMESPACE_HANDLE_COUNT} namespace handles)",
expected_sockets + NAMESPACE_HANDLE_COUNT
));
}
Ok(Received::Handles(fds))
}
}
}
#[cfg(target_os = "linux")]
const DNS_PORT_BE: u16 = crate::egress_proxy::EGRESS_DNS_PORT.to_be();
#[cfg(test)]
mod tests {
use super::*;
use crate::sealed::{ProfileAttachment, UsernsGrant};
fn attachments() -> [ProfileAttachment; 4] {
[
ProfileAttachment::Sealed {
profile: "mur-sealed".to_string(),
},
ProfileAttachment::Other {
profile: "docker-default".to_string(),
},
ProfileAttachment::Unconfined,
ProfileAttachment::NotReported,
]
}
fn step(step: CreationStep, errno: i32) -> Result<(), NamespaceAttemptError> {
Err(NamespaceAttemptError::Step { step, errno })
}
fn failure(
attempt: Result<(), NamespaceAttemptError>,
grant: UsernsGrant,
attachment: ProfileAttachment,
) -> EgressNamespaceFailure {
egress_namespace_failure(true, attempt, grant, attachment)
.expect("a failed attempt is a failure")
}
#[test]
fn a_created_namespace_is_never_refused_whatever_the_readings() {
for grant in UsernsGrant::ALL {
for attachment in attachments() {
assert_eq!(
egress_namespace_failure(true, Ok(()), *grant, attachment.clone()),
None,
"{grant:?} / {attachment:?}"
);
}
}
}
#[test]
fn a_failed_creation_is_refused_whatever_the_readings() {
let attempts = [
(
step(CreationStep::Unshare, libc::EPERM),
EgressNamespaceBlocker::CapabilityGrantMissing,
),
(
step(CreationStep::Unshare, libc::ENOSPC),
EgressNamespaceBlocker::KernelSupportMissing,
),
(
Err(NamespaceAttemptError::Creator("fork() failed".to_string())),
EgressNamespaceBlocker::CreatorUnavailable,
),
];
for (attempt, blocker) in attempts {
for grant in UsernsGrant::ALL {
for attachment in attachments() {
let failure =
egress_namespace_failure(true, attempt.clone(), *grant, attachment.clone())
.unwrap_or_else(|| {
panic!("{attempt:?} under {grant:?} / {attachment:?}")
});
assert_eq!(failure.blocker, blocker, "{grant:?} / {attachment:?}");
}
}
}
}
#[test]
fn only_an_unshare_the_kernel_cannot_honour_is_missing_kernel_support() {
for errno in [libc::ENOSPC, libc::EINVAL, libc::ENOSYS, libc::EUSERS] {
assert_eq!(
failure(
step(CreationStep::Unshare, errno),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined
)
.blocker,
EgressNamespaceBlocker::KernelSupportMissing,
"errno {errno}"
);
}
for (failed, errno) in [
(CreationStep::Unshare, libc::EPERM),
(CreationStep::Unshare, libc::EACCES),
(CreationStep::IdentityMaps, libc::EPERM),
(CreationStep::Loopback, libc::EPERM),
(CreationStep::LocalRoute, libc::EINVAL),
(CreationStep::Resolver, libc::ENOSPC),
] {
assert_eq!(
failure(
step(failed, errno),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined
)
.blocker,
EgressNamespaceBlocker::CapabilityGrantMissing,
"{failed:?} errno {errno}"
);
}
}
#[test]
fn a_creator_that_could_not_run_is_unavailable_and_keeps_its_detail() {
let detail = "fork() for the namespace creator failed: Resource temporarily unavailable \
(os error 11)";
let failure = failure(
Err(NamespaceAttemptError::Creator(detail.to_string())),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
);
assert_eq!(failure.blocker, EgressNamespaceBlocker::CreatorUnavailable);
assert_eq!(failure.errno, None);
let reason = failure.reason();
assert!(reason.starts_with(detail), "{reason}");
assert!(!reason.contains("apparmor_parser"), "{reason}");
assert!(!reason.contains("CONFIG_USER_NS"), "{reason}");
assert!(!reason.contains("user.max_user_namespaces"), "{reason}");
}
#[test]
fn non_linux_is_never_refused_for_a_mechanism_it_never_had() {
for attempt in [
Ok(()),
step(CreationStep::Unshare, libc::EPERM),
Err(NamespaceAttemptError::Creator("fork() failed".to_string())),
] {
assert_eq!(
egress_namespace_failure(
false,
attempt,
UsernsGrant::Withheld,
ProfileAttachment::NotReported
),
None
);
}
#[cfg(not(target_os = "linux"))]
assert_eq!(detect_egress_namespace_failure(), None);
}
#[test]
fn the_check_refuses_exactly_a_capsule_that_can_spawn_on_a_failing_host() {
let failure = failure(
step(CreationStep::Unshare, libc::EPERM),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
);
match check_egress_namespace(true, Some(&failure)) {
Err(crate::errors::RuntimeError::EgressNamespaceUnavailable {
blocker,
reason,
..
}) => {
assert_eq!(blocker, failure.blocker);
assert_eq!(reason, failure.reason());
}
other => panic!("expected the E-CAP-005 refusal, got {other:?}"),
}
assert!(check_egress_namespace(false, Some(&failure)).is_ok());
assert!(check_egress_namespace(true, None).is_ok());
assert!(check_egress_namespace(false, None).is_ok());
}
#[test]
fn an_unconfined_binary_on_a_restricted_host_is_told_the_profile_before_the_sysctl() {
let reason = failure(
step(CreationStep::Unshare, libc::EPERM),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
)
.reason();
assert!(
reason.starts_with(
"unshare(CLONE_NEWUSER | CLONE_NEWNET) failed with EPERM (Operation not \
permitted)."
),
"{reason}"
);
assert!(
reason.contains("kernel.apparmor_restrict_unprivileged_userns=1"),
"{reason}"
);
let profile = reason
.find("apparmor_parser -r")
.expect("the profile install command must be named");
let sysctl = reason
.find("kernel.apparmor_restrict_unprivileged_userns=0")
.expect("the fallback must still be named for hosts that cannot load a profile");
assert!(
profile < sysctl,
"the profile install must come first: {reason}"
);
assert!(reason.contains("LAST RESORT"), "{reason}");
assert!(reason.contains("every program on the machine"), "{reason}");
assert!(
reason.contains("scripts/install-dev-apparmor.sh"),
"{reason}"
);
assert!(
reason.ends_with(
"Host readings: kernel.apparmor_restrict_unprivileged_userns=1; AppArmor profile \
confining mur: unconfined."
),
"{reason}"
);
}
#[test]
fn a_binary_another_profile_confines_is_given_the_container_remedy_not_the_sysctl() {
let reason = failure(
step(CreationStep::Unshare, libc::EPERM),
UsernsGrant::Withheld,
ProfileAttachment::Other {
profile: "docker-default".to_string(),
},
)
.reason();
assert!(reason.contains("`docker-default`"), "{reason}");
assert!(!reason.contains("--cap-add"), "{reason}");
assert!(reason.contains("seccomp profile"), "{reason}");
assert!(
reason.contains("unshare(CLONE_NEWUSER) and setns"),
"{reason}"
);
assert!(reason.contains("seccompProfile: Localhost"), "{reason}");
assert!(
!reason.contains("kernel.apparmor_restrict_unprivileged_userns=0"),
"{reason}"
);
assert!(!reason.contains("apparmor_parser"), "{reason}");
assert!(
reason.ends_with("AppArmor profile confining mur: docker-default."),
"{reason}"
);
}
#[test]
fn a_failure_under_the_sealed_profile_points_at_a_stale_load() {
let reason = failure(
step(CreationStep::Unshare, libc::EPERM),
UsernsGrant::ProfileConfining,
ProfileAttachment::Sealed {
profile: "mur-sealed".to_string(),
},
)
.reason();
assert!(
reason.contains("sudo apparmor_parser -r /etc/apparmor.d/mur-sealed"),
"{reason}"
);
assert!(reason.contains("stale"), "{reason}");
assert!(!reason.contains("LAST RESORT"), "{reason}");
}
#[test]
fn without_the_restriction_the_cause_is_not_apparmor() {
for (grant, readings) in [
(
UsernsGrant::RestrictionDisabledHostWide,
"kernel.apparmor_restrict_unprivileged_userns=0",
),
(UsernsGrant::ApparmorAbsent, "AppArmor not enabled"),
] {
for attachment in [
ProfileAttachment::Unconfined,
ProfileAttachment::NotReported,
] {
let reason =
failure(step(CreationStep::Unshare, libc::EPERM), grant, attachment).reason();
assert!(
!reason.contains("apparmor_restrict_unprivileged_userns=1"),
"{reason}"
);
assert!(!reason.contains("apparmor_parser"), "{reason}");
assert!(!reason.contains("--cap-add"), "{reason}");
assert!(!reason.contains("CAP_SYS_ADMIN"), "{reason}");
assert!(
reason.contains("seccomp profile is the usual blocker"),
"{reason}"
);
assert!(
reason.contains("unshare(CLONE_NEWUSER) and setns"),
"{reason}"
);
assert!(reason.contains("--security-opt seccomp=<file>"), "{reason}");
assert!(
reason.contains(&format!("Host readings: {readings};")),
"{reason}"
);
}
}
}
#[test]
fn another_security_modules_label_is_not_blamed_as_an_apparmor_profile() {
let reason = failure(
step(CreationStep::Unshare, libc::EPERM),
UsernsGrant::ApparmorAbsent,
ProfileAttachment::Other {
profile: "unconfined_u:unconfined_r:unconfined_t:s0".to_string(),
},
)
.reason();
assert!(reason.contains("not AppArmor"), "{reason}");
assert!(!reason.contains("the AppArmor profile `"), "{reason}");
}
#[test]
fn a_namespace_created_and_then_refused_says_which_half_failed() {
let configure = failure(
step(CreationStep::Loopback, libc::EPERM),
UsernsGrant::RestrictionDisabledHostWide,
ProfileAttachment::Unconfined,
)
.reason();
assert!(
configure.starts_with("bringing lo up failed with EPERM"),
"{configure}"
);
assert!(configure.contains("CAP_NET_ADMIN"), "{configure}");
let owned = failure(
step(CreationStep::IdentityMaps, libc::EACCES),
UsernsGrant::RestrictionDisabledHostWide,
ProfileAttachment::Unconfined,
)
.reason();
assert!(
owned.starts_with("writing uid_map/gid_map failed with EACCES (Permission denied)."),
"{owned}"
);
assert!(owned.contains("could not be owned"), "{owned}");
}
#[test]
fn missing_kernel_support_names_the_limit_or_the_config_symbol_and_no_apparmor() {
for (errno, named) in [
(libc::ENOSPC, "user.max_user_namespaces"),
(libc::EINVAL, "CONFIG_USER_NS"),
(libc::ENOSYS, "CONFIG_USER_NS"),
(libc::EUSERS, "nesting limit"),
] {
let reason = failure(
step(CreationStep::Unshare, errno),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
)
.reason();
assert!(reason.contains(named), "{reason}");
assert!(
reason.contains(errno_name(errno).expect("a named errno")),
"{reason}"
);
assert!(!reason.contains("apparmor_parser"), "{reason}");
assert!(!reason.contains("LAST RESORT"), "{reason}");
}
}
#[test]
fn an_unnamed_errno_is_rendered_by_number() {
assert_eq!(errno_name(libc::EPERM), Some("EPERM"));
assert_eq!(errno_name(-7), None);
let failure = failure(
step(CreationStep::HandOff, 4095),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
);
assert_eq!(
failure.summary(),
"handing the namespace to the runtime failed with errno 4095"
);
}
#[test]
fn the_summary_names_the_call_and_the_errno() {
let failed = failure(
step(CreationStep::Unshare, libc::EPERM),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
);
assert_eq!(
failed.summary(),
"unshare(CLONE_NEWUSER | CLONE_NEWNET) failed with EPERM"
);
let creator = failure(
Err(NamespaceAttemptError::Creator("fork() failed".to_string())),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
);
assert_eq!(creator.summary(), "fork() failed");
}
#[test]
fn every_step_round_trips_through_its_code() {
for step in CreationStep::ALL {
assert_eq!(CreationStep::from_code(step as u32), Some(step));
}
assert_eq!(CreationStep::from_code(0), None);
assert_eq!(CreationStep::from_code(99), None);
}
#[test]
fn a_session_creation_failure_is_refused_in_the_staging_words() {
for (failed, errno, grant, attachment) in [
(
CreationStep::Unshare,
libc::EPERM,
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
),
(
CreationStep::LocalRoute,
libc::EPERM,
UsernsGrant::Withheld,
ProfileAttachment::Other {
profile: "docker-default".to_string(),
},
),
(
CreationStep::Unshare,
libc::ENOSPC,
UsernsGrant::RestrictionDisabledHostWide,
ProfileAttachment::Unconfined,
),
] {
let staging =
egress_namespace_failure(true, step(failed, errno), grant, attachment.clone());
let staging_refusal = check_egress_namespace(true, staging.as_ref())
.expect_err("staging refuses")
.to_string();
let establish = session_netns_creation_error(
NamespaceAttemptError::Step {
step: failed,
errno,
},
grant,
attachment,
);
assert_eq!(establish, format!("sandbox: E-CAP-005: {staging_refusal}"));
assert!(
establish.contains(&staging.expect("a failure").reason()),
"{establish}"
);
}
}
#[test]
fn a_session_creator_that_could_not_run_keeps_the_runtime_account() {
let error = session_netns_creation_error(
NamespaceAttemptError::Creator("fork() for the namespace creator failed".to_string()),
UsernsGrant::Withheld,
ProfileAttachment::Unconfined,
);
assert_eq!(
error,
"sandbox: could not create this session's network namespace: fork() for the \
namespace creator failed"
);
}
#[test]
fn the_socket_count_is_one_per_port_plus_the_resolver() {
let plan = CapsuleNetnsPlan {
tcp_ports: vec![80, 443],
uid: 1000,
gid: 1000,
force_pid_namespace_failure: false,
};
assert_eq!(plan.socket_count(), 3);
}
#[test]
fn a_capsule_with_no_tcp_egress_still_binds_the_resolver() {
let plan = CapsuleNetnsPlan {
tcp_ports: Vec::new(),
uid: 1000,
gid: 1000,
force_pid_namespace_failure: false,
};
assert_eq!(plan.socket_count(), 1);
}
#[test]
fn the_widest_allowlist_still_fits_one_scm_rights_message() {
let plan = CapsuleNetnsPlan {
tcp_ports: (0..crate::egress_proxy::MAX_EGRESS_TCP_PORTS as u16).collect(),
uid: 1000,
gid: 1000,
force_pid_namespace_failure: false,
};
assert!(plan.socket_count() <= MAX_NAMESPACE_SOCKETS);
assert_eq!(MAX_SESSION_NETNS_FDS, MAX_NAMESPACE_SOCKETS + 3);
}
#[cfg(target_os = "linux")]
#[test]
fn an_ended_session_refuses_to_create_a_namespace() {
let netns = SessionNetns::new();
netns.end();
let refusal = netns
.establish(
&[],
crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new()),
)
.expect_err("an ended session creates nothing");
assert!(refusal.contains("session has ended"), "{refusal}");
assert_eq!(netns.creations(), 0);
}
#[cfg(target_os = "linux")]
#[test]
fn a_session_creates_its_namespace_once_and_ends_it_for_good() {
if skip_without_egress_namespace(
"a_session_creates_its_namespace_once_and_ends_it_for_good",
) {
return;
}
let policy = || crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new());
let netns = SessionNetns::new();
assert_eq!(
netns.creations(),
0,
"nothing is created before it is needed"
);
let first = netns
.establish(&[], policy())
.expect("the namespace is created");
let second = netns
.establish(&[], policy())
.expect("the namespace is reused");
assert_eq!(netns.creations(), 1);
let inode = |fd: std::os::fd::RawFd| {
std::fs::metadata(format!("/proc/self/fd/{fd}"))
.map(|metadata| std::os::unix::fs::MetadataExt::ino(&metadata))
.unwrap()
};
assert_eq!(inode(first.raw_fds().1), inode(second.raw_fds().1));
let host_netns = std::fs::metadata("/proc/self/ns/net")
.map(|metadata| std::os::unix::fs::MetadataExt::ino(&metadata))
.unwrap();
assert_ne!(
inode(first.raw_fds().1),
host_netns,
"the session's namespace is not the runtime's own"
);
netns.end();
netns.end();
assert!(netns.establish(&[], policy()).is_err());
assert_eq!(
netns.creations(),
1,
"an ended namespace is never recreated"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_creation_failure_names_its_step_and_errno() {
assert_eq!(
linux::decode_failure_record(4, libc::EPERM),
Some(NamespaceAttemptError::Step {
step: CreationStep::LocalRoute,
errno: libc::EPERM,
})
);
assert_eq!(linux::decode_failure_record(99, 0), None);
}
#[cfg(target_os = "linux")]
fn close_range_enosys_filter() -> [libc::sock_filter; 7] {
const BPF_LD_W_ABS: u16 = 0x20;
const BPF_JMP_JEQ_K: u16 = 0x15;
const BPF_RET_K: u16 = 0x06;
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
const NR: u32 = 0;
const ARCH: u32 = 4;
#[cfg(target_arch = "x86_64")]
const AUDIT_ARCH: u32 = 0xc000_003e;
#[cfg(target_arch = "aarch64")]
const AUDIT_ARCH: u32 = 0xc000_00b7;
let statement = |code: u16, jt: u8, jf: u8, k: u32| libc::sock_filter { code, jt, jf, k };
[
statement(BPF_LD_W_ABS, 0, 0, ARCH),
statement(BPF_JMP_JEQ_K, 1, 0, AUDIT_ARCH),
statement(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW),
statement(BPF_LD_W_ABS, 0, 0, NR),
statement(BPF_JMP_JEQ_K, 0, 1, libc::SYS_close_range as u32),
statement(BPF_RET_K, 0, 0, SECCOMP_RET_ERRNO | libc::ENOSYS as u32),
statement(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW),
]
}
#[cfg(target_os = "linux")]
#[test]
#[allow(unsafe_code)]
fn the_holder_ends_with_its_session_where_close_range_is_refused() {
let test = "the_holder_ends_with_its_session_where_close_range_is_refused";
if skip_without_egress_namespace(test) {
return;
}
std::thread::spawn(|| {
let filter = close_range_enosys_filter();
let program = libc::sock_fprog {
len: filter.len() as u16,
filter: filter.as_ptr().cast_mut(),
};
unsafe {
assert_eq!(libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), 0);
assert_eq!(
libc::prctl(
libc::PR_SET_SECCOMP,
libc::SECCOMP_MODE_FILTER,
&raw const program,
),
0,
"installing the filter: {}",
std::io::Error::last_os_error()
);
let refused = libc::syscall(
libc::SYS_close_range,
u32::MAX - 1,
u32::MAX,
0 as libc::c_uint,
);
assert_eq!(refused, -1);
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ENOSYS)
);
}
let policy = || crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new());
let end_explicitly = |explicit: bool| {
let netns = SessionNetns::new();
let join = netns
.establish(&[], policy())
.expect("the namespace is created");
let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
.expect("the session's PID namespace handle is readable");
let holder = std::fs::read_dir("/proc")
.expect("/proc is readable")
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
.find(|pid| {
std::fs::read_link(format!("/proc/{pid}/ns/pid"))
.is_ok_and(|namespace| namespace == pid_ns)
&& std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
|status| {
status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.and_then(|fields| fields.split_whitespace().last())
== Some("1")
},
)
})
.expect("the session's holder is pid 1 of its PID namespace");
let status = std::fs::read_to_string(format!("/proc/{holder}/status")).unwrap();
assert!(
status.lines().any(|line| line == "Seccomp:\t2"),
"the holder is not under the filter: {status}"
);
let held_fds = || -> Vec<_> {
std::fs::read_dir(format!("/proc/{holder}/fd"))
.expect("the holder's descriptor table is readable")
.filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok())
.collect()
};
let sleeping = || {
std::fs::read_to_string(format!("/proc/{holder}/stat")).is_ok_and(|stat| {
stat.rsplit_once(") ")
.is_some_and(|(_, rest)| rest.starts_with('S'))
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let held = loop {
let held = held_fds();
if (held.len() <= 1 && sleeping()) || std::time::Instant::now() >= deadline {
break held;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(sleeping(), "holder {holder} never blocked on its lifeline");
assert_eq!(
held.len(),
1,
"the holder keeps only its lifeline: {held:?}"
);
assert!(
held[0].to_string_lossy().starts_with("pipe:"),
"the holder keeps only its lifeline: {held:?}"
);
if explicit {
netns.end();
} else {
drop(netns);
}
drop(join);
let running = |pid: i32| {
std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
stat.rsplit_once(") ")
.is_some_and(|(_, rest)| !rest.starts_with('Z'))
})
};
let wait = crate::sandbox::SESSION_END_KILL_WAIT;
let deadline = std::time::Instant::now() + wait;
while running(holder) {
assert!(
std::time::Instant::now() < deadline,
"holder {holder} outlived its session by {wait:?} (ended explicitly: \
{explicit})"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
};
end_explicitly(true);
end_explicitly(false);
})
.join()
.expect("the filtered thread's assertions hold");
}
#[cfg(target_os = "linux")]
#[test]
fn the_probe_and_a_session_creation_agree_about_this_host() {
let probe = detect_egress_namespace_failure();
let session = linux::create_session_netns(&CapsuleNetnsPlan::resolve(Vec::new())).map(drop);
match (&probe, &session) {
(None, Ok(())) => {}
(Some(failure), Err(NamespaceAttemptError::Step { step, errno })) => {
assert_eq!(failure.failed_call, step.describe());
assert_eq!(failure.errno, Some(*errno));
}
other => panic!("the probe and a session creation disagree: {other:?}"),
}
}
}