crates/heimdall-container/src/kernel.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The kernel features a runner actually gets (spec §10), probed at start.
//!
//! Heimdall is a defensive sandbox. | feature | kernel | if missing |:
//! seccomp-bpf (3.5) refuses to start; USER_NOTIF (5.0) and NOTIF_ADDFD
//! (5.9) are the broker's (its mode follows them, [`crate::brokered::mode_for`]),
//! and both come from the broker's own probe ([`heimdall_broker::Support`]),
//! which asks the kernel: there is ONE kernel probe for them; Landlock ABI 1 / 4
//! (5.13 / 6.7) logs and continues without it, or refuses in strict mode;
//! cgroup v2 with the memory, pids and cpu controllers refuses in strict
//! mode (io is applied when delegated and reported either way); user
//! namespaces refuse in strict mode.
use std::path::Path;
use serde::{Deserialize, Serialize};
/// The controllers strict mode requires for the jobs' cgroups.
pub const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
/// What the kernel gives this runner.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelReport {
/// `uname -r`.
pub release: String,
/// seccomp filter mode with `SECCOMP_RET_KILL_PROCESS` (3.5 / 4.14).
pub seccomp_filter: bool,
/// `SECCOMP_RET_USER_NOTIF` (5.0), seen working by the broker's probe.
pub user_notif: bool,
/// `SECCOMP_IOCTL_NOTIF_ADDFD` (5.9), seen working by the broker's probe.
pub notif_addfd: bool,
/// The kernel's Landlock ABI, `None` when Landlock is absent or disabled.
pub landlock_abi: Option<u32>,
/// cgroup v2 at the configured cgroup root.
pub cgroup_v2: bool,
/// Controllers the jobs' cgroups can get.
pub cgroup_controllers: Vec<String>,
/// Unprivileged user namespaces are possible.
pub user_namespaces: bool,
}
/// `heimdall kernel needs --json` (KTOOL fix 7) is `nornir.kernel-runtime-needs/1`,
/// whose ONE definition and validator live in the shared MIT crate
/// `nordisk-kernel-evidence` (kernel tooling fix 20): the kconfig lines a runner
/// needs, each with its reason. Build Thing reads the same type. Heimdall's
/// names for it stay.
pub use nordisk_kernel_evidence::runtime_needs::{
NeedOp, NeededBy, RuntimeNeed as KconfigNeed, RuntimeNeeds as KernelNeeds,
RUNTIME_NEEDS_SCHEMA as KERNEL_NEEDS_SCHEMA,
};
/// **The kconfig lines a strict runner needs** (with the broker on, the
/// default), each with its reason.
///
/// Each line serves one check of [`KernelReport::missing_for_strict`]
/// (`needed_by = strict`), the namespaces and mounts every job's container
/// asks for (`job`, heimdall-plain `spec.rs`), or the broker's full mode
/// (`broker`). A test holds every strict check to its lines.
pub fn strict_kconfig_needs() -> Vec<KconfigNeed> {
use NeedOp::{ListContains, Set};
use NeededBy::{Broker, Job, Strict};
let n = |symbol: &str, value: &str, op, needed_by, feature: &str, reason: &str| KconfigNeed {
symbol: symbol.into(),
value: value.into(),
op,
needed_by,
feature: feature.into(),
reason: reason.into(),
};
vec![
n("CONFIG_SECCOMP", "y", Set, Strict, "seccomp_filter",
"Heimdall strict: every job runs under a seccomp filter (SECCOMP_RET_KILL_PROCESS); without it the runner refuses to start"),
n("CONFIG_SECCOMP_FILTER", "y", Set, Strict, "seccomp_filter",
"Heimdall strict: the job's profile is a BPF filter (SECCOMP_SET_MODE_FILTER), and the broker's USER_NOTIF listener hangs off it"),
n("CONFIG_MULTIUSER", "y", Set, Strict, "landlock",
"Heimdall strict: SECURITY and NAMESPACES depend on MULTIUSER (security/Kconfig, init/Kconfig)"),
n("CONFIG_SECURITY", "y", Set, Strict, "landlock",
"Heimdall strict: the LSM menu Landlock hangs off (security/Kconfig)"),
n("CONFIG_SECURITY_LANDLOCK", "y", Set, Strict, "landlock",
"Heimdall strict: every job is restricted with Landlock (filesystem and network rules no root inside the job can widen); without it the runner refuses to start"),
n("CONFIG_LSM", "landlock", ListContains, Strict, "landlock",
"Heimdall strict: Landlock must be in the LSM boot order by name (`landlock` in CONFIG_LSM or lsm=), or the kernel has it built and never starts it"),
n("CONFIG_CGROUPS", "y", Set, Strict, "cgroup_v2",
"Heimdall strict: every job's limits are a cgroup v2 cgroup under the runner's parent"),
n("CONFIG_MEMCG", "y", Set, Strict, "cgroup.memory",
"Heimdall strict: memory.max and memory.swap.max, the job's memory limit"),
n("CONFIG_CGROUP_PIDS", "y", Set, Strict, "cgroup.pids",
"Heimdall strict: pids.max, the job's process limit (a fork bomb ends there)"),
n("CONFIG_CGROUP_SCHED", "y", Set, Strict, "cgroup.cpu",
"Heimdall strict: the cpu controller cpu.max hangs off"),
n("CONFIG_FAIR_GROUP_SCHED", "y", Set, Strict, "cgroup.cpu",
"Heimdall strict: the CFS group the job's cpu quota is accounted on"),
n("CONFIG_CFS_BANDWIDTH", "y", Set, Strict, "cgroup.cpu",
"Heimdall strict: cpu.max, the job's cpu quota"),
n("CONFIG_NAMESPACES", "y", Set, Strict, "user_namespaces",
"Heimdall strict: every job is a container in its own namespaces"),
n("CONFIG_USER_NS", "y", Set, Strict, "user_namespaces",
"Heimdall strict: the job's root maps to one unprivileged host id; without user namespaces the runner refuses to start"),
n("CONFIG_PID_NS", "y", Set, Job, "job.namespaces",
"every job's container has its own pid namespace (heimdall-plain spec.rs): it sees only itself, a kill reaches all of it"),
n("CONFIG_SYSVIPC", "y", Set, Job, "job.namespaces",
"IPC_NS depends on SYSVIPC or POSIX_MQUEUE (init/Kconfig)"),
n("CONFIG_IPC_NS", "y", Set, Job, "job.namespaces",
"every job's container has its own ipc namespace (heimdall-plain spec.rs)"),
n("CONFIG_UTS_NS", "y", Set, Job, "job.namespaces",
"every job's container has its own uts namespace (heimdall-plain spec.rs)"),
n("CONFIG_NET", "y", Set, Job, "job.namespaces",
"NET_NS depends on NET (net/Kconfig)"),
n("CONFIG_NET_NS", "y", Set, Job, "job.namespaces",
"every job's container has its own, empty network namespace (heimdall-plain spec.rs)"),
n("CONFIG_TMPFS", "y", Set, Job, "job.mounts",
"every job's /tmp is a capped tmpfs (heimdall-plain spec.rs)"),
n("CONFIG_CROSS_MEMORY_ATTACH", "y", Set, Broker, "broker.read_args",
"the broker reads a job's call arguments with process_vm_readv(2) (heimdall-broker sys.rs); tinyconfig leaves it off"),
]
}
/// The strict runner's document (`heimdall kernel needs --json`).
pub fn strict() -> KernelNeeds {
KernelNeeds {
schema: KERNEL_NEEDS_SCHEMA.into(),
producer: format!("heimdall {}", env!("CARGO_PKG_VERSION")),
mode: "strict".into(),
needs: strict_kconfig_needs(),
}
}
/// One missing feature, named for people.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Missing(pub String);
impl KernelReport {
/// Probe this kernel; `cgroup_root`/`cgroup_parent` as in the config.
pub fn probe(cgroup_root: &Path, cgroup_parent: &str) -> KernelReport {
Self::probe_with(
cgroup_root,
cgroup_parent,
heimdall_broker::Support::probe(),
)
}
/// [`probe`](Self::probe) with the broker's kernel probe given: the
/// seccomp notifier features come from it.
pub fn probe_with(
cgroup_root: &Path,
cgroup_parent: &str,
broker: heimdall_broker::Support,
) -> KernelReport {
let read = |p: &str| std::fs::read_to_string(p).unwrap_or_default();
let release = read("/proc/sys/kernel/osrelease").trim().to_string();
let (kill_process, _) = parse_actions(&read("/proc/sys/kernel/seccomp/actions_avail"));
// USER_NOTIF and ADDFD: the broker's probe, never the release string.
let user_notif = broker.user_notif;
let cgroup_v2 = cgroup_root.join("cgroup.controllers").is_file();
// The jobs' cgroups get what their parent's subtree enables, or (when
// the parent does not exist yet) what the root offers its children.
let parent = cgroup_root.join(cgroup_parent);
let controllers_file = if parent.join("cgroup.controllers").is_file() {
parent.join("cgroup.controllers")
} else {
cgroup_root.join("cgroup.subtree_control")
};
let mut cgroup_controllers: Vec<String> = std::fs::read_to_string(controllers_file)
.unwrap_or_default()
.split_whitespace()
.map(String::from)
.collect();
cgroup_controllers.sort();
let max_userns: u64 = read("/proc/sys/user/max_user_namespaces")
.trim()
.parse()
.unwrap_or(0);
// Debian's knob; absent elsewhere (then only the max counts).
let unpriv = read("/proc/sys/kernel/unprivileged_userns_clone");
let user_namespaces = max_userns > 0 && unpriv.trim() != "0";
KernelReport {
release,
seccomp_filter: kill_process,
user_notif,
notif_addfd: user_notif && broker.addfd,
landlock_abi: crate::sys::landlock_abi(),
cgroup_v2,
cgroup_controllers: if cgroup_v2 {
cgroup_controllers
} else {
vec![]
},
user_namespaces,
}
}
/// This report as heimdall-core's [`heimdall_core::KernelFeatures`], the
/// form every signed result names (spec §10).
pub fn features(&self) -> heimdall_core::KernelFeatures {
heimdall_core::KernelFeatures {
release: self.release.clone(),
seccomp_filter: self.seccomp_filter,
user_notif: self.user_notif,
notif_addfd: self.notif_addfd,
landlock_abi: self.landlock_abi,
cgroup_v2: self.cgroup_v2,
cgroup_controllers: self.cgroup_controllers.clone(),
user_namespaces: self.user_namespaces,
}
}
/// Landlock's TCP port rules (ABI 4) are there.
pub fn landlock_net(&self) -> bool {
self.landlock_abi.is_some_and(|a| a >= 4)
}
pub fn has_controller(&self, c: &str) -> bool {
self.cgroup_controllers.iter().any(|x| x == c)
}
/// What is missing that strict mode needs (seccomp included, which every
/// mode needs).
pub fn missing_for_strict(&self) -> Vec<Missing> {
let mut m = Vec::new();
if !self.seccomp_filter {
m.push(Missing(
"seccomp filters with SECCOMP_RET_KILL_PROCESS (kernel 4.14)".into(),
));
}
if self.landlock_abi.is_none() {
m.push(Missing(
"Landlock (kernel 5.13, CONFIG_SECURITY_LANDLOCK and `landlock` in the lsm= list)"
.into(),
));
}
if !self.cgroup_v2 {
m.push(Missing("cgroup v2 at the cgroup root".into()));
} else {
for c in REQUIRED_CONTROLLERS {
if !self.has_controller(c) {
m.push(Missing(format!(
"the cgroup v2 {c} controller delegated to the jobs' parent cgroup"
)));
}
}
}
if !self.user_namespaces {
m.push(Missing("unprivileged user namespaces".into()));
}
m
}
}
/// `major.minor` of a release string like `7.0.0-38-generic`.
pub fn parse_release(s: &str) -> Option<(u32, u32)> {
let mut it = s.trim().split(|c: char| !c.is_ascii_digit());
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
Some((major, minor))
}
/// `/proc/sys/kernel/seccomp/actions_avail` → (kill_process, user_notif).
pub fn parse_actions(s: &str) -> (bool, bool) {
let has = |a: &str| s.split_whitespace().any(|x| x == a);
(has("kill_process"), has("user_notif"))
}