crates/heimdall-cli/src/worker.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! `heimdall run --job wasm` under the GENERATED seccomp profile of the wasm
//! host (lane genprof; docs/SECCOMP.md "Enforcing the wasm host profile").
//!
//! **Heimdall is a defensive sandbox.** In a wasm job wasmtime is the only
//! native code (spec §2), so the generated `heimdall-wasm-host` profile is the
//! kernel filter of every wasm job. It is applied to a WORKER process, the
//! smallest place that runs the guest:
//!
//! 1. the parent `heimdall run` (no filter) picks the checked-in profile for
//! this build's arch and libc (crate heimdall-profiles), checks
//! `--seccomp-sha256`, makes the per-boot key and writes `--pin-out`;
//! 2. it starts itself again as the worker (`--heimdall-worker`) through
//! heimdall-seccomp's `Launch`: the filter (prelude + profile, unknown
//! call = `KILL_PROCESS`) is installed before the worker's first
//! instruction, exactly as the probe and `profiles/tests/enforce.rs` ran
//! the binary;
//! 3. the worker verifies and loads the guest, runs it, writes `out/files/`
//! and prints the UNSIGNED result on stdout; it never holds the key;
//! 4. the parent re-checks every output file against that result, states
//! `Protection::Generated { profile_digest }` (the sha256 of the profile's
//! OCI JSON), signs and writes `out/result.signed`. A worker killed by the
//! filter (`SIGSYS`) gives a signed Refused `heimdall-seccomp-1` with no
//! outputs; another signal `heimdall-exit-2`.
//!
//! `--seccomp none` (or `HEIMDALL_SECCOMP=none`) runs the guest in the one
//! process without a filter, as before, and the result says
//! `Protection::None`. The probe corpus (`profiles/heimdall-wasm-host`) runs
//! that way, so the profile is the whole host's.
use std::io::Read;
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use clap::ValueEnum;
use heimdall_core::{
codes, BootKey, ExitInfo, Features, JobId, JobResult, Nonce, Protection, Sha256Digest, Verdict,
};
use heimdall_profiles::Entry;
use heimdall_run::job::wasm_features;
use heimdall_run::outdir::{FILES_DIR, RESULT_FILE};
use heimdall_seccomp::bpf::Mode;
use heimdall_seccomp::launch::{Exit, Launch, Stdio};
use heimdall_seccomp::{Arch, Libc, Profile};
/// The workload whose profile the wasm host runs under.
pub const WORKLOAD: &str = "heimdall-wasm-host";
/// The environment variable that sets the default `--seccomp`.
pub const ENV: &str = "HEIMDALL_SECCOMP";
/// The hidden flag that makes `heimdall run` the worker.
pub const WORKER_FLAG: &str = "--heimdall-worker";
/// The largest unsigned result a worker may print.
const MAX_RESULT_JSON: u64 = 16 << 20;
/// `--seccomp`.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SeccompArg {
/// The checked-in generated profile of the wasm host, in a worker process.
Generated,
/// No filter: the guest runs in this process (Protection::None).
None,
}
/// The mode: the flag, else `HEIMDALL_SECCOMP`, else `generated` in a
/// release build and `none` in a debug build (the checked-in profiles are
/// generated from the release build; a debug build makes other calls, e.g.
/// std's debug-assertion `fcntl(F_GETFD)` checks).
pub fn mode(flag: Option<SeccompArg>) -> Result<SeccompArg, String> {
if let Some(m) = flag {
return Ok(m);
}
match std::env::var(ENV) {
Err(std::env::VarError::NotPresent) if cfg!(debug_assertions) => Ok(SeccompArg::None),
Err(std::env::VarError::NotPresent) => Ok(SeccompArg::Generated),
Ok(v) => SeccompArg::from_str(&v, false)
.map_err(|_| format!("{ENV}={v:?} is not `generated` or `none`")),
Err(e) => Err(format!("{ENV}: {e}")),
}
}
/// This build's libc (the profiles differ per libc).
pub fn build_libc() -> Libc {
if cfg!(target_env = "musl") {
Libc::Musl
} else {
Libc::Glibc
}
}
/// The checked-in profile for this build's arch and libc.
pub fn builtin() -> Result<&'static Entry, String> {
if cfg!(debug_assertions) {
return Err(format!(
"this is a debug build: the generated {WORKLOAD} profiles are for the release build"
));
}
let arch = Arch::native().ok_or("this arch has no seccomp profiles")?;
let libc = build_libc();
heimdall_profiles::ALL
.iter()
.find(|e| {
e.profile.workload == WORKLOAD && e.profile.arch == arch && e.profile.libc == libc
})
.ok_or_else(|| format!("no generated {WORKLOAD} profile for {arch}-{libc} in this build"))
}
/// `sha256:<hex>` of a checked-in profile's OCI JSON.
pub fn digest(e: &Entry) -> String {
Sha256Digest::of(e.oci_json.as_bytes()).to_prefixed()
}
/// The features of a run under the generated profile.
pub fn features(profile_digest: &str) -> Features {
Features {
seccomp: Protection::Generated {
profile_digest: profile_digest.to_string(),
},
..wasm_features()
}
}
/// The test hook's variable (feature `test-hooks` only).
#[cfg_attr(not(feature = "test-hooks"), allow(dead_code))]
const TEST_HOOK_ENV: &str = "HEIMDALL_TEST_WORKER_CALL";
/// Test hook (feature `test-hooks`, never in a shipped binary):
/// `HEIMDALL_TEST_WORKER_CALL=socket` makes the worker open a TCP socket, a
/// call the wasm host profile does not allow, so the tests can prove the
/// filter kills it.
pub fn test_hook() {
#[cfg(feature = "test-hooks")]
if std::env::var(TEST_HOOK_ENV).as_deref() == Ok("socket") {
let _ = std::net::TcpListener::bind("127.0.0.1:0");
}
}
/// What the worker ended with.
pub enum WorkerEnd {
/// The worker's unsigned result.
Result(Box<JobResult>),
/// The worker could not start the job (its message is on stderr): pass
/// this exit code on, nothing is signed (as without a worker).
Host(u8),
/// The worker died: sign a refusal.
Died(Verdict, ExitInfo),
}
/// Start the worker under `profile` with this process's arguments plus
/// [`WORKER_FLAG`]; wait for it and read its result.
pub fn spawn(profile: &Profile, profile_digest: &str) -> Result<WorkerEnd, String> {
let exe = std::env::current_exe().map_err(|e| format!("current exe: {e}"))?;
let (mine, theirs) = UnixStream::pair().map_err(|e| format!("worker socket: {e}"))?;
// The worker starts from the environment the profile was probed with
// (profiles/heimdall-wasm-host/workload.toml): nothing of the caller's
// environment reaches it, so no LD_LIBRARY_PATH / LD_PRELOAD makes the
// dynamic loader do what the probe never saw.
#[allow(unused_mut)]
let mut l = Launch::new(&exe)
.arg0("heimdall")
.args(std::env::args_os().skip(1))
.arg(WORKER_FLAG)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("RUST_BACKTRACE", "0");
#[cfg(feature = "test-hooks")]
if let Some(v) = std::env::var_os(TEST_HOOK_ENV) {
l = l.env(TEST_HOOK_ENV, v);
}
let l = l
.stdin(Stdio::Null)
.stdout(Stdio::Fd(OwnedFd::from(theirs)))
.stderr(Stdio::Inherit)
.profile(profile, &Mode::Enforce);
let mut child = l.spawn().map_err(|e| format!("worker: {e}"))?;
let reader = std::thread::spawn(move || {
let mut out = Vec::new();
let _ = (&mine).take(MAX_RESULT_JSON).read_to_end(&mut out);
out
});
let exit = child.wait().map_err(|e| format!("worker: {e}"))?;
let out = reader.join().unwrap_or_default();
Ok(match exit {
Exit::Code(0) => match serde_json::from_slice::<JobResult>(&out) {
Ok(r) => WorkerEnd::Result(Box::new(r)),
Err(e) => WorkerEnd::Died(
Verdict::refused(
codes::EXIT_NONZERO,
format!("the wasm host worker printed no result ({e})"),
),
ExitInfo::exited(0),
),
},
Exit::Code(c) if c == i32::from(crate::EXIT_HOST) || c == i32::from(crate::EXIT_USAGE) => {
WorkerEnd::Host(c as u8)
}
Exit::Code(c) => WorkerEnd::Died(
Verdict::refused(
codes::EXIT_NONZERO,
format!("the wasm host worker exited with code {c}"),
),
ExitInfo::exited(c),
),
Exit::Signal { signal, .. } if signal == libc::SIGSYS => WorkerEnd::Died(
Verdict::refused(
codes::SECCOMP_KILL,
format!(
"the wasm host worker died from signal {signal} (SIGSYS): a system call its generated seccomp profile ({profile_digest}) kills"
),
),
ExitInfo {
signal: Some(signal),
..ExitInfo::default()
},
),
Exit::Signal { signal, .. } => WorkerEnd::Died(
Verdict::refused(
codes::EXIT_SIGNAL,
format!("the wasm host worker died from signal {signal}"),
),
ExitInfo {
signal: Some(signal),
..ExitInfo::default()
},
),
})
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Remove every file the worker left in `out/files/` (a refused job hands on
/// no partial output); create `out/` and `out/files/` when the worker never
/// got that far.
fn discard_outputs(out: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
if std::fs::symlink_metadata(out).is_err() {
std::fs::DirBuilder::new().mode(0o700).create(out)?;
}
let files = out.join(FILES_DIR);
if std::fs::symlink_metadata(&files).is_err() {
std::fs::DirBuilder::new().mode(0o700).create(&files)?;
return Ok(());
}
for e in std::fs::read_dir(&files)? {
let p = e?.path();
if std::fs::symlink_metadata(&p)?.is_dir() {
std::fs::remove_dir_all(&p)?;
} else {
std::fs::remove_file(&p)?;
}
}
Ok(())
}
/// The result the parent signs for what the worker ended with: the worker's
/// own result with its outputs re-checked on disk, or a refusal. `job_id`,
/// `nonce` and the features are the parent's, never the worker's.
pub fn finish(
end: WorkerEnd,
out: &Path,
job_id: JobId,
nonce: Nonce,
profile_digest: &str,
started_unix: i64,
) -> Result<JobResult, String> {
let refused = |verdict: Verdict, exit: ExitInfo| -> Result<JobResult, String> {
discard_outputs(out).map_err(|e| format!("out dir {}: {e}", out.display()))?;
Ok(JobResult {
job_id,
nonce,
verdict,
outputs: vec![],
exit,
started_unix,
finished_unix: now_unix().max(started_unix),
runner: features(profile_digest),
events: vec![],
kernel: None,
})
};
match end {
WorkerEnd::Host(c) => Err(format!("the worker could not start the job (exit {c})")),
WorkerEnd::Died(v, e) => refused(v, e),
WorkerEnd::Result(r) => {
let mut r = *r;
r.job_id = job_id;
r.nonce = nonce;
r.runner = features(profile_digest);
r.kernel = None;
if let Err(e) = heimdall_core::check_output_files(&r, &out.join(FILES_DIR)) {
return refused(
Verdict::refused(
codes::OUT_AUDIT,
format!("the worker's outputs do not match its result ({e})"),
),
r.exit,
);
}
Ok(r)
}
}
}
/// Sign `result` and write `out/result.signed` (exclusive, no symlink follow).
pub fn sign_and_write(
key: &BootKey,
result: JobResult,
out: &Path,
) -> Result<(heimdall_core::SignedResult, std::path::PathBuf), String> {
let signed = key.sign(result).map_err(|e| e.to_string())?;
let path = out.join(RESULT_FILE);
let write = || -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)?;
f.write_all(&signed.to_bytes())?;
f.sync_all()
};
write().map_err(|e| format!("{}: {e}", path.display()))?;
Ok((signed, path))
}