crates/heimdall-container/src/brokered.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The syscall broker (layer 2, spec §4) for one protected job: which mode
//! this kernel allows (spec §10), the job's [`BrokerPolicy`], and the
//! decisions as signed [`BrokerEvent`]s.
//!
//! Heimdall is a defensive sandbox. The job's executor installs the broker
//! filter inside the container, right after Landlock and before `execve`
//! (and before a generated profile), and hands its USER_NOTIF listener to
//! the shim, which serves it with [`heimdall_broker::Broker`]. The broker
//! opens files ITSELF, outside Landlock, so its policy is never wider than
//! the job's Landlock plan ([`crate::confine`]):
//!
//! | where (as the job sees it) | broker access | resolve |
//! |---|---|---|
//! | `/` (the image; not `/` itself) | read | in-root, no mount crossing |
//! | `/in`, `policy.read` | read | beneath |
//! | `/out` | write only (no read-back, no listing) | no symlinks |
//! | `/tmp`, `/work` (the job workspace), `policy.write` | read and write | beneath |
//! | `/proc` | read | the caller's own `self` |
//! | `/dev/null` | read and write | exact path, character device |
//! | `/dev/{zero,full,random,urandom}` | read | exact path, character device |
//! | sockets | none; a fetch job (`policy.net`): TCP to its declared IP:port peers, created and connected by the broker in the runner's network namespace | |
//! | exec | allowed and logged (Landlock: only the image executes) | |
use std::net::SocketAddr;
use std::path::PathBuf;
use heimdall_broker::{Access, BrokerPolicy, BrokerReport, ExecPolicy, Mode, Resolve};
use heimdall_core::{BrokerEvent, Decision, JobId, NetAllow};
use serde::{Deserialize, Serialize};
use crate::kernel::KernelReport;
/// Whether jobs get the broker.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BrokerSetting {
/// The broker in the best mode this kernel allows (spec §10): full with
/// USER_NOTIF + ADDFD, check-only without ADDFD, deny-only without
/// USER_NOTIF.
Auto,
/// No broker filter at all (tests and diagnosis: proves what Landlock
/// alone refuses). `Features.broker` is false.
Off,
}
/// What the runner asks the shim for.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrokerRequest {
pub mode: Mode,
pub policy: BrokerPolicy,
/// Stream the job's network events ([`heimdall_core::NetEvent`]) as JSON
/// lines on the shim's stdout, before its response (the runner has an
/// observer, 0.4).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub observe: bool,
}
/// The mode a runner with this setting gives jobs on this kernel.
pub fn mode_for(setting: BrokerSetting, k: &KernelReport) -> Option<Mode> {
match setting {
BrokerSetting::Off => None,
BrokerSetting::Auto if !k.user_notif => Some(Mode::DenyOnly),
BrokerSetting::Auto if !k.notif_addfd => Some(Mode::CheckOnly),
BrokerSetting::Auto => Some(Mode::Full),
}
}
/// The devices a job may open, as in the Landlock plan.
pub const DEV_RW: &[&str] = &["/dev/null"];
pub const DEV_READ: &[&str] = &["/dev/zero", "/dev/full", "/dev/random", "/dev/urandom"];
/// Events kept per job (the rest are counted): they travel in the shim's
/// response and the signed result.
pub const MAX_EVENTS: usize = 4096;
/// The environment variable that hands a fetch job its declared peers.
pub const PEERS_ENV: &str = "HEIMDALL_NET_PEERS";
/// `HEIMDALL_NET_PEERS=<the declared peers as a JSON array of {host, addr,
/// port}>`: the job sends SNI and checks certificates against `host`, and
/// connects to `addr:port` (the only addresses the broker connects).
pub fn peers_env(n: &NetAllow) -> String {
format!(
"{PEERS_ENV}={}",
serde_json::to_string(&n.peers).expect("peers serialize")
)
}
/// The broker policy of one job (see the module docs). `fetch`: a fetch
/// job's declared TCP peers (from [`NetAllow::check`]). The job's network
/// namespace stays empty: the broker creates the socket in ITS network
/// namespace (the runner's), connects it to one of exactly these
/// addresses and injects it; any other socket, connect, bind or listen is
/// refused. Landlock (ABI 4) keeps refusing every TCP connect and
/// bind the job makes itself.
pub fn policy(
job_id: JobId,
read: &[PathBuf],
write: &[PathBuf],
fetch: &[SocketAddr],
) -> BrokerPolicy {
let mut p = BrokerPolicy::empty(job_id)
.with_exec(ExecPolicy::Allow)
.with_prefix("/", Access::Read, Resolve::InRoot)
.with_prefix("/in", Access::Read, Resolve::Beneath)
.with_prefix("/out", Access::WriteOnly, Resolve::NoSymlinks)
.with_prefix("/tmp", Access::ReadWrite, Resolve::Beneath)
// The job workspace (its own mount, when the job has one): like /tmp;
// whether it executes is the mount's and Landlock's decision.
.with_prefix(
heimdall_core::WORKSPACE_PATH,
Access::ReadWrite,
Resolve::Beneath,
)
.with_prefix("/proc", Access::Read, Resolve::Proc);
for d in DEV_RW {
p = p.with_prefix(*d, Access::ReadWrite, Resolve::Device);
}
for d in DEV_READ {
p = p.with_prefix(*d, Access::Read, Resolve::Device);
}
for r in read {
p = p.with_prefix(r, Access::Read, Resolve::Beneath);
}
for w in write {
p = p.with_prefix(w, Access::ReadWrite, Resolve::Beneath);
}
p.max_events = MAX_EVENTS;
p.fetch = fetch.to_vec();
p
}
/// The broker policy of `job`: [`policy`] with its declared peers named
/// (host, rule) and its `job_ref` for the network events.
pub fn job_policy(job: &heimdall_core::Job) -> BrokerPolicy {
let p = &job.policy;
let mut bp = policy(job.id, &p.read, &p.write, &[]);
if let Some(n) = &p.net {
if let Ok(addrs) = n.check() {
for (i, (peer, addr)) in n.peers.iter().zip(addrs).enumerate() {
bp = bp.with_peer(addr, &peer.host, &peer.rule_name(i));
}
}
}
bp.event_job_id = Some(p.event_job_id(&job.id));
bp
}
/// The name of a mode in events.
pub fn mode_name(m: Mode) -> &'static str {
match m {
Mode::Full => "full",
Mode::CheckOnly => "check-only",
Mode::DenyOnly => "deny-only",
}
}
/// The job's signed events: first a `broker` event naming the mode (and
/// every warning), then each decision, then a count of events not kept.
pub fn events(mode: Mode, report: Option<&BrokerReport>) -> Vec<BrokerEvent> {
let mut args = vec![format!("mode={}", mode_name(mode))];
match mode {
Mode::Full => {}
Mode::CheckOnly => args.push(
"warning: no SECCOMP_IOCTL_NOTIF_ADDFD: calls are checked and then proceed \
(allow/deny only, not TOCTOU-safe)"
.into(),
),
Mode::DenyOnly => args.push(
"warning: no SECCOMP_RET_USER_NOTIF: every brokered call is refused (fail closed)"
.into(),
),
}
if let Some(r) = report {
args.extend(r.warnings.iter().map(|w| format!("warning: {w}")));
}
let mut v = vec![BrokerEvent {
at_ms: 0,
call: "broker".into(),
args,
decision: Decision::Allowed,
}];
if let Some(r) = report {
v.extend(r.events.iter().cloned());
if r.dropped_events > 0 {
v.push(BrokerEvent {
at_ms: r.events.last().map_or(0, |e| e.at_ms),
call: "broker".into(),
args: vec![format!("dropped={}", r.dropped_events)],
decision: Decision::Allowed,
});
}
}
v
}