Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

jobs/runner/src/container.rs

//! **`ContainerRunner`: the interim runner** over Holger's own sandbox
//! (`holger_sandbox`): one fresh container per job (podman, its API socket, or crun
//! over the verified rootfs), or one fresh rlimited subprocess in the scan sidecar
//! (`engine: remote`). No network, a read-only root, every capability dropped,
//! no-new-privileges, a non-root user, the engine's default seccomp (none in the
//! sidecar), the job's input read-only at `/in/i` and its one writable `/out`.
//!
//! It is NOT Heimdall: no generated syscall profile, no Landlock, no broker, no
//! wasm. [`ContainerRunner::features`] says so, and in strict CDS mode
//! (`boundary: OneWay`) Holger warns at startup.

use std::path::Path;

use heimdall_core::{
    ExitInfo, Features, Job, JobKind, JobResult, JobRunner, KillReason, Limits, Protection,
    RunError, SignedResult, Verdict, VerifyingKey,
};
use holger_sandbox::{EngineKind, Mount, Refusal, Request, Sandbox};

use crate::collect::{collect, empty, refused};
use crate::key::ResultKey;
use crate::wire::{read_mount, Answer, WireJob, INPUT_MOUNT, JOB_SCHEMA};

/// The interim runner.
pub struct ContainerRunner {
    sandbox: Sandbox,
    key: ResultKey,
}

impl std::fmt::Debug for ContainerRunner {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ContainerRunner")
            .field("engine", &self.sandbox.engine_words())
            .field("key", &self.key)
            .finish()
    }
}

impl ContainerRunner {
    /// The runner over `sandbox`, with a fresh per-boot key.
    pub fn new(sandbox: Sandbox) -> Self {
        Self::with_key(sandbox, ResultKey::per_boot())
    }

    /// The runner over `sandbox` with `key` (tests).
    pub fn with_key(sandbox: Sandbox, key: ResultKey) -> Self {
        Self { sandbox, key }
    }

    pub fn sandbox(&self) -> &Sandbox {
        &self.sandbox
    }

    /// What protects a job under `engine`, stated truthfully.
    pub fn features_of(engine: EngineKind) -> Features {
        let (runner, container, seccomp) = match engine {
            EngineKind::Podman => ("holger-podman", true, Protection::Default),
            EngineKind::Crun => ("holger-crun", true, Protection::Default),
            // A fresh rlimited subprocess in the scan sidecar's container: no container
            // of its own per job, and no seccomp filter the sidecar installs.
            EngineKind::Remote => ("holger-sidecar", false, Protection::None),
            // Never a sandbox of this runner (`Sandbox::from_config` refuses it); said
            // plainly should one be built by hand.
            EngineKind::Heimdall => ("holger-not-heimdall", false, Protection::None),
        };
        Features {
            runner: runner.into(),
            container,
            seccomp,
            landlock: false,
            broker: false,
            wasm: false,
            image_verified_each_start: false,
        }
    }

    /// The sandbox's limits for one job: the job's own, with the config's scratch
    /// space (below the memory) and report cap.
    pub fn limits_for(&self, l: &Limits) -> holger_sandbox::Limits {
        let cfg = &self.sandbox.config().limits;
        let memory_mib = (l.memory_bytes >> 20).max(64);
        holger_sandbox::Limits {
            cpu_millis: ((f64::from(l.cpus) * 1000.0).round() as u64).max(1),
            memory_mib,
            pids: u64::from(l.pids).max(8),
            scratch_mib: cfg.scratch_mib.min(memory_mib / 2).max(1),
            timeout_secs: l.wall.as_secs().max(1),
            report_max_bytes: cfg.report_max_bytes,
        }
    }
}

