Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Fork + confinement sequence: child-side Landlock + seccomp application
// and parent-child pipe synchronization.

use std::ffi::CString;
use std::ffi::{CStr, CString};
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};

Expand Down Expand Up @@ -193,9 +193,23 @@ fn write_id_maps(real_uid: u32, real_gid: u32, target_uid: u32, target_gid: u32)
/// handlers). Lifetimes tie everything to the parent's stack frame — the
/// child never outlives the fork point because `confine_child` either execs
/// or exits.
/// The terminal action `confine_child` performs after confinement is installed.
/// Exactly one of the two: there is no longer a "command plus optional override".
pub(crate) enum ChildEntry<'a> {
/// `execve` this command (the normal path). argv[0] becomes the process name.
Exec(&'a [CString]),
/// Run this function in-process, with the process named `name`. Used for the
/// OCI in-sandbox PID-1: the child is a fork of the supervisor so the code is
/// already mapped, nothing is exec'd, and Landlock has no execve to
/// authorize. `run` must not return; `confine_child` `_exit(0)`s if it does.
InProcess { name: &'a CStr, run: fn() },
}

pub(crate) struct ChildSpawnArgs<'a> {
pub sandbox: &'a Sandbox,
pub cmd: &'a [CString],
/// Terminal action after confinement: `execve` a command or run a fn
/// in-process. See [`ChildEntry`].
pub entry: ChildEntry<'a>,
pub pipes: &'a PipePair,
/// Skip the user-notification supervisor: child installs a kernel-only
/// deny filter, parent reads `notif_fd_num = 0` and never starts a
Expand All @@ -215,14 +229,22 @@ pub(crate) struct ChildSpawnArgs<'a> {
pub parent_pid: libc::pid_t,
}

/// Apply irreversible confinement (Landlock + seccomp) then exec the command.
/// Set the calling thread/process name (`/proc/<pid>/comm`, shown by `ps`). The
/// kernel truncates to 15 bytes + NUL. Used for the in-process PID-1, which has
/// no `execve` to set its name from argv[0].
fn set_proc_name(name: &CStr) {
unsafe { libc::prctl(libc::PR_SET_NAME, name.as_ptr() as libc::c_ulong, 0, 0, 0) };
}

