pub(crate) const MAX_NAMESPACE_SOCKETS: usize = crate::egress_proxy::MAX_EGRESS_TCP_PORTS + 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EgressNamespaceBlocker {
CapabilityGrantMissing,
KernelSupportMissing,
}
impl EgressNamespaceBlocker {
pub const ALL: &'static [EgressNamespaceBlocker] = &[
EgressNamespaceBlocker::CapabilityGrantMissing,
EgressNamespaceBlocker::KernelSupportMissing,
];
#[must_use]
pub fn reason(self) -> String {
match self {
EgressNamespaceBlocker::CapabilityGrantMissing => format!(
"this host refused unshare(CLONE_NEWUSER | CLONE_NEWNET) to the mur binary. On an \
AppArmor host (Ubuntu 23.10+ and derivatives) this is the unprivileged-userns \
restriction: install and load the profile shipped with mur, `sudo install -m 644 \
packaging/apparmor/{name} {path} && sudo apparmor_parser -r {path}` (or re-run \
the mur installer as root), then re-run. Inside a container it is a missing \
capability: add `--cap-add SYS_ADMIN` to the container invocation, or create the \
network namespace outside the container and run mur inside it. The runtime will \
not fall back to the retired seccomp connect/sendto interception — that \
mechanism was removed as unsound, not demoted to a fallback.",
name = crate::sealed::SEALED_APPARMOR_PROFILE_NAME,
path = crate::sealed::SEALED_APPARMOR_PROFILE_PATH,
),
EgressNamespaceBlocker::KernelSupportMissing => {
"this kernel does not provide unprivileged user namespaces, which a capsule's \
network namespace has to be created inside (CONFIG_USER_NS=n, or \
user.max_user_namespaces=0). Raise `sudo sysctl -w \
user.max_user_namespaces=10000` if the sysctl is merely zeroed, otherwise run on \
a kernel built with CONFIG_USER_NS=y. The runtime will not fall back to the \
retired seccomp connect/sendto interception — that mechanism was removed as \
unsound, not demoted to a fallback."
.to_string()
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum EgressNamespaceProbe {
Ok,
Denied,
MapDenied,
ConfigDenied,
#[default]
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct EgressNamespaceSupport {
pub(crate) apparmor_permits_userns: bool,
pub(crate) namespace: EgressNamespaceProbe,
}
pub(crate) fn egress_namespace_blocker(
is_linux: bool,
support: EgressNamespaceSupport,
) -> Option<EgressNamespaceBlocker> {
if !is_linux {
return None;
}
if !support.apparmor_permits_userns {
return Some(EgressNamespaceBlocker::CapabilityGrantMissing);
}
match support.namespace {
EgressNamespaceProbe::Ok => None,
EgressNamespaceProbe::Denied
| EgressNamespaceProbe::MapDenied
| EgressNamespaceProbe::ConfigDenied => {
Some(EgressNamespaceBlocker::CapabilityGrantMissing)
}
EgressNamespaceProbe::Unsupported => Some(EgressNamespaceBlocker::KernelSupportMissing),
}
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn detect_egress_namespace_blocker() -> Option<EgressNamespaceBlocker> {
static SUPPORT: std::sync::OnceLock<EgressNamespaceSupport> = std::sync::OnceLock::new();
let support = *SUPPORT.get_or_init(linux::probe_egress_namespace);
egress_namespace_blocker(true, support)
}
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn detect_egress_namespace_blocker() -> Option<EgressNamespaceBlocker> {
egress_namespace_blocker(false, EgressNamespaceSupport::default())
}
pub fn skip_without_egress_namespace(test_name: &str) -> bool {
match detect_egress_namespace_blocker() {
Some(blocker) => {
eprintln!("[SKIP-HOST] {test_name}: {}", blocker.reason());
true
}
None => false,
}
}
pub fn check_egress_namespace(
can_spawn_subprocess: bool,
blocker: Option<EgressNamespaceBlocker>,
) -> Result<(), crate::errors::RuntimeError> {
if !can_spawn_subprocess {
return Ok(());
}
match blocker {
None => Ok(()),
Some(blocker) => Err(crate::errors::RuntimeError::EgressNamespaceUnavailable {
blocker,
reason: blocker.reason(),
}),
}
}
#[derive(Debug, Clone)]
pub(crate) struct CapsuleNetnsPlan {
pub(crate) tcp_ports: Vec<u16>,
pub(crate) uid: u32,
pub(crate) gid: u32,
}
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,
}
}
pub(crate) fn socket_count(&self) -> usize {
self.tcp_ports.len() + 1
}
}
#[cfg(target_os = "linux")]
pub(crate) use linux::{create_capsule_netns, receive_namespace_sockets};
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
mod linux {
use std::io;
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use super::{CapsuleNetnsPlan, EgressNamespaceProbe, EgressNamespaceSupport};
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;
pub(super) fn probe_egress_namespace() -> EgressNamespaceSupport {
EgressNamespaceSupport {
apparmor_permits_userns: crate::sealed::apparmor_permits_userns(),
namespace: probe_namespace(),
}
}
const PROBE_OK: i32 = 0;
const PROBE_UNSHARE_DENIED: i32 = 1;
const PROBE_UNSHARE_UNSUPPORTED: i32 = 2;
const PROBE_MAP_DENIED: i32 = 3;
const PROBE_CONFIG_DENIED: i32 = 4;
fn probe_namespace() -> EgressNamespaceProbe {
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
let pid = unsafe { libc::fork() };
if pid < 0 {
return EgressNamespaceProbe::Unsupported;
}
if pid == 0 {
unsafe {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
let errno = *libc::__errno_location();
libc::_exit(match errno {
libc::EPERM => PROBE_UNSHARE_DENIED,
_ => PROBE_UNSHARE_UNSUPPORTED,
});
}
if write_identity_maps(uid, gid).is_err() {
libc::_exit(PROBE_MAP_DENIED);
}
if bring_loopback_up().is_err() || add_local_default_route().is_err() {
libc::_exit(PROBE_CONFIG_DENIED);
}
libc::_exit(PROBE_OK);
}
}
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
if waited != pid || !libc::WIFEXITED(status) {
return EgressNamespaceProbe::Unsupported;
}
match libc::WEXITSTATUS(status) {
PROBE_OK => EgressNamespaceProbe::Ok,
PROBE_UNSHARE_DENIED => EgressNamespaceProbe::Denied,
PROBE_MAP_DENIED => EgressNamespaceProbe::MapDenied,
PROBE_CONFIG_DENIED => EgressNamespaceProbe::ConfigDenied,
_ => EgressNamespaceProbe::Unsupported,
}
}
pub(crate) unsafe fn create_capsule_netns(
plan: &CapsuleNetnsPlan,
sock_fd: RawFd,
) -> io::Result<()> {
unsafe {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
return Err(io::Error::other(
"egress-netns: unshare(CLONE_NEWUSER | CLONE_NEWNET) failed",
));
}
write_identity_maps(plan.uid, plan.gid)
.map_err(|()| io::Error::other("egress-netns: writing uid_map/gid_map failed"))?;
bring_loopback_up()
.map_err(|()| io::Error::other("egress-netns: bringing lo up failed"))?;
add_local_default_route().map_err(|()| {
io::Error::other("egress-netns: installing the local default route failed")
})?;
let mut fds = [-1 as RawFd; super::MAX_NAMESPACE_SOCKETS];
let mut count = 0usize;
for port in &plan.tcp_ports {
let fd = bind_tcp_listener(*port);
if fd < 0 {
close_all(&fds[..count]);
return Err(io::Error::other(
"egress-netns: binding a TCP listener inside the namespace failed",
));
}
fds[count] = fd;
count += 1;
}
let resolver = bind_dns_socket();
if resolver < 0 {
close_all(&fds[..count]);
return Err(io::Error::other(
"egress-netns: binding UDP 53 inside the namespace failed",
));
}
fds[count] = resolver;
count += 1;
let sent = send_fds(sock_fd, &fds[..count]);
close_all(&fds[..count]);
sent.map_err(|()| {
io::Error::other(
"egress-netns: handing the namespace sockets to the runtime failed",
)
})
}
}
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((super::MAX_NAMESPACE_SOCKETS * std::mem::size_of::<RawFd>()) as u32)
as usize
} <= CMSG_BUFFER_LEN,
"CMSG_BUFFER_LEN is too small to hold MAX_NAMESPACE_SOCKETS descriptors plus the cmsghdr; \
raise it to match"
);
#[repr(C, align(8))]
struct CmsgBuffer([u8; CMSG_BUFFER_LEN]);
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(())
}
}
pub(crate) fn receive_namespace_sockets(
sock_fd: RawFd,
expected: usize,
) -> io::Result<Vec<OwnedFd>> {
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 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 _;
if libc::recvmsg(sock_fd, &mut msg, libc::MSG_CMSG_CLOEXEC) < 0 {
return Err(io::Error::last_os_error());
}
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null()
|| (*cmsg).cmsg_level != libc::SOL_SOCKET
|| (*cmsg).cmsg_type != libc::SCM_RIGHTS
{
return Err(io::Error::other(
"egress-netns: the namespace sockets did not arrive as an SCM_RIGHTS message",
));
}
let bytes = (*cmsg).cmsg_len - libc::CMSG_LEN(0) as usize;
let received = bytes / std::mem::size_of::<RawFd>();
let data = libc::CMSG_DATA(cmsg).cast::<RawFd>();
let mut sockets = Vec::with_capacity(received);
for index in 0..received {
sockets.push(OwnedFd::from_raw_fd(std::ptr::read_unaligned(
data.add(index),
)));
}
if received != expected {
return Err(io::Error::other(
"egress-netns: the runtime received a different number of namespace sockets \
than the launch plan asked for",
));
}
Ok(sockets)
}
}
}
#[cfg(target_os = "linux")]
const DNS_PORT_BE: u16 = crate::egress_proxy::EGRESS_DNS_PORT.to_be();
#[cfg(test)]
mod tests {
use super::*;
fn support(apparmor: bool, namespace: EgressNamespaceProbe) -> EgressNamespaceSupport {
EgressNamespaceSupport {
apparmor_permits_userns: apparmor,
namespace,
}
}
#[test]
fn a_working_namespace_blocks_nothing() {
assert_eq!(
egress_namespace_blocker(true, support(true, EgressNamespaceProbe::Ok)),
None
);
}
#[test]
fn apparmor_is_blamed_before_the_namespace_outcome() {
assert_eq!(
egress_namespace_blocker(true, support(false, EgressNamespaceProbe::Unsupported)),
Some(EgressNamespaceBlocker::CapabilityGrantMissing)
);
}
#[test]
fn every_refusal_of_a_supported_mechanism_is_a_capability_grant_problem() {
for probe in [
EgressNamespaceProbe::Denied,
EgressNamespaceProbe::MapDenied,
EgressNamespaceProbe::ConfigDenied,
] {
assert_eq!(
egress_namespace_blocker(true, support(true, probe)),
Some(EgressNamespaceBlocker::CapabilityGrantMissing),
"{probe:?}"
);
}
}
#[test]
fn a_kernel_without_the_mechanism_is_named_separately() {
assert_eq!(
egress_namespace_blocker(true, support(true, EgressNamespaceProbe::Unsupported)),
Some(EgressNamespaceBlocker::KernelSupportMissing)
);
}
#[test]
fn non_linux_is_never_refused_for_a_mechanism_it_never_had() {
for probe in [
EgressNamespaceProbe::Ok,
EgressNamespaceProbe::Denied,
EgressNamespaceProbe::Unsupported,
] {
assert_eq!(egress_namespace_blocker(false, support(false, probe)), None);
}
}
#[test]
fn every_blocker_refuses_the_retired_fallback_in_so_many_words() {
for blocker in EgressNamespaceBlocker::ALL {
let reason = blocker.reason();
assert!(
reason.contains("will not fall back"),
"{blocker:?} must state that the retired seccomp path is not a fallback"
);
}
}
#[test]
fn the_capability_grant_refusal_names_both_remediations() {
let reason = EgressNamespaceBlocker::CapabilityGrantMissing.reason();
assert!(reason.contains("apparmor_parser"), "names the AppArmor fix");
assert!(
reason.contains("--cap-add SYS_ADMIN"),
"names the container fix"
);
}
#[test]
fn the_kernel_refusal_names_the_sysctl_and_the_config_symbol() {
let reason = EgressNamespaceBlocker::KernelSupportMissing.reason();
assert!(reason.contains("user.max_user_namespaces"));
assert!(reason.contains("CONFIG_USER_NS"));
}
#[test]
fn the_socket_count_is_one_per_port_plus_the_resolver() {
let plan = CapsuleNetnsPlan {
tcp_ports: vec![80, 443],
uid: 1000,
gid: 1000,
};
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,
};
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,
};
assert!(plan.socket_count() <= MAX_NAMESPACE_SOCKETS);
}
}