crates/heimdall-broker/src/spawn.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Launching a worker under the broker: fork, install the broker filter in
//! the child (before exec), hand its listener to the broker thread, exec.
//!
//! Heimdall is a defensive sandbox. This is the minimal launcher (the kernel
//! tests use it, and so can a runner without a container); heimdall-container
//! installs the same filter inside its container and serves it with the same
//! [`Broker`].
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::sync::Arc;
use std::thread::JoinHandle;
use heimdall_seccomp::launch::{install, FILTER_FLAG_NEW_LISTENER};
use heimdall_seccomp::notif::Listener;
use crate::broker::{Broker, BrokerReport, DebugKnobs};
use crate::filter::{self, FilterMode};
use crate::policy::BrokerPolicy;
use crate::support::{Mode, Support};
use crate::sys;
use crate::BrokerError;
/// Options for [`spawn_brokered`].
#[derive(Clone, Debug, Default)]
pub struct SpawnOptions {
/// Run in this mode instead of the best this kernel allows (the
/// fallback tests force [`Mode::CheckOnly`]). Refused when the kernel
/// cannot do it.
pub force_mode: Option<Mode>,
pub knobs: DebugKnobs,
}
/// A worker running under the broker.
pub struct BrokeredChild {
pub child: Child,
pub mode: Mode,
stop: Arc<OwnedFd>,
broker: Option<JoinHandle<Result<BrokerReport, BrokerError>>>,
}
impl BrokeredChild {
pub fn pid(&self) -> u32 {
self.child.id()
}
/// Waits for the worker, stops the broker and returns its report.
pub fn wait(mut self) -> Result<(ExitStatus, BrokerReport), BrokerError> {
let status = self.child.wait().map_err(|e| BrokerError::Io {
what: "wait for the worker".into(),
source: e,
})?;
let report = self.stop_broker()?;
Ok((status, report))
}
/// Like [`wait`](Self::wait) but also returns the worker's stdout
/// (the command must have `Stdio::piped()` stdout).
pub fn wait_with_output(mut self) -> Result<(ExitStatus, Vec<u8>, BrokerReport), BrokerError> {
use std::io::Read;
let mut out = Vec::new();
if let Some(mut s) = self.child.stdout.take() {
let _ = s.read_to_end(&mut out);
}
let (st, rep) = self.wait()?;
Ok((st, out, rep))
}
fn stop_broker(&mut self) -> Result<BrokerReport, BrokerError> {
sys::eventfd_signal(self.stop.as_raw_fd());
match self.broker.take() {
Some(h) => h.join().map_err(|_| BrokerError::Panicked)?,
None => Err(BrokerError::Panicked),
}
}
}
impl Drop for BrokeredChild {
fn drop(&mut self) {
if self.broker.is_some() {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = self.stop_broker();
}
}
}
/// Spawns `cmd` with the broker filter installed in the child before its
/// exec, served by a broker thread in this process. The program path should
/// be absolute (one exec, no `PATH` search). Spec §10: without USER_NOTIF
/// this refuses ([`BrokerError::Unsupported`]); a runner installs
/// [`filter::program`] with [`FilterMode::Deny`] inside the worker instead.
pub fn spawn_brokered(
cmd: &mut Command,
policy: BrokerPolicy,
opts: SpawnOptions,
) -> Result<BrokeredChild, BrokerError> {
policy.validate().map_err(BrokerError::Policy)?;
let support = Support::probe();
let mode = opts.force_mode.unwrap_or_else(|| support.mode());
if mode == Mode::DenyOnly {
return Err(BrokerError::Unsupported(
"no USER_NOTIF: brokered calls are plain denies; the launcher cannot exec a worker under it".into(),
));
}
if !support.allows(mode) {
return Err(BrokerError::Unsupported(format!(
"mode {mode:?} on this kernel ({support:?})"
)));
}
let prog = Arc::new(
filter::program(FilterMode::Notify, policy.exec)
.map_err(|e| BrokerError::Policy(format!("the broker filter: {e}")))?,
);
let (parent_end, child_end) = sys::seqpacket_pair().map_err(|e| BrokerError::Io {
what: "socketpair".into(),
source: e,
})?;
let stop = Arc::new(sys::eventfd().map_err(|e| BrokerError::Io {
what: "eventfd".into(),
source: e,
})?);
let bstop = Arc::clone(&stop);
let knobs = opts.knobs.clone();
let broker = std::thread::Builder::new()
.name("heimdall-broker".into())
.spawn(move || -> Result<BrokerReport, BrokerError> {
serve_handoff(
parent_end.as_fd(),
bstop.as_raw_fd(),
None,
policy,
mode,
knobs,
)?
.ok_or_else(|| BrokerError::Unsupported("the worker never started".into()))
})
.map_err(|e| BrokerError::Io {
what: "spawn the broker thread".into(),
source: e,
})?;
let child_raw = child_end.as_raw_fd();
let hook_prog = Arc::clone(&prog);
// SAFETY: the hook makes system calls only (no allocation, no locks).
unsafe {
cmd.pre_exec(move || {
let fd = install(&hook_prog, FILTER_FLAG_NEW_LISTENER)?
.ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))?;
sys::send_fd_and_pid(child_raw, fd.as_raw_fd(), libc::getpid())
});
}
let spawned = cmd.spawn();
drop(child_end);
match spawned {
Ok(child) => Ok(BrokeredChild {
child,
mode,
stop,
broker: Some(broker),
}),
Err(e) => {
sys::eventfd_signal(stop.as_raw_fd());
let _ = broker.join();
Err(BrokerError::Io {
what: "spawn the worker".into(),
source: e,
})
}
}
}
/// A unix socket pair for handing a worker's listener to its broker
/// (`SOCK_SEQPACKET`, close-on-exec): `(broker end, worker end)`.
pub fn handoff_pair() -> std::io::Result<(OwnedFd, OwnedFd)> {
sys::seqpacket_pair()
}
/// A stop signal for [`serve_handoff`] / [`Broker::serve`]: readable once
/// [`StopSignal::signal`] was called.
#[derive(Debug)]
pub struct StopSignal(OwnedFd);
impl StopSignal {
pub fn new() -> std::io::Result<StopSignal> {
sys::eventfd().map(StopSignal)
}
pub fn signal(&self) {
sys::eventfd_signal(self.0.as_raw_fd());
}
pub fn raw(&self) -> RawFd {
self.0.as_raw_fd()
}
}
/// Inside the worker, right before its exec: install the broker filter (no
/// new privileges) and, in [`FilterMode::Notify`], send its listener over
/// `handoff` (the worker end of [`handoff_pair`]). The listener is
/// close-on-exec and closed here, so the worker's program never holds it.
pub fn install_worker_filter(
mode: FilterMode,
exec: crate::ExecPolicy,
handoff: Option<BorrowedFd<'_>>,
) -> std::io::Result<()> {
let prog = filter::program(mode, exec).map_err(std::io::Error::other)?;
match mode {
FilterMode::Deny => install(&prog, 0).map(|_| ()),
FilterMode::Notify => {
let sock = handoff.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no hand-off socket")
})?;
let l = install(&prog, FILTER_FLAG_NEW_LISTENER)?
.ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))?;
// SAFETY: both fds are open for the call.
unsafe { sys::send_fd_and_pid(sock.as_raw_fd(), l.as_raw_fd(), libc::getpid()) }
}
}
}
/// The broker side: wait on `sock` (the broker end of [`handoff_pair`]) for
/// the worker's listener, then serve it until every task using the filter is
/// gone or `stop` is readable. `worker_pid`: the worker's pid as THIS process
/// sees it (a container's init); `None`: the pid the worker sent (same pid
/// namespace). `Ok(None)`: the worker never handed a listener over (it
/// failed before its exec, or `stop` came first).
pub fn serve_handoff(
sock: BorrowedFd<'_>,
stop: RawFd,
worker_pid: Option<u32>,
policy: BrokerPolicy,
mode: Mode,
knobs: DebugKnobs,
) -> Result<Option<BrokerReport>, BrokerError> {
let io = |what: &str| {
let what = what.to_string();
move |source| BrokerError::Io { what, source }
};
loop {
let rev = sys::poll_in(&[sock.as_raw_fd(), stop], -1).map_err(io("poll"))?;
if rev[0] & libc::POLLIN != 0 {
break;
}
if rev[1] != 0 || rev[0] != 0 {
return Ok(None);
}
}
let Some((listener, pid)) =
sys::recv_fd_and_pid(sock.as_raw_fd()).map_err(io("receive the listener"))?
else {
return Ok(None);
};
let pid = worker_pid.unwrap_or(pid as u32);
let b = Broker::new(Listener::from_fd(listener), pid, policy, mode, knobs)?;
Ok(Some(b.serve(stop)))
}