/// Apply irreversible confinement (Landlock + seccomp), then either `execve` the
/// command or run an in-process entrypoint, per [`ChildEntry`].
///
/// This function **never returns**: it calls `execvp` on success or
/// `_exit(127)` on any error.
/// This function **never returns**: on success it execs or runs the entrypoint
/// (which `_exit`s); on any error it `_exit(127)`s.
pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
let ChildSpawnArgs {
sandbox,
cmd,
entry,
pipes,
no_supervisor,
keep_fds,
Expand Down Expand Up @@ -490,6 +512,22 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! {
// Empty list = all GPUs visible, don't set env vars
}

// 14. Terminal action: run the in-process entrypoint, or fall through to
// execve the command. The in-process arm diverges (`_exit`), so the match
// yields the command slice only on the `Exec` path.
let cmd: &[CString] = match entry {
ChildEntry::InProcess { name, run } => {
// Name the PID-1 so ps / /proc/<pid>/comm read correctly: there is
// no execve here to set argv[0]. The child is a fork of the
// supervisor, so `run`'s code is already mapped; running it directly
// avoids an execve that Landlock would otherwise have to authorize.
set_proc_name(name);
run();
unsafe { libc::_exit(0) };
}
ChildEntry::Exec(cmd) => cmd,
};

// 14. exec
debug_assert!(!cmd.is_empty(), "cmd must not be empty");
let argv_ptrs: Vec<*const libc::c_char> = cmd
Expand Down
56 changes: 54 additions & 2 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,17 @@ pub struct Sandbox {

// Environment
pub chroot: Option<PathBuf>,

/// When set, the confined child runs this function in-process instead of
/// `execve`-ing a workload. Used to run an in-sandbox PID-1 (the OCI
/// `sandlock-init` control loop) without exec'ing a separate image: the
/// child is already a fork of the supervisor, so its code is mapped, and
/// because nothing is exec'd, Landlock has no execution to authorize. The
/// function must not return (it loops and `_exit`s); `confine_child` calls
/// `_exit(0)` if it does.
#[serde(skip)]
pub in_child_main: Option<fn()>,

pub clean_env: bool,
pub env: HashMap<String, String>,
// Devices
Expand Down Expand Up @@ -444,6 +455,7 @@ impl Clone for Sandbox {
on_error: self.on_error.clone(),
fs_mount: self.fs_mount.clone(),
chroot: self.chroot.clone(),
in_child_main: self.in_child_main,
clean_env: self.clean_env,
env: self.env.clone(),
gpu_devices: self.gpu_devices.clone(),
Expand Down Expand Up @@ -847,6 +859,28 @@ impl Sandbox {
self.do_create(cmd, false).await
}

/// Create a confined child that, instead of `execve`-ing a workload, runs
/// `entrypoint` in-process after confinement is installed. The child is a
/// `fork()` of this process, so `entrypoint`'s code is already mapped; no
/// image is exec'd, so Landlock has nothing to authorize for the child's own
/// startup. `extra_fds` maps caller fds onto fixed fd numbers in the child
/// (e.g. the control channel). Used to run the OCI in-sandbox PID-1.
///
/// `name` is not exec'd; it sets the child's process name
/// (`/proc/<pid>/comm`). `start()` releases the parked child to run
/// `entrypoint`.
pub async fn create_with_in_child_main(
&mut self,
name: &str,
extra_fds: Vec<(i32, i32)>,
entrypoint: fn(),
) -> Result<(), crate::error::SandlockError> {
self.ensure_runtime()?;
self.in_child_main = Some(entrypoint);
self.rt_mut().extra_fds = extra_fds;
self.do_create(&[name], false).await
}

/// Freeze the sandbox: hold fork notifications + SIGSTOP the process group.
pub(crate) async fn freeze(&self) -> Result<(), crate::error::SandlockError> {
use crate::error::{SandboxRuntimeError, SandlockError};
Expand Down Expand Up @@ -880,8 +914,20 @@ impl Sandbox {
let pid = self.runtime.as_ref()
.and_then(|rt| rt.child_pid)
.ok_or(SandlockError::Runtime(SandboxRuntimeError::NotRunning))?;
self.checkpoint_pid(pid).await
}

/// Capture a checkpoint targeting a specific pid instead of the sandbox's
/// direct child. The target must be a fork-descendant confined by the same
/// policy (e.g. the workload spawned by sandlock-init). `target_pid` must
/// be positive.
pub async fn checkpoint_pid(&self, target_pid: i32) -> Result<crate::checkpoint::Checkpoint, crate::error::SandlockError> {
use crate::error::{SandboxRuntimeError, SandlockError};
if target_pid <= 0 {
return Err(SandlockError::Runtime(SandboxRuntimeError::NotRunning));
}
self.freeze().await?;
let cp = crate::checkpoint::capture(pid, self);
let cp = crate::checkpoint::capture(target_pid, self);
self.thaw().await?;
cp
}
Expand Down Expand Up @@ -1454,9 +1500,15 @@ impl Sandbox {
.collect();

let sandbox_name = self.rt().name.clone();
// In-process entrypoint (OCI PID-1) names the process from cmd[0];
// otherwise execve the command.
let entry = match self.in_child_main {
Some(run) => context::ChildEntry::InProcess { name: c_cmd[0].as_c_str(), run },
None => context::ChildEntry::Exec(&c_cmd),
};
context::confine_child(context::ChildSpawnArgs {
sandbox: self,
cmd: &c_cmd,
entry,
pipes: &pipes,
no_supervisor,
keep_fds: &gather_keep_fds,
Expand Down
1 change: 1 addition & 0 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ impl SandboxBuilder {
on_error: self.on_error.unwrap_or_default(),
fs_mount: self.fs_mount,
chroot: self.chroot,
in_child_main: None,
clean_env: self.clean_env,
env: self.env,
gpu_devices: self.gpu_devices,
Expand Down
165 changes: 165 additions & 0 deletions crates/sandlock-oci/src/fdpass.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//! SCM_RIGHTS file-descriptor passing over the supervisor control socket.
//!
//! `exec` is the only control command that carries open fds (the CLI's
//! stdin/stdout/stderr, which the container shim wired to the exec stream).
//! The daemon must receive those fds with the SAME `recvmsg` that reads the
//! command bytes, because ancillary data binds to specific bytes. All other
//! commands send an empty ancillary payload, so the receive path is uniform.

use std::io;
use std::os::unix::io::{FromRawFd, OwnedFd, RawFd};

/// Send `data` plus `fds` (as SCM_RIGHTS) over a blocking unix socket.
pub fn send_with_fds(
stream: &std::os::unix::net::UnixStream,
data: &[u8],
fds: &[RawFd],
) -> io::Result<()> {
use std::os::unix::io::AsRawFd;

let mut iov = libc::iovec {
iov_base: data.as_ptr() as *mut libc::c_void,
iov_len: data.len(),
};
let fds_bytes = std::mem::size_of_val(fds) as u32;
let cmsg_space = if fds.is_empty() {
0usize
} else {
unsafe { libc::CMSG_SPACE(fds_bytes) as usize }
};
let mut cmsg_buf = vec![0u8; cmsg_space];

let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
if !fds.is_empty() {
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_space as _;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(fds_bytes) as _;
std::ptr::copy_nonoverlapping(
fds.as_ptr(),
libc::CMSG_DATA(cmsg) as *mut RawFd,
fds.len(),
);
}
}

let n = unsafe { libc::sendmsg(stream.as_raw_fd(), &msg, 0) };
if n < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}

/// Receive bytes plus up to `max_fds` SCM_RIGHTS fds from a raw socket fd
/// (one `recvmsg`). Returns the data and any received `OwnedFd`s.
pub fn recv_with_fds(fd: RawFd, max_fds: usize) -> io::Result<(Vec<u8>, Vec<OwnedFd>)> {
let mut buf = vec![0u8; 8192];
let mut iov = libc::iovec {
iov_base: buf.as_mut_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
let cmsg_space =
unsafe { libc::CMSG_SPACE((max_fds * std::mem::size_of::<RawFd>()) as u32) as usize };
let mut cmsg_buf = vec![0u8; cmsg_space];

let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_space as _;

let n = unsafe { libc::recvmsg(fd, &mut msg, 0) };
if n < 0 {
return Err(io::Error::last_os_error());
}

let mut fds = Vec::new();
unsafe {
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
while !cmsg.is_null() {
if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS {
let payload = (*cmsg).cmsg_len as usize - libc::CMSG_LEN(0) as usize;
let count = payload / std::mem::size_of::<RawFd>();
let data_ptr = libc::CMSG_DATA(cmsg) as *const RawFd;
for i in 0..count {
fds.push(OwnedFd::from_raw_fd(*data_ptr.add(i)));
}
}
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
}
}

buf.truncate(n as usize);
Ok((buf, fds))
}

/// Tokio wrapper: await readability, then one `recv_with_fds` on the raw fd.
/// `recvmsg` returns `EAGAIN` (`WouldBlock`) if the socket was not actually
/// ready; loop until it yields data.
pub async fn recv_with_fds_async(
stream: &tokio::net::UnixStream,
max_fds: usize,
) -> io::Result<(Vec<u8>, Vec<OwnedFd>)> {
use std::os::unix::io::AsRawFd;
loop {
stream.readable().await?;
match stream.try_io(tokio::io::Interest::READABLE, || {
recv_with_fds(stream.as_raw_fd(), max_fds)
}) {
Ok(res) => return Ok(res),
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => continue,
Err(e) => return Err(e),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::os::unix::io::AsRawFd;
use std::os::unix::net::UnixStream;

/// Send a pipe's write-end across a socketpair, then prove the received fd
/// is the SAME open file: writing through it is readable from the original
/// read-end.
#[test]
fn send_and_recv_one_fd_roundtrip() {
let (a, b) = UnixStream::pair().unwrap();

// A pipe whose write end we will pass over the socket.
let mut fds = [0i32; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
let (pipe_r, pipe_w) = (fds[0], fds[1]);

send_with_fds(&a, b"PING", &[pipe_w]).unwrap();
let (data, got) = recv_with_fds(b.as_raw_fd(), 3).unwrap();

assert_eq!(&data, b"PING");
assert_eq!(got.len(), 1);

// Write through the RECEIVED fd, read from the original pipe read end.
let received_w = got[0].as_raw_fd();
assert_eq!(unsafe { libc::write(received_w, b"Z".as_ptr() as *const _, 1) }, 1);
let mut buf = [0u8; 1];
assert_eq!(unsafe { libc::read(pipe_r, buf.as_mut_ptr() as *mut _, 1) }, 1);
assert_eq!(buf[0], b'Z');

unsafe { libc::close(pipe_r); libc::close(pipe_w); }
let _ = (a, b);
}

#[test]
fn recv_without_fds_returns_empty_vec() {
let (mut a, b) = UnixStream::pair().unwrap();
a.write_all(b"hello").unwrap();
let (data, got) = recv_with_fds(b.as_raw_fd(), 3).unwrap();
assert_eq!(&data, b"hello");
assert!(got.is_empty());
}
}
31 changes: 31 additions & 0 deletions crates/sandlock-oci/src/init/fdrecv.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use std::os::unix::io::RawFd;

/// Blocking recvmsg of one message plus up to `max_fds` SCM_RIGHTS fds.
/// Returns (bytes, fds). Empty bytes means EOF/peer closed.
pub fn recv(fd: RawFd, max_fds: usize) -> std::io::Result<(Vec<u8>, Vec<RawFd>)> {
let mut buf = vec![0u8; 65536];
let mut iov = libc::iovec { iov_base: buf.as_mut_ptr() as *mut _, iov_len: buf.len() };
let space = unsafe { libc::CMSG_SPACE((max_fds * 4) as u32) as usize };
let mut cbuf = vec![0u8; space];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cbuf.as_mut_ptr() as *mut _;
msg.msg_controllen = space as _;
let n = unsafe { libc::recvmsg(fd, &mut msg, 0) };
if n < 0 { return Err(std::io::Error::last_os_error()); }
let mut fds = Vec::new();
unsafe {
let mut c = libc::CMSG_FIRSTHDR(&msg);
while !c.is_null() {
if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS {
let payload = (*c).cmsg_len as usize - libc::CMSG_LEN(0) as usize;
let p = libc::CMSG_DATA(c) as *const RawFd;
for i in 0..(payload / 4) { fds.push(*p.add(i)); }
}
c = libc::CMSG_NXTHDR(&msg, c);
}
}
buf.truncate(n as usize);
Ok((buf, fds))
}
Loading
Loading