impl JobRunner for ContainerRunner {
    fn run(&self, job: Job) -> Result<SignedResult, RunError> {
        let argv = native_argv(&job)?;
        if job.policy.net.is_some() {
            return Err(RunError::Unsupported { what: "a network allowance: the interim container runner gives a job no network at all".into() });
        }
        if !job.policy.write.is_empty() {
            return Err(RunError::Unsupported {
                what: "a writable path besides the output directory".into(),
            });
        }
        check_dirs(&job)?;
        make_readable(&job.input_dir);
        let wire = WireJob {
            schema: JOB_SCHEMA.into(),
            job_id: job.id.to_hex(),
            argv,
            params: job.params.clone(),
            reads: job.policy.read.len(),
            answer: None,
        };
        let doc = serde_json::to_vec(&wire).map_err(|e| RunError::Io {
            reason: format!("the job document: {e}"),
        })?;
        let mut mounts = vec![Mount::new(&job.input_dir, INPUT_MOUNT)];
        for (n, p) in job.policy.read.iter().enumerate() {
            if !p.is_absolute() {
                return Err(RunError::InvalidJob {
                    reason: format!("the read path {} is not absolute", p.display()),
                });
            }
            mounts.push(Mount::new(p, read_mount(n)));
        }
        let name = job.id.to_hex();
        let req = Request {
            artifact: b"",
            job: &doc,
            mounts,
            sha256: &name,
            command: Vec::new(),
        };
        let started = now_unix();
        let (verdict, outputs, exit) =
            match self
                .sandbox
                .run_job(&req, &job.output_dir, &self.limits_for(&job.policy.limits))
            {
                Ok(o) => match Answer::parse(&o.report) {
                    Ok(a) => {
                        let (v, outs) = collect(&a, &job.output_dir);
                        (v, outs, ExitInfo::exited(0))
                    }
                    Err(why) => {
                        empty(&job.output_dir);
                        (
                            refused(&holger_errcode::SANDBOX_JOB_ANSWER_INVALID, why),
                            Vec::new(),
                            ExitInfo::exited(0),
                        )
                    }
                },
                Err(r) => {
                    empty(&job.output_dir);
                    let exit = match &r {
                        Refusal::Timeout { .. } => ExitInfo {
                            killed: Some(KillReason::Wall),
                            ..Default::default()
                        },
                        Refusal::Killed { exit, oom, .. } => ExitInfo {
                            code: *exit,
                            killed: oom.then_some(KillReason::Memory),
                            ..Default::default()
                        },
                        Refusal::WorkerFailed { exit, .. } => ExitInfo {
                            code: *exit,
                            ..Default::default()
                        },
                        Refusal::ReportTooLarge { .. } | Refusal::ReportInvalid(_) => {
                            ExitInfo::default()
                        }
                        Refusal::Config(_) => {
                            return Err(RunError::InvalidJob {
                                reason: r.to_string(),
                            })
                        }
                        Refusal::Io(_) => {
                            return Err(RunError::Io {
                                reason: r.to_string(),
                            })
                        }
                        Refusal::ImageMissing(_) => {
                            return Err(RunError::Image {
                                reason: r.to_string(),
                            })
                        }
                        _ => {
                            return Err(RunError::Setup {
                                reason: r.to_string(),
                            })
                        }
                    };
                    (
                        Verdict::Refused {
                            code: r.code().code.into(),
                            reason: r.to_string(),
                        },
                        Vec::new(),
                        exit,
                    )
                }
            };
        let result = JobResult {
            job_id: job.id,
            nonce: job.nonce,
            verdict,
            outputs,
            exit,
            started_unix: started,
            finished_unix: now_unix().max(started),
            runner: self.features(),
            events: Vec::new(),
            // This runner probes no kernel (Heimdall 0.2's `JobResult.kernel`).
            kernel: None,
        };
        self.key.sign(result)
    }

    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }

    fn features(&self) -> Features {
        Self::features_of(self.sandbox.config().engine)
    }
}

/// The argv of a native job of Holger's own worker; anything else is refused.
pub(crate) fn native_argv(job: &Job) -> Result<Vec<String>, RunError> {
    match &job.kind {
        JobKind::Wasm { .. } => Err(RunError::Unsupported {
            what: "a wasm guest: this runner runs native workers only".into(),
        }),
        JobKind::Native { image, .. } if image.name != crate::WORKER => {
            Err(RunError::Unsupported {
                what: format!(
                    "the image {:?}: this runner runs Holger's own worker ({}) only",
                    image.name,
                    crate::WORKER
                ),
            })
        }
        JobKind::Native { argv, .. } if argv.is_empty() => Err(RunError::InvalidJob {
            reason: "the job names no program".into(),
        }),
        JobKind::Native { argv, .. } => Ok(argv.clone()),
    }
}

/// The input is an absolute directory, the output an absolute, empty one.
pub(crate) fn check_dirs(job: &Job) -> Result<(), RunError> {
    for (what, d) in [("input", &job.input_dir), ("output", &job.output_dir)] {
        if !d.is_absolute() || !d.is_dir() {
            return Err(RunError::InvalidJob {
                reason: format!(
                    "the job's {what} directory {} is not an absolute directory",
                    d.display()
                ),
            });
        }
    }
    let mut there = std::fs::read_dir(&job.output_dir).map_err(|e| RunError::Io {
        reason: format!("{}: {e}", job.output_dir.display()),
    })?;
    if there.next().is_some() {
        return Err(RunError::InvalidJob {
            reason: format!(
                "the job's output directory {} is not empty",
                job.output_dir.display()
            ),
        });
    }
    Ok(())
}

/// The input tree readable by the job's (mapped, non-root) user: directories 0755,
/// regular files 0444. Links are not followed. Modes only: nothing is opened.
fn make_readable(dir: &Path) {
    use std::os::unix::fs::PermissionsExt as _;
    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755));
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
        match e.file_type() {
            Ok(t) if t.is_dir() => make_readable(&e.path()),
            Ok(t) if t.is_file() => {
                let _ = std::fs::set_permissions(e.path(), std::fs::Permissions::from_mode(0o444));
            }
            _ => {}
        }
    }
}

pub(crate) fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}