feat(sandbox): initialize isolated namespaces through gated helpers
This commit is contained in:
+45
-5
@@ -5,7 +5,7 @@ Cargo workspace 分为五个 crate:
|
||||
| Crate | 职责 | 当前能力 |
|
||||
| --- | --- | --- |
|
||||
| `kf-contracts` | 跨服务通知和执行契约 | 最小 v1 通知、共享 JSON 样例检查 |
|
||||
| `kf-sandbox` | Linux 隔离、限制、计量与清理 | 规格/配置检查与 `probe` 只读观察;`run` 拒绝执行 |
|
||||
| `kf-sandbox` | Linux 隔离、限制、计量与清理 | 规格/配置检查、内部 cgroup/namespace 初始化;`run` 拒绝执行 |
|
||||
| `kf-engine` | 语言适配、测试点编排、比较与确定性汇总 | 预留库边界,无模拟判定 |
|
||||
| `kf-worker` | 拉取、租约、工件准备与本地编排 | `--check` 入口,无消费循环 |
|
||||
| `kf-coordinator` | 节点登记、租约、状态与结果写入权 | HTTP 存活检查,无任务端点 |
|
||||
@@ -98,8 +98,8 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜
|
||||
|
||||
`launcher/entry.rs` 在可信二进制的独立进程中运行,不启动 Tokio、不安装自定义 `pre_exec`
|
||||
闭包,也不在 Worker 的 fork 子进程里运行 Rust 初始化逻辑。入口实际检查 `/proc/self/task`
|
||||
只有一个线程;未来每次权限操作或 fork 前须再次检查。当前入口以普通身份运行,没有实现
|
||||
特权 helper、namespace、降权、seccomp 或用户程序执行,初始化返回准备阶段的能力不可用。
|
||||
只有一个线程;未来每次权限操作或 fork 前须再次检查。此控制入口以普通身份运行,不进入 namespace、降权、seccomp 或用户执行阶段,
|
||||
初始化返回准备阶段的能力不可用。独立的特权 namespace 初始化入口见下文。
|
||||
|
||||
控制端必须先在可信 state 根下创建独占、权限 0700 的每次启动目录和 Unix listener,再通过
|
||||
清空环境的 `Command` 启动内部入口;socket 不放在用户工作区,控制通道与用户 stdio 分开。
|
||||
@@ -112,9 +112,10 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜
|
||||
非法 run_id 和未知命令。所有 I/O 共用一个绝对截止时间(入口 5 秒,通道最大 10 秒),
|
||||
部分帧不能续期;EOF、解析、超时或写入错误关闭并锁定通道,不能重新同步后继续初始化。
|
||||
|
||||
新增 `rustix` 1.1.5 仅用于沙箱的安全 syscall/FD 封装,启用 `net`、`process`,由锁文件固定。
|
||||
`rustix` 1.1.5 用于沙箱的 syscall/FD 封装,由锁文件固定。
|
||||
选择它的 OwnedFd/AsFd 接口完成连接与凭据核对;目前无需 `nix` 或直接 `libc` 调用,
|
||||
也未放宽 `unsafe` 限制。参见 [rustix](https://github.com/bytecodealliance/rustix)、
|
||||
namespace 初始化另有一个局部、附安全理由的 `unshare_unsafe` 调用,workspace 的 unsafe deny
|
||||
仍生效;不使用 FILES 标志,调用前检查独立入口只有一个线程。参见 [rustix](https://github.com/bytecodealliance/rustix)、
|
||||
[socket_peercred](https://docs.rs/rustix/1.1.5/rustix/net/sockopt/fn.socket_peercred.html) 和
|
||||
[fork 后的安全约束](https://doc.rust-lang.org/std/os/unix/process/trait.CommandExt.html#tymethod.pre_exec)。
|
||||
|
||||
@@ -158,6 +159,45 @@ memory.max、交换空间 0、pids.max、cpu.max 和 memory.oom.group,限制
|
||||
[pidfd_open](https://man7.org/linux/man-pages/man2/pidfd_open.2.html)、
|
||||
[PR_SET_PDEATHSIG](https://man7.org/linux/man-pages/man2/PR_SET_PDEATHSIG.2const.html)。
|
||||
|
||||
## 实际 namespace 初始化
|
||||
|
||||
`isolation/namespaces.rs` 集中 namespace 策略及 OS 操作,`launcher/namespaces.rs`
|
||||
负责进程与控制编排。它的 `NamespacedTarget` 只推进固定的可信初始化,不接收用户命令。
|
||||
它沿用停止目标及 cgroup 纳入流程;确认限额、成员和冻结状态后,通过私有 socketpair
|
||||
发送初始化命令,再仅在此阶段解除冻结和继续 bootstrap。通道仍使用 1 KiB 帧及绝对截止时间,
|
||||
协议只含 initialize/cancel 和初始化结果,没有用户 argv、路径或 exec 命令。
|
||||
|
||||
socketpair 的 SO_PEERCRED 标识创建者,不能当作子进程 PID 认证;安全依据是可信 Command
|
||||
独占继承的端点。bootstrap 在 unshare 前核对创建者。PID 1 的对端位于祖先 PID namespace,
|
||||
内核会把该对端 PID 映射为 0,rustix UCred 的非零 Pid 类型不能表示它,因此 PID 1 只使用
|
||||
已经认证并独占继承的端点,不在该角色再次调用 socket_peercred。控制 socket 暂占内部入口的
|
||||
FD 0,stdout/stderr 为 null;这里没有用户 stdio,最终 FD 白名单和 stdio 收集仍待实现。
|
||||
|
||||
初始化要求原 user namespace 中的 root helper,建立 mount/PID/IPC/UTS/network/cgroup
|
||||
namespace,并立即把挂载传播递归设为 private。P0 选定的身份模型保留原 user namespace;
|
||||
单 UID 的非特权 user namespace 映射不受支持。独立任务 UID/GID 租约、降权和 capabilities
|
||||
清除属于后续身份阶段,当前 helper 仍有初始化权限,不能执行用户代码。
|
||||
|
||||
NEWPID 仅影响后续子进程,所以 bootstrap 再次 fresh exec 可信入口作为 namespace PID 1。
|
||||
PID 1 设置父线程死亡 SIGKILL,并利用尚未替换的祖先 procfs 核对实际外部父 PID。
|
||||
监督器读回其父子关系、NSpid、六类 namespace inode 与保留的 user namespace,随后从祖先
|
||||
namespace 通过 pidfd 使 PID 1 停止,再冻结整个任务 cgroup。bootstrap 仍位于祖先 PID
|
||||
namespace,负责等待这个 init;用户进程树的 PID-1 回收循环尚未实现。
|
||||
|
||||
cgroup namespace 在实际任务成员关系建立后创建,PID 1 核对自己的 cgroup 视图为 `0::/`。
|
||||
它只虚拟化 cgroup 路径;限额和终止继续由外部监督器持有的 cgroup FD 执行。完成此阶段后,
|
||||
最小运行根、受限 proc/dev、身份及 seccomp 均未就绪;`refuse_unimplemented_root` 返回
|
||||
root_filesystem 阶段拒绝并清理,CLI run 继续拒绝。
|
||||
|
||||
`namespace_vm.rs` 与 `blocked_vm.rs` 使用同一个 VM 条件守卫。独立 VM 中的固定 C 探针验证
|
||||
UTS 主机名、SysV IPC 隔离、新 network namespace 仅含未启用的 lo、跨任务挂载隔离和
|
||||
PID namespace 的后续子进程。探针由验收运行者在 VM 中进入目标 namespace,没有开放生产
|
||||
用户执行入口。权限测试还覆盖 pids.max 导致初始化失败及冻结目标丢弃;VM 自身的 PID 1
|
||||
在整体终止路径回收孤儿 helper,完整服务进程树回收仍待实现,不能据此宣称已完成。
|
||||
|
||||
参见 [PID namespace](https://man7.org/linux/man-pages/man7/pid_namespaces.7.html)、
|
||||
[cgroup namespace](https://man7.org/linux/man-pages/man7/cgroup_namespaces.7.html)。
|
||||
|
||||
## 可信运行配置与启动前检查
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,7 +10,7 @@ kf-contracts.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rustix = { version = "1.1.5", features = ["net", "process", "fs"] }
|
||||
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -218,6 +218,46 @@ impl TaskCgroup {
|
||||
Ok(self.limits.clone())
|
||||
}
|
||||
|
||||
/// Private fixed initialization only, never a public user-exec release.
|
||||
pub(crate) fn begin_namespace_initialization(
|
||||
&mut self,
|
||||
target: &mut BlockedTarget,
|
||||
) -> io::Result<()> {
|
||||
target.verify_stopped()?;
|
||||
self.applied_limits()?;
|
||||
self.wait_event("frozen", "1", Duration::from_secs(1))?;
|
||||
self.verify_members(&[target.pid()])?;
|
||||
write_verify(&self.fd, "cgroup.freeze", "0")?;
|
||||
self.wait_event("frozen", "0", Duration::from_secs(1))?;
|
||||
target.continue_initialization()
|
||||
}
|
||||
|
||||
pub(crate) fn finish_namespace_initialization(&mut self, members: &[u32]) -> io::Result<()> {
|
||||
self.verify_members(members)?;
|
||||
self.applied_limits()?;
|
||||
write_verify(&self.fd, "cgroup.freeze", "1")?;
|
||||
self.wait_event("frozen", "1", Duration::from_secs(1))?;
|
||||
self.verify_members(members)
|
||||
}
|
||||
|
||||
fn verify_members(&self, expected: &[u32]) -> io::Result<()> {
|
||||
let mut members = read(&self.fd, "cgroup.procs")?
|
||||
.split_whitespace()
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| invalid("invalid cgroup member"))
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let mut expected = expected.to_vec();
|
||||
members.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
if members != expected {
|
||||
return Err(invalid("unexpected cgroup members"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PID comes from the unreaped direct Child; callers cannot enroll arbitrary PIDs.
|
||||
pub fn enroll(&mut self, target: &mut BlockedTarget) -> Result<(), CgroupError> {
|
||||
self.live()?;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Fixed Linux isolation operations; launcher owns process/control orchestration.
|
||||
|
||||
pub mod namespaces;
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Namespace policy and kernel operations, restricted to a fresh trusted helper.
|
||||
|
||||
use std::io;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
/// Kernel namespace inode identities, observed by the trusted supervisor.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NamespaceIds {
|
||||
pub mount: u64,
|
||||
pub pid: u64,
|
||||
pub ipc: u64,
|
||||
pub uts: u64,
|
||||
pub network: u64,
|
||||
pub cgroup: u64,
|
||||
pub user: u64,
|
||||
}
|
||||
|
||||
impl NamespaceIds {
|
||||
pub fn observe(pid: u32) -> io::Result<Self> {
|
||||
let inode = |name| std::fs::metadata(format!("/proc/{pid}/ns/{name}")).map(|m| m.ino());
|
||||
Ok(Self {
|
||||
mount: inode("mnt")?,
|
||||
pid: inode("pid")?,
|
||||
ipc: inode("ipc")?,
|
||||
uts: inode("uts")?,
|
||||
network: inode("net")?,
|
||||
cgroup: inode("cgroup")?,
|
||||
user: inode("user")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn verify_separate(&self, parent: &Self) -> io::Result<()> {
|
||||
if self.mount == parent.mount
|
||||
|| self.pid == parent.pid
|
||||
|| self.ipc == parent.ipc
|
||||
|| self.uts == parent.uts
|
||||
|| self.network == parent.network
|
||||
|| self.cgroup == parent.cgroup
|
||||
|| self.user != parent.user
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"namespace/privileged-helper identity mismatch",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One narrowly scoped unsafe call: rustix requires unsafe for all unshare
|
||||
/// flags because FILES can split FD tables. We never use FILES or NEWUSER,
|
||||
/// and run only in a fresh, verified single-threaded trusted helper.
|
||||
#[allow(unsafe_code)]
|
||||
pub(crate) fn create() -> io::Result<()> {
|
||||
crate::launcher::entry::ensure_single_threaded()?;
|
||||
// P0 policy: privileged initialization in the original user namespace,
|
||||
// followed (in a later stage) by distinct task UID/GID and capability
|
||||
// removal. Single-UID NEWUSER mapping is deliberately unsupported.
|
||||
if rustix::process::geteuid().as_raw() != 0
|
||||
|| rustix::process::getuid().as_raw() != 0
|
||||
|| std::fs::metadata("/proc/self/ns/user")?.ino()
|
||||
!= std::fs::metadata("/proc/1/ns/user")?.ino()
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"requires original-user-namespace privileged helper",
|
||||
));
|
||||
}
|
||||
let flags = rustix::thread::UnshareFlags::NEWNS
|
||||
| rustix::thread::UnshareFlags::NEWPID
|
||||
| rustix::thread::UnshareFlags::NEWIPC
|
||||
| rustix::thread::UnshareFlags::NEWUTS
|
||||
| rustix::thread::UnshareFlags::NEWNET
|
||||
| rustix::thread::UnshareFlags::NEWCGROUP;
|
||||
// SAFETY: verified single thread, no FILES flag, no foreign FD-table users.
|
||||
unsafe {
|
||||
rustix::thread::unshare_unsafe(flags)?;
|
||||
}
|
||||
rustix::mount::mount_change(
|
||||
"/",
|
||||
rustix::mount::MountPropagationFlags::PRIVATE | rustix::mount::MountPropagationFlags::REC,
|
||||
)?;
|
||||
rustix::system::sethostname(b"kf-sandbox")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::spec::ValidatedSpec;
|
||||
use rustix::fd::{AsFd, OwnedFd};
|
||||
use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions};
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -50,6 +51,30 @@ pub struct BlockedTarget {
|
||||
impl BlockedTarget {
|
||||
/// `executable` is the operator's trusted sandbox binary, never a request argv.
|
||||
pub fn spawn(executable: &Path) -> io::Result<Self> {
|
||||
Self::spawn_inner(executable, None)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_initializing(
|
||||
executable: &Path,
|
||||
) -> io::Result<(Self, super::protocol::ControlChannel)> {
|
||||
let (parent, child) = UnixStream::pair()?;
|
||||
// socketpair credentials identify its creator, not the eventual child.
|
||||
// Authentication here is exclusive endpoint inheritance through this
|
||||
// trusted Command; no user process exists or receives these endpoints.
|
||||
let channel = super::protocol::ControlChannel::new(
|
||||
parent,
|
||||
super::protocol::ExpectedPeer {
|
||||
pid: std::process::id(),
|
||||
uid: rustix::process::geteuid().as_raw(),
|
||||
gid: rustix::process::getegid().as_raw(),
|
||||
},
|
||||
Instant::now() + super::protocol::MAX_CONTROL_TIMEOUT,
|
||||
)?;
|
||||
let target = Self::spawn_inner(executable, Some(child))?;
|
||||
Ok((target, channel))
|
||||
}
|
||||
|
||||
fn spawn_inner(executable: &Path, initialization: Option<UnixStream>) -> io::Result<Self> {
|
||||
// A ignored/caught SIGCHLD could reap/recycle a PID before pidfd_open.
|
||||
// Fresh helpers retain the default disposition; reject inherited changes.
|
||||
let status = std::fs::read_to_string("/proc/self/status")?;
|
||||
@@ -66,15 +91,22 @@ impl BlockedTarget {
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut child = Command::new(executable)
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.arg("bootstrap")
|
||||
.arg("--parent-pid")
|
||||
.arg(std::process::id().to_string())
|
||||
.env_clear()
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
.stderr(Stdio::null());
|
||||
if let Some(socket) = initialization {
|
||||
let fd: OwnedFd = socket.into();
|
||||
command
|
||||
.arg("--initialize-namespaces")
|
||||
.stdin(Stdio::from(fd));
|
||||
}
|
||||
let mut child = command.spawn()?;
|
||||
let Some(pid) = Pid::from_raw(child.id() as i32) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -124,6 +156,14 @@ impl BlockedTarget {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
pub(crate) fn continue_initialization(&mut self) -> io::Result<()> {
|
||||
self.verify_stopped()?;
|
||||
Ok(rustix::process::pidfd_send_signal(
|
||||
&self.pidfd,
|
||||
Signal::CONT,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn poll_exit(&mut self) -> io::Result<Option<ExitStatus>> {
|
||||
let status = self.child.try_wait()?;
|
||||
if status.is_some() {
|
||||
@@ -183,7 +223,11 @@ pub struct InitializationFailure {
|
||||
pub cleanup_failure: Option<ExecutionError>,
|
||||
}
|
||||
|
||||
fn failure(stage: ExecutionStage, code: ErrorCode, error: Option<&io::Error>) -> ExecutionError {
|
||||
pub(crate) fn failure(
|
||||
stage: ExecutionStage,
|
||||
code: ErrorCode,
|
||||
error: Option<&io::Error>,
|
||||
) -> ExecutionError {
|
||||
ExecutionError {
|
||||
stage,
|
||||
code,
|
||||
@@ -196,8 +240,8 @@ fn failure(stage: ExecutionStage, code: ErrorCode, error: Option<&io::Error>) ->
|
||||
/// Owns the real stopped child and its frozen task cgroup. No lifecycle event,
|
||||
/// JSON field or public method can turn this into a user-code execution permit.
|
||||
pub struct PreparedTarget {
|
||||
target: BlockedTarget,
|
||||
cgroup: TaskCgroup,
|
||||
pub(crate) target: BlockedTarget,
|
||||
pub(crate) cgroup: TaskCgroup,
|
||||
}
|
||||
|
||||
impl PreparedTarget {
|
||||
@@ -215,17 +259,44 @@ impl PreparedTarget {
|
||||
),
|
||||
cleanup_failure: None,
|
||||
})?;
|
||||
Self::enroll(config, spec, &mut target).map(|cgroup| Self { target, cgroup })
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_initializing(
|
||||
config: &ValidatedRuntimeConfig,
|
||||
spec: &ValidatedSpec,
|
||||
executable: &Path,
|
||||
) -> Result<(Self, super::protocol::ControlChannel), InitializationFailure> {
|
||||
let (mut target, channel) =
|
||||
BlockedTarget::spawn_initializing(executable).map_err(|error| {
|
||||
InitializationFailure {
|
||||
failure: failure(
|
||||
ExecutionStage::Preparation,
|
||||
ErrorCode::OperationFailed,
|
||||
Some(&error),
|
||||
),
|
||||
cleanup_failure: None,
|
||||
}
|
||||
})?;
|
||||
Self::enroll(config, spec, &mut target).map(|cgroup| (Self { target, cgroup }, channel))
|
||||
}
|
||||
|
||||
fn enroll(
|
||||
config: &ValidatedRuntimeConfig,
|
||||
spec: &ValidatedSpec,
|
||||
target: &mut BlockedTarget,
|
||||
) -> Result<TaskCgroup, InitializationFailure> {
|
||||
let setup = (|| {
|
||||
let root = DelegatedCgroup::open(config)?;
|
||||
let mut group = root.create_task(spec)?;
|
||||
if let Err(mut error) = group.enroll(&mut target) {
|
||||
if let Err(mut error) = group.enroll(target) {
|
||||
error.cleanup_error = group.kill_and_remove(CLEANUP).err();
|
||||
return Err(error);
|
||||
}
|
||||
Ok::<_, crate::cgroup::CgroupError>(group)
|
||||
})();
|
||||
match setup {
|
||||
Ok(cgroup) => Ok(Self { target, cgroup }),
|
||||
Ok(cgroup) => Ok(cgroup),
|
||||
Err(error) => {
|
||||
let reap_error = target.terminate().err();
|
||||
Err(InitializationFailure {
|
||||
@@ -258,14 +329,18 @@ impl PreparedTarget {
|
||||
/// Missing isolation is a real initialization failure, never readiness.
|
||||
/// Kill/reap happens before a refusal is returned to the caller.
|
||||
pub fn refuse_unimplemented_isolation(mut self) -> InitializationFailure {
|
||||
self.refuse_at(ExecutionStage::Namespaces, None)
|
||||
}
|
||||
|
||||
pub(crate) fn refuse_at(
|
||||
&mut self,
|
||||
stage: ExecutionStage,
|
||||
error: Option<&io::Error>,
|
||||
) -> InitializationFailure {
|
||||
let group_error = self.cgroup.kill_and_remove(CLEANUP).err();
|
||||
let reap_error = self.target.terminate().err();
|
||||
InitializationFailure {
|
||||
failure: failure(
|
||||
ExecutionStage::Namespaces,
|
||||
ErrorCode::CapabilityUnavailable,
|
||||
None,
|
||||
),
|
||||
failure: failure(stage, ErrorCode::CapabilityUnavailable, error),
|
||||
cleanup_failure: group_error.as_ref().or(reap_error.as_ref()).map(|e| {
|
||||
failure(
|
||||
ExecutionStage::Cleanup,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Lifecycle events describe completed operations, not commands received from
|
||||
//! an untrusted request. The standalone entry authenticates its parent and uses
|
||||
//! bounded IPC, but refuses initialization until OS isolation is implemented.
|
||||
//! bounded IPC, and refuses user execution. The separate namespace bootstrap initializes
|
||||
//! only fixed OS isolation operations and remains gated at the missing root stage.
|
||||
|
||||
pub mod lifecycle;
|
||||
|
||||
@@ -11,4 +12,6 @@ pub mod blocked;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod entry;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod namespaces;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod protocol;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Fixed privileged initialization, with a fresh exec for namespace PID 1.
|
||||
//! This stage never accepts request argv and never enables user execution.
|
||||
|
||||
use super::blocked::{InitializationFailure, PreparedTarget};
|
||||
use super::protocol::{
|
||||
ControlChannel, ExpectedPeer, MAX_CONTROL_TIMEOUT, NamespaceCommand, NamespaceEvent,
|
||||
};
|
||||
use crate::outcome::ExecutionStage;
|
||||
use crate::runtime::ValidatedRuntimeConfig;
|
||||
use crate::spec::ValidatedSpec;
|
||||
use rustix::process::Signal;
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use crate::isolation::namespaces::NamespaceIds;
|
||||
|
||||
fn denied(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
fn inherited_channel(parent_pid: u32) -> io::Result<ControlChannel> {
|
||||
let fd = rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3)?;
|
||||
ControlChannel::new(
|
||||
UnixStream::from(fd),
|
||||
ExpectedPeer {
|
||||
pid: parent_pid,
|
||||
uid: rustix::process::geteuid().as_raw(),
|
||||
gid: rustix::process::getegid().as_raw(),
|
||||
},
|
||||
Instant::now() + MAX_CONTROL_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
/// Fixed namespace bootstrap. The inherited socket is authenticated before
|
||||
/// unshare; changing PID namespaces changes SO_PEERCRED's PID translation.
|
||||
pub fn bootstrap(expected_parent: u32) -> io::Result<()> {
|
||||
super::entry::ensure_single_threaded()?;
|
||||
rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?;
|
||||
if expected_parent <= 1
|
||||
|| rustix::process::getppid().map(|pid| pid.as_raw_pid() as u32) != Some(expected_parent)
|
||||
{
|
||||
return Err(denied("bootstrap parent mismatch"));
|
||||
}
|
||||
let mut channel = inherited_channel(expected_parent)?;
|
||||
rustix::process::kill_process(rustix::process::getpid(), Signal::STOP)?;
|
||||
match channel.receive::<NamespaceCommand>()? {
|
||||
NamespaceCommand::Cancel {} => {
|
||||
channel.send(&NamespaceEvent::Cancelled {})?;
|
||||
return Ok(());
|
||||
}
|
||||
NamespaceCommand::Initialize {} => {}
|
||||
}
|
||||
let setup = (|| {
|
||||
crate::isolation::namespaces::create()?;
|
||||
super::entry::ensure_single_threaded()?;
|
||||
// NEWPID applies to children, so a second fresh exec becomes PID 1.
|
||||
// No Rust initialization occurs in a custom fork/pre_exec callback.
|
||||
let child = Command::new(std::env::current_exe()?)
|
||||
.arg("namespace-init")
|
||||
.arg("--parent-pid")
|
||||
.arg(std::process::id().to_string())
|
||||
.env_clear()
|
||||
.stdin(Stdio::from(channel.duplicate_stream()?))
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
Ok::<_, io::Error>(child)
|
||||
})();
|
||||
let mut child = match setup {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
channel.send(&NamespaceEvent::Failed {
|
||||
errno: error.raw_os_error().and_then(|n| u16::try_from(n).ok()),
|
||||
})?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// PID 1 is the sole control writer now. Release the bootstrap's copies so
|
||||
// control EOF has its actual meaning. The bootstrap owns/reaps PID 1 only;
|
||||
// user descendants and the final init reaping loop are a separate stage.
|
||||
rustix::stdio::dup2_stdin(std::fs::File::open("/dev/null")?)?;
|
||||
drop(channel);
|
||||
let status = child.wait()?;
|
||||
if !status.success() {
|
||||
return Err(denied("namespace init refused execution"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_value(status: &str, name: &str) -> io::Result<u32> {
|
||||
status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix(name))
|
||||
.and_then(|raw| raw.trim().parse().ok())
|
||||
.ok_or_else(|| io::Error::other("missing process identity"))
|
||||
}
|
||||
|
||||
/// This helper runs before a new proc mount: the inherited procfs still reports
|
||||
/// outer PIDs, allowing the post-prctl parent check even when getppid() is zero
|
||||
/// in the new PID namespace. Later root setup must replace this inherited proc.
|
||||
pub fn namespace_init(expected_outer_parent: u32) -> io::Result<()> {
|
||||
super::entry::ensure_single_threaded()?;
|
||||
if rustix::process::getpid().as_raw_pid() != 1 || rustix::process::getppid().is_some() {
|
||||
return Err(denied("not the namespace init"));
|
||||
}
|
||||
rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?;
|
||||
let status = std::fs::read_to_string("/proc/self/status")?;
|
||||
if expected_outer_parent <= 1 || status_value(&status, "PPid:")? != expected_outer_parent {
|
||||
return Err(denied("namespace init parent mismatch"));
|
||||
}
|
||||
// The inherited proc still shows the outer PID, but the task's cgroup
|
||||
// namespace must already be rooted at its own resource-limited group.
|
||||
if std::fs::read_to_string("/proc/self/cgroup")?.trim() != "0::/" {
|
||||
return Err(denied("cgroup namespace not rooted at the task"));
|
||||
}
|
||||
let fd = rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3)?;
|
||||
let mut channel = ControlChannel::inherited_from_authenticated_bootstrap(
|
||||
UnixStream::from(fd),
|
||||
Instant::now() + MAX_CONTROL_TIMEOUT,
|
||||
)?;
|
||||
channel.send(&NamespaceEvent::Initialized {
|
||||
init_pid: status_value(&status, "Pid:")?,
|
||||
})?;
|
||||
// Only the supervisor in the ancestor PID namespace stops PID 1. Signals
|
||||
// sent within the namespace have special PID-1 restrictions. While it
|
||||
// verifies/stops us, this bounded receive can only cancel/refuse.
|
||||
let _ = channel.receive::<NamespaceCommand>()?;
|
||||
Err(denied("namespace init has no execution permit"))
|
||||
}
|
||||
|
||||
/// Actual namespaces only, not complete isolation or an execution permit.
|
||||
/// Both helper processes remain in the same bounded, frozen task cgroup.
|
||||
pub struct NamespacedTarget {
|
||||
prepared: PreparedTarget,
|
||||
init_pid: u32,
|
||||
ids: NamespaceIds,
|
||||
}
|
||||
|
||||
impl NamespacedTarget {
|
||||
pub fn prepare(
|
||||
config: &ValidatedRuntimeConfig,
|
||||
spec: &ValidatedSpec,
|
||||
executable: &Path,
|
||||
) -> Result<Self, InitializationFailure> {
|
||||
let (mut prepared, mut control) =
|
||||
PreparedTarget::prepare_initializing(config, spec, executable)?;
|
||||
let initialize = (|| {
|
||||
let parent_ids = NamespaceIds::observe(std::process::id())?;
|
||||
control.send(&NamespaceCommand::Initialize {})?;
|
||||
prepared
|
||||
.cgroup
|
||||
.begin_namespace_initialization(&mut prepared.target)?;
|
||||
let init_pid = match control.receive::<NamespaceEvent>()? {
|
||||
NamespaceEvent::Initialized { init_pid } => init_pid,
|
||||
NamespaceEvent::Failed { errno: Some(errno) } => {
|
||||
return Err(io::Error::from_raw_os_error(i32::from(errno)));
|
||||
}
|
||||
_ => return Err(denied("namespace initialization failed")),
|
||||
};
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let pid = rustix::process::Pid::from_raw(init_pid as i32)
|
||||
.ok_or_else(|| denied("invalid namespace init PID"))?;
|
||||
let pidfd = rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty())?;
|
||||
// The trusted bootstrap creates exactly one child and waits for
|
||||
// it. Verify its identity before signaling through the pidfd.
|
||||
let status = std::fs::read_to_string(format!("/proc/{init_pid}/status"))?;
|
||||
if status_value(&status, "PPid:")? != prepared.pid() {
|
||||
return Err(denied("namespace init parent mismatch"));
|
||||
}
|
||||
rustix::process::pidfd_send_signal(&pidfd, Signal::STOP)?;
|
||||
loop {
|
||||
let status = std::fs::read_to_string(format!("/proc/{init_pid}/status"))?;
|
||||
if status_value(&status, "PPid:")? != prepared.pid()
|
||||
|| !status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("NSpid:"))
|
||||
.is_some_and(|v| v.split_whitespace().last() == Some("1"))
|
||||
{
|
||||
return Err(denied("namespace init identity mismatch"));
|
||||
}
|
||||
if status.lines().any(|line| line == "State:\tT (stopped)") {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"namespace init stop deadline",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
let ids = NamespaceIds::observe(init_pid)?;
|
||||
ids.verify_separate(&parent_ids)?;
|
||||
prepared
|
||||
.cgroup
|
||||
.finish_namespace_initialization(&[prepared.pid(), init_pid])?;
|
||||
Ok::<_, io::Error>((init_pid, ids))
|
||||
})();
|
||||
match initialize {
|
||||
Ok((init_pid, ids)) => Ok(Self {
|
||||
prepared,
|
||||
init_pid,
|
||||
ids,
|
||||
}),
|
||||
Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_pid(&self) -> u32 {
|
||||
self.init_pid
|
||||
}
|
||||
pub fn bootstrap_pid(&self) -> u32 {
|
||||
self.prepared.pid()
|
||||
}
|
||||
pub fn namespace_ids(&self) -> &NamespaceIds {
|
||||
&self.ids
|
||||
}
|
||||
pub fn cgroup(&self) -> &crate::cgroup::TaskCgroup {
|
||||
self.prepared.cgroup()
|
||||
}
|
||||
|
||||
/// The next missing stage remains a hard refusal with actual group cleanup.
|
||||
pub fn refuse_unimplemented_root(mut self) -> InitializationFailure {
|
||||
self.prepared
|
||||
.refuse_at(ExecutionStage::RootFilesystem, None)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,23 @@ pub enum ChildMessage {
|
||||
Cancelled {},
|
||||
}
|
||||
|
||||
/// A distinct fixed initialization protocol. There is no argv, host path,
|
||||
/// readiness claim or exec/release command in this protocol.
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum NamespaceCommand {
|
||||
Initialize {},
|
||||
Cancel {},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum NamespaceEvent {
|
||||
Initialized { init_pid: u32 },
|
||||
Failed { errno: Option<u16> },
|
||||
Cancelled {},
|
||||
}
|
||||
|
||||
mod sealed {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
@@ -44,6 +61,21 @@ pub trait WireMessage: sealed::Sealed + Serialize + DeserializeOwned {
|
||||
}
|
||||
|
||||
impl sealed::Sealed for ParentMessage {}
|
||||
impl sealed::Sealed for NamespaceCommand {}
|
||||
impl WireMessage for NamespaceCommand {
|
||||
fn valid(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl sealed::Sealed for NamespaceEvent {}
|
||||
impl WireMessage for NamespaceEvent {
|
||||
fn valid(&self) -> bool {
|
||||
match self {
|
||||
Self::Initialized { init_pid } => *init_pid > 1,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl WireMessage for ParentMessage {
|
||||
fn valid(&self) -> bool {
|
||||
match self {
|
||||
@@ -88,13 +120,10 @@ pub struct ControlChannel {
|
||||
}
|
||||
|
||||
impl ControlChannel {
|
||||
pub(crate) fn duplicate_stream(&self) -> io::Result<rustix::fd::OwnedFd> {
|
||||
Ok(rustix::io::fcntl_dupfd_cloexec(&self.stream, 3)?)
|
||||
}
|
||||
pub fn new(stream: UnixStream, peer: ExpectedPeer, deadline: Instant) -> io::Result<Self> {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|duration| !duration.is_zero() && *duration <= MAX_CONTROL_TIMEOUT)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "invalid control deadline")
|
||||
})?;
|
||||
let credentials = rustix::net::sockopt::socket_peercred(&stream)?;
|
||||
if credentials.pid.as_raw_pid() as u32 != peer.pid
|
||||
|| credentials.uid.as_raw() != peer.uid
|
||||
@@ -105,6 +134,23 @@ impl ControlChannel {
|
||||
"unexpected control peer",
|
||||
));
|
||||
}
|
||||
Self::inherited_from_authenticated_bootstrap(stream, deadline)
|
||||
}
|
||||
|
||||
/// Only the fresh namespace-init role uses this. Its endpoint was checked
|
||||
/// before unshare and exclusively inherited through the trusted Command.
|
||||
/// Do not call socket_peercred in PID 1: a peer outside its PID namespace
|
||||
/// has PID 0, which rustix UCred's nonzero Pid cannot represent.
|
||||
pub(crate) fn inherited_from_authenticated_bootstrap(
|
||||
stream: UnixStream,
|
||||
deadline: Instant,
|
||||
) -> io::Result<Self> {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|duration| !duration.is_zero() && *duration <= MAX_CONTROL_TIMEOUT)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "invalid control deadline")
|
||||
})?;
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(remaining))?;
|
||||
stream.set_write_timeout(Some(remaining))?;
|
||||
@@ -290,6 +336,19 @@ mod tests {
|
||||
);
|
||||
assert!(serde_json::from_str::<ChildMessage>(r#"{"command":"cancel"}"#).is_err());
|
||||
assert!(serde_json::from_str::<ParentMessage>(r#"{"command":"release_exec"}"#).is_err());
|
||||
for raw in [
|
||||
r#"{"command":"initialize","argv":["/bin/sh"]}"#,
|
||||
r#"{"command":"release_exec"}"#,
|
||||
r#"{"command":"cancel","path":"/host"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<NamespaceCommand>(raw).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_str::<NamespaceEvent>(
|
||||
r#"{"event":"initialized","init_pid":2,"exec_ready":true}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,3 +11,6 @@ pub mod spec;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod cgroup;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod isolation;
|
||||
|
||||
@@ -41,6 +41,15 @@ enum Command {
|
||||
Bootstrap {
|
||||
#[arg(long)]
|
||||
parent_pid: u32,
|
||||
#[arg(long)]
|
||||
initialize_namespaces: bool,
|
||||
},
|
||||
/// Internal fresh PID-1 helper; refuses arbitrary commands and user exec.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[command(hide = true)]
|
||||
NamespaceInit {
|
||||
#[arg(long)]
|
||||
parent_pid: u32,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -77,8 +86,16 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Command::Bootstrap { parent_pid } => {
|
||||
match kf_sandbox::launcher::blocked::bootstrap(parent_pid) {
|
||||
Command::Bootstrap {
|
||||
parent_pid,
|
||||
initialize_namespaces,
|
||||
} => {
|
||||
let result = if initialize_namespaces {
|
||||
kf_sandbox::launcher::namespaces::bootstrap(parent_pid)
|
||||
} else {
|
||||
kf_sandbox::launcher::blocked::bootstrap(parent_pid)
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::from(2),
|
||||
Err(error) => spec_error(
|
||||
"bootstrap_refused",
|
||||
@@ -86,6 +103,16 @@ fn main() -> ExitCode {
|
||||
),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Command::NamespaceInit { parent_pid } => {
|
||||
match kf_sandbox::launcher::namespaces::namespace_init(parent_pid) {
|
||||
Ok(()) => ExitCode::from(2),
|
||||
Err(error) => spec_error(
|
||||
"namespace_init_refused",
|
||||
Some(serde_json::json!({"io_kind":format!("{:?}",error.kind())})),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,116 +6,13 @@
|
||||
use kf_sandbox::cgroup::DelegatedCgroup;
|
||||
use kf_sandbox::launcher::blocked::{BlockedTarget, PreparedTarget};
|
||||
use kf_sandbox::outcome::{ErrorCode, ExecutionStage};
|
||||
use kf_sandbox::runtime::{RuntimeConfig, ValidatedRuntimeConfig};
|
||||
use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static ATTEMPT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn require_vm() {
|
||||
assert_eq!(
|
||||
std::env::var("KF_SANDBOX_INDEPENDENT_VM").as_deref(),
|
||||
Ok("1")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string("/kf/INDEPENDENT_VM").unwrap().trim(),
|
||||
"isolated-test-guest"
|
||||
);
|
||||
assert!(
|
||||
fs::read_to_string("/sys/class/dmi/id/sys_vendor")
|
||||
.unwrap()
|
||||
.contains("QEMU")
|
||||
);
|
||||
for path in ["/mnt/hgfs", "/home/kronecker", "/root/.ssh"] {
|
||||
assert!(!Path::new(path).exists());
|
||||
}
|
||||
let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap();
|
||||
for forbidden in ["hgfs", "9p", "virtiofs"] {
|
||||
assert!(!mounts.contains(forbidden));
|
||||
}
|
||||
let net: Vec<_> = fs::read_dir("/sys/class/net")
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
assert_eq!(net, ["lo"]);
|
||||
}
|
||||
|
||||
struct Root {
|
||||
path: PathBuf,
|
||||
config: RuntimeConfig,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
fn new() -> Self {
|
||||
require_vm();
|
||||
let path = PathBuf::from(format!(
|
||||
"/sys/fs/cgroup/kf-vm-{}-{}",
|
||||
std::process::id(),
|
||||
ATTEMPT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut config: RuntimeConfig = serde_json::from_str(include_str!(
|
||||
"../../../../deploy/sandbox.runtime.example.json"
|
||||
))
|
||||
.unwrap();
|
||||
config.delegated_cgroup_root = path.to_str().unwrap().into();
|
||||
config.aggregate.task_cpu_quota_us = 10_000;
|
||||
Self { path, config }
|
||||
}
|
||||
fn validated(&self) -> ValidatedRuntimeConfig {
|
||||
self.config.clone().validate().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Root {
|
||||
fn drop(&mut self) {
|
||||
if self.path.exists() {
|
||||
let _ = fs::write(self.path.join("cgroup.kill"), "1");
|
||||
if let Ok(entries) = fs::read_dir(&self.path) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
let _ = fs::remove_dir(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(id: &str, memory: u64, processes: u32) -> ValidatedSpec {
|
||||
let mut spec: ExecutionSpec =
|
||||
serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap();
|
||||
spec.run_id = id.into();
|
||||
spec.limits.memory_bytes = memory;
|
||||
spec.limits.processes = processes;
|
||||
spec.validate(&SpecPolicy::default()).unwrap()
|
||||
}
|
||||
|
||||
fn binary() -> PathBuf {
|
||||
std::env::var_os("KF_SANDBOX_TEST_BIN")
|
||||
.expect("VM sandbox path")
|
||||
.into()
|
||||
}
|
||||
fn children() -> Vec<u32> {
|
||||
let mut children = Vec::new();
|
||||
for task in fs::read_dir("/proc/self/task").unwrap() {
|
||||
children.extend(
|
||||
fs::read_to_string(task.unwrap().path().join("children"))
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.map(|pid| pid.parse::<u32>().unwrap()),
|
||||
);
|
||||
}
|
||||
children.sort_unstable();
|
||||
children
|
||||
}
|
||||
mod support;
|
||||
use support::{Root, binary, children, spec};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM with externally verified whole-VM caps"]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* Fixed probe, compiled/executed only by the independently capped VM harness. */
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <net/if.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ipc.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/msg.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int enter(const char *pid, const char *name, int flag) {
|
||||
char path[128];
|
||||
if (snprintf(path,sizeof path,"/proc/%s/ns/%s",pid,name)>=(int)sizeof path) return -1;
|
||||
int fd=open(path,O_RDONLY|O_CLOEXEC);
|
||||
if(fd<0) return -1;
|
||||
int result=setns(fd,flag); close(fd); return result;
|
||||
}
|
||||
static int probe(const char *pid, int queue) {
|
||||
char hostname[64]={0};
|
||||
if(enter(pid,"uts",CLONE_NEWUTS)||gethostname(hostname,sizeof hostname)||strcmp(hostname,"kf-sandbox"))return 10;
|
||||
if(enter(pid,"ipc",CLONE_NEWIPC))return 11;
|
||||
struct msqid_ds ds;
|
||||
if(msgctl(queue,IPC_STAT,&ds)!=-1 || (errno!=EINVAL&&errno!=EIDRM))return 12;
|
||||
if(enter(pid,"net",CLONE_NEWNET))return 13;
|
||||
struct if_nameindex *names=if_nameindex();
|
||||
if(!names || strcmp(names[0].if_name,"lo") || names[1].if_index)return 14;
|
||||
if_freenameindex(names);
|
||||
int sock=socket(AF_INET,SOCK_DGRAM|SOCK_CLOEXEC,0);
|
||||
struct ifreq iface={0};strcpy(iface.ifr_name,"lo");
|
||||
if(sock<0||ioctl(sock,SIOCGIFFLAGS,&iface)||iface.ifr_flags&IFF_UP)return 15;
|
||||
close(sock);
|
||||
if(enter(pid,"mnt",CLONE_NEWNS)||mount("tmpfs","/kf/ns-mount","tmpfs",MS_NOSUID|MS_NODEV|MS_NOEXEC,"size=1m,mode=0700"))return 16;
|
||||
int file=open("/kf/ns-mount/sentinel",O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC,0600);
|
||||
if(file<0||write(file,"isolated",8)!=8)return 17;
|
||||
close(file);
|
||||
if(enter(pid,"pid",CLONE_NEWPID))return 18;
|
||||
pid_t child=fork();
|
||||
if(child==0)_exit(getpid()>1&&getppid()==0&&kill(1,0)==0?0:19);
|
||||
int status;
|
||||
if(child<0||waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status))return 20;
|
||||
return 0;
|
||||
}
|
||||
int main(int argc,char **argv){
|
||||
alarm(4);
|
||||
if(argc!=2)return 2;
|
||||
for(char *p=argv[1];*p;p++)if(*p<'0'||*p>'9')return 2;
|
||||
int queue=msgget(IPC_PRIVATE,IPC_CREAT|0600);if(queue<0)return 3;
|
||||
pid_t child=fork();if(child==0)_exit(probe(argv[1],queue));
|
||||
int status=0;int ok=child>0&&waitpid(child,&status,0)==child&&WIFEXITED(status)&&WEXITSTATUS(status)==0;
|
||||
if(msgctl(queue,IPC_RMID,NULL))return 4;
|
||||
if(!ok){fprintf(stderr,"namespace probe status=%d\n",status);return 5;}
|
||||
puts("KF_NAMESPACE_PROBE uts=1 ipc=1 net_lo_down=1 mount_private=1 pid_child=1");return 0;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
//! Privileged acceptance runs exclusively under the independent VM harness.
|
||||
mod support;
|
||||
|
||||
use kf_sandbox::launcher::namespaces::{NamespaceIds, NamespacedTarget};
|
||||
use kf_sandbox::outcome::ExecutionStage;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
use support::{Root, binary, children, spec};
|
||||
|
||||
fn gone(pid: u32) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Path::new(&format!("/proc/{pid}")).exists() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"VM init has not reaped orphaned helper"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM with externally verified whole-VM caps"]
|
||||
fn actual_namespaces_and_pid_one_are_distinct_and_remain_gated() {
|
||||
let root = Root::new();
|
||||
let before = NamespaceIds::observe(std::process::id()).unwrap();
|
||||
let one = NamespacedTarget::prepare(&root.validated(), &spec("ns-one", 64 << 20, 8), &binary())
|
||||
.unwrap();
|
||||
let two = NamespacedTarget::prepare(&root.validated(), &spec("ns-two", 64 << 20, 8), &binary())
|
||||
.unwrap();
|
||||
let a = one.namespace_ids();
|
||||
let b = two.namespace_ids();
|
||||
assert_ne!(a.mount, b.mount);
|
||||
assert_ne!(a.pid, b.pid);
|
||||
assert_ne!(a.ipc, b.ipc);
|
||||
assert_ne!(a.uts, b.uts);
|
||||
assert_ne!(a.network, b.network);
|
||||
assert_ne!(a.cgroup, b.cgroup);
|
||||
assert_eq!(a.user, before.user);
|
||||
assert_eq!(b.user, before.user);
|
||||
let output = Command::new("/kf/bin/namespace-probe")
|
||||
.arg(one.init_pid().to_string())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"fixed probe: {:?} {:?}",
|
||||
output.stdout,
|
||||
output.stderr
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(output.stdout).unwrap().trim(),
|
||||
"KF_NAMESPACE_PROBE uts=1 ipc=1 net_lo_down=1 mount_private=1 pid_child=1"
|
||||
);
|
||||
assert!(!Path::new("/kf/ns-mount/sentinel").exists());
|
||||
assert!(
|
||||
Path::new(&format!(
|
||||
"/proc/{}/root/kf/ns-mount/sentinel",
|
||||
one.init_pid()
|
||||
))
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!Path::new(&format!(
|
||||
"/proc/{}/root/kf/ns-mount/sentinel",
|
||||
two.init_pid()
|
||||
))
|
||||
.exists()
|
||||
);
|
||||
for target in [&one, &two] {
|
||||
let path = root.path.join(target.cgroup().name());
|
||||
assert_eq!(
|
||||
fs::read_to_string(path.join("cgroup.freeze"))
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"1"
|
||||
);
|
||||
let status = fs::read_to_string(format!("/proc/{}/status", target.init_pid())).unwrap();
|
||||
assert!(status.contains("State:\tT (stopped)"));
|
||||
assert!(
|
||||
status
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("NSpid:"))
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.last()
|
||||
== Some("1")
|
||||
);
|
||||
let mounts = fs::read_to_string(format!("/proc/{}/mountinfo", target.init_pid())).unwrap();
|
||||
assert!(!mounts.contains(" shared:") && !mounts.contains(" master:"));
|
||||
let limits = target.cgroup().applied_limits().unwrap();
|
||||
assert_eq!(limits.memory_bytes, 64 << 20);
|
||||
assert_eq!(limits.processes, 8);
|
||||
}
|
||||
assert_eq!(NamespaceIds::observe(std::process::id()).unwrap(), before);
|
||||
let ids = (one.init_pid(), two.init_pid());
|
||||
for target in [one, two] {
|
||||
let bootstrap = target.bootstrap_pid();
|
||||
let path = root.path.join(target.cgroup().name());
|
||||
let failure = target.refuse_unimplemented_root();
|
||||
assert_eq!(failure.failure.stage, ExecutionStage::RootFilesystem);
|
||||
assert!(failure.cleanup_failure.is_none());
|
||||
assert!(!path.exists());
|
||||
gone(bootstrap);
|
||||
}
|
||||
gone(ids.0);
|
||||
gone(ids.1);
|
||||
println!(
|
||||
"KF_NAMESPACE_PROOF init_pids={ids:?} six_namespaces_distinct=1 user_namespace_unchanged=1 frozen=1 root_refusal=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM with externally verified whole-VM caps"]
|
||||
fn pid_limit_initialization_failure_reports_namespace_stage_and_rolls_back() {
|
||||
let root = Root::new();
|
||||
let before = children();
|
||||
let result =
|
||||
NamespacedTarget::prepare(&root.validated(), &spec("ns-fail", 64 << 20, 1), &binary());
|
||||
let Err(failure) = result else {
|
||||
panic!("PID 1 must not bypass pids.max");
|
||||
};
|
||||
assert_eq!(failure.failure.stage, ExecutionStage::Namespaces);
|
||||
assert_eq!(failure.failure.errno, Some(11));
|
||||
assert!(failure.cleanup_failure.is_none());
|
||||
assert_eq!(children(), before);
|
||||
assert!(!root.path.join("kf-ns-fail").exists());
|
||||
println!("KF_NAMESPACE_FAILURE_PROOF errno=11 stage=namespaces reaped=1 removed=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM with externally verified whole-VM caps"]
|
||||
fn dropping_frozen_namespace_target_kills_helpers_and_removes_task() {
|
||||
let root = Root::new();
|
||||
let target =
|
||||
NamespacedTarget::prepare(&root.validated(), &spec("ns-drop", 64 << 20, 8), &binary())
|
||||
.unwrap();
|
||||
let ids = (target.bootstrap_pid(), target.init_pid());
|
||||
let path = root.path.join(target.cgroup().name());
|
||||
drop(target);
|
||||
gone(ids.0);
|
||||
gone(ids.1);
|
||||
assert!(!path.exists());
|
||||
println!("KF_NAMESPACE_DROP_PROOF helpers_gone=1 task_removed=1");
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use kf_sandbox::runtime::{RuntimeConfig, ValidatedRuntimeConfig};
|
||||
use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static ATTEMPT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn require_vm() {
|
||||
assert_eq!(
|
||||
std::env::var("KF_SANDBOX_INDEPENDENT_VM").as_deref(),
|
||||
Ok("1")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string("/kf/INDEPENDENT_VM").unwrap().trim(),
|
||||
"isolated-test-guest"
|
||||
);
|
||||
assert!(
|
||||
fs::read_to_string("/sys/class/dmi/id/sys_vendor")
|
||||
.unwrap()
|
||||
.contains("QEMU")
|
||||
);
|
||||
for path in ["/mnt/hgfs", "/home/kronecker", "/root/.ssh"] {
|
||||
assert!(!Path::new(path).exists());
|
||||
}
|
||||
let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap();
|
||||
for forbidden in ["hgfs", "9p", "virtiofs"] {
|
||||
assert!(!mounts.contains(forbidden));
|
||||
}
|
||||
let net: Vec<_> = fs::read_dir("/sys/class/net")
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
assert_eq!(net, ["lo"]);
|
||||
}
|
||||
|
||||
pub struct Root {
|
||||
pub path: PathBuf,
|
||||
config: RuntimeConfig,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
pub fn new() -> Self {
|
||||
require_vm();
|
||||
let path = PathBuf::from(format!(
|
||||
"/sys/fs/cgroup/kf-vm-{}-{}",
|
||||
std::process::id(),
|
||||
ATTEMPT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut config: RuntimeConfig = serde_json::from_str(include_str!(
|
||||
"../../../../../deploy/sandbox.runtime.example.json"
|
||||
))
|
||||
.unwrap();
|
||||
config.delegated_cgroup_root = path.to_str().unwrap().into();
|
||||
config.aggregate.task_cpu_quota_us = 10_000;
|
||||
Self { path, config }
|
||||
}
|
||||
pub fn validated(&self) -> ValidatedRuntimeConfig {
|
||||
self.config.clone().validate().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Root {
|
||||
fn drop(&mut self) {
|
||||
if self.path.exists() {
|
||||
let _ = fs::write(self.path.join("cgroup.kill"), "1");
|
||||
if let Ok(entries) = fs::read_dir(&self.path) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
let _ = fs::remove_dir(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spec(id: &str, memory: u64, processes: u32) -> ValidatedSpec {
|
||||
let mut spec: ExecutionSpec =
|
||||
serde_json::from_str(include_str!("../fixtures/spec.valid.json")).unwrap();
|
||||
spec.run_id = id.into();
|
||||
spec.limits.memory_bytes = memory;
|
||||
spec.limits.processes = processes;
|
||||
spec.validate(&SpecPolicy::default()).unwrap()
|
||||
}
|
||||
|
||||
pub fn binary() -> PathBuf {
|
||||
std::env::var_os("KF_SANDBOX_TEST_BIN")
|
||||
.expect("VM sandbox path")
|
||||
.into()
|
||||
}
|
||||
pub fn children() -> Vec<u32> {
|
||||
let mut children = Vec::new();
|
||||
for task in fs::read_dir("/proc/self/task").unwrap() {
|
||||
children.extend(
|
||||
fs::read_to_string(task.unwrap().path().join("children"))
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.map(|pid| pid.parse::<u32>().unwrap()),
|
||||
);
|
||||
}
|
||||
children.sort_unstable();
|
||||
children
|
||||
}
|
||||
Reference in New Issue
Block a user