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

nordisk/holger

server/lib/src/sandbox/clamd.rs

//! **clamd in its own sandbox, on the box** (lane HOLGER-MEDIA, 2026-10-23): what
//! `scripts/clamd-sandbox.sh` does with podman, done with crun over the ClamAV
//! rootfs the medium carried.
//!
//! One long-lived container: `--network none` (a new, empty network
//! namespace), a read-only root, no capability, no-new-privileges, the seccomp
//! profile, the clamav user (uid 100) in a user namespace, memory and pids
//! limits, a capped tmpfs `/tmp`, its run directory bind-mounted read-write so
//! the socket appears on the host. The signature database is the pinned
//! image's own, read-only inside the rootfs. Holger streams bytes to the socket
//! and unpacks nothing itself.
//!
//! [`Clamd::start`] spawns the runtime in the foreground (its child IS the
//! container) and waits for `PING` → `PONG` on the socket; a clamd that does not
//! come up in time is [`Refusal::ClamdDown`] (`holger-sandbox-12`) and the boot
//! refuses by name. [`supervise`] keeps it alive: a container that exits is
//! started again with a backoff, and the server's `required: true` ClamAV
//! scanner refuses every scan in between.

use std::io::{Read as _, Write as _};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use holger_sandbox::crun::{MountSpec, Runtime, Spec};
use holger_sandbox::media::Layout;
use holger_sandbox::{Limits, Refusal};

/// The clamav user inside the image (`id clamav` in `clamav/clamav`).
pub const CLAMD_UID: u32 = 100;
pub const CLAMD_GID: u32 = 101;
/// The daemon inside the image.
pub const CLAMD_BIN: &str = "/usr/sbin/clamd";
/// Its config inside the container (the host's `clamd.conf`, read-only).
pub const CLAMD_CONF_IN: &str = "/etc/clamav/clamd-sandbox.conf";
/// Its run directory inside (the config's `LocalSocket /run/clamd/clamd.sock`).
pub const CLAMD_RUN_IN: &str = "/run/clamd";
/// clamd's DatabaseDirectory inside (the config's `DatabaseDirectory /var/lib/clamav`).
pub const CLAMD_DB_IN: &str = "/var/lib/clamav";
/// The container's name.
pub const CONTAINER: &str = "holger-clamd";
/// How long the first PONG may take (the database loads first: tens of seconds).
pub const START_BUDGET: Duration = Duration::from_secs(180);

/// clamd's limits: the script's `--memory 4g --pids-limit 256`, with a 2 GiB
/// memory ceiling for a 4 GiB box (clamd holds about 1.3 GiB with the full
/// database), two cores, and a 1 GiB `/tmp`.
pub fn limits() -> Limits {
    Limits {
        cpu_millis: 2000,
        memory_mib: 2048,
        pids: 256,
        scratch_mib: 1024,
        timeout_secs: 1,
        report_max_bytes: 1024,
    }
}

/// The container, as data. Once a signed signature bundle is installed
/// ([`Layout::signatures`] holds `installed.json`), its `clamav/` is mounted read-only over
/// clamd's DatabaseDirectory; before, clamd reads the pinned image's own database.
pub fn spec(layout: &Layout, userns_base: u32, ipc: bool) -> Spec {
    let mut mounts = vec![
        MountSpec::Tmpfs {
            dest: "/tmp".into(),
            size_mib: limits().scratch_mib,
            mode: 0o1777,
        },
        MountSpec::Bind {
            host: layout.clamd_run(),
            dest: CLAMD_RUN_IN.into(),
            rw: true,
        },
        MountSpec::Bind {
            host: layout.clamd_run().join("clamd.conf"),
            dest: CLAMD_CONF_IN.into(),
            rw: false,
        },
    ];
    if layout.signatures().join("installed.json").is_file() {
        mounts.push(MountSpec::Bind {
            host: layout.signatures().join("clamav"),
            dest: CLAMD_DB_IN.into(),
            rw: false,
        });
    }
    Spec {
        name: CONTAINER.into(),
        hostname: "holger-clamd".into(),
        rootfs: layout.clamav_rootfs(),
        argv: vec![CLAMD_BIN.into(), format!("--config-file={CLAMD_CONF_IN}")],
        env: vec![
            (
                "PATH".into(),
                "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
            ),
            ("TZ".into(), "Etc/UTC".into()),
        ],
        uid: CLAMD_UID,
        gid: CLAMD_GID,
        limits: limits(),
        mounts,
        userns_base,
        ipc,
    }
}

/// `zPING\0` → `PONG`: is clamd answering on `socket`?
pub fn ping(socket: &Path, timeout: Duration) -> Result<(), String> {
    let mut s = UnixStream::connect(socket).map_err(|e| format!("{}: {e}", socket.display()))?;
    s.set_read_timeout(Some(timeout)).ok();
    s.set_write_timeout(Some(timeout)).ok();
    s.write_all(b"zPING\0").map_err(|e| format!("write: {e}"))?;
    let mut buf = [0u8; 16];
    let n = s.read(&mut buf).map_err(|e| format!("read: {e}"))?;
    if buf[..n].starts_with(b"PONG") {
        Ok(())
    } else {
        Err(format!(
            "answered {:?}, not PONG",
            String::from_utf8_lossy(&buf[..n])
        ))
    }
}

/// `zRELOAD\0` → `RELOADING`: clamd reads its database again (a signature bundle was
/// installed). A container started before the first bundle still has the image's database
/// mounted; it takes the bundle's at its next start.
pub fn reload(socket: &Path) -> Result<(), String> {
    let mut s = UnixStream::connect(socket).map_err(|e| format!("{}: {e}", socket.display()))?;
    s.set_read_timeout(Some(Duration::from_secs(10))).ok();
    s.write_all(b"zRELOAD\0")
        .map_err(|e| format!("write: {e}"))?;
    let mut buf = [0u8; 32];
    let n = s.read(&mut buf).map_err(|e| format!("read: {e}"))?;
    if buf[..n].starts_with(b"RELOADING") {
        Ok(())
    } else {
        Err(format!(
            "answered {:?}, not RELOADING",
            String::from_utf8_lossy(&buf[..n])
        ))
    }
}

/// The running clamd container.
pub struct Clamd {
    rt: Runtime,
    spec: Spec,
    child: std::process::Child,
    /// The claim on the child's exit (a PID 1's reaper leaves it to us).
    _claim: Option<holger_sandbox::ChildClaim>,
    pub socket: PathBuf,
    started: Instant,
}

impl std::fmt::Debug for Clamd {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Clamd")
            .field("socket", &self.socket)
            .field("pid", &self.child.id())
            .finish()
    }
}

impl Clamd {
    /// Start the container and wait for its first PONG (at most `budget`).
    pub fn start(
        rt: Runtime,
        spec: Spec,
        socket: PathBuf,
        budget: Duration,
    ) -> Result<Clamd, Refusal> {
        rt.kill(&spec.name);
        rt.delete(&spec.name);
        let _ = std::fs::remove_file(&socket);
        // A32-14, a process by design: clamd, run in the sandbox, claimed and supervised.
        let child = rt.spawn(&spec)?;
        let claim = holger_sandbox::claim_child(&child);
        let mut c = Clamd {
            rt,
            spec,
            child,
            _claim: claim,
            socket,
            started: Instant::now(),
        };
        let deadline = c.started + budget;
        loop {
            if let Ok(Some(status)) = c.child.try_wait() {
                let tail = c.stderr_tail();
                c.rt.delete(&c.spec.name);
                return Err(Refusal::ClamdDown(format!(
                    "the clamd container exited {status} before answering{tail}"
                )));
            }
            if c.socket.exists() && ping(&c.socket, Duration::from_secs(2)).is_ok() {
                return Ok(c);
            }
            if Instant::now() >= deadline {
                let tail = c.stderr_tail();
                c.stop();
                return Err(Refusal::ClamdDown(format!(
                    "no PONG on {} within {} s{tail}",
                    c.socket.display(),
                    budget.as_secs()
                )));
            }
            std::thread::sleep(Duration::from_millis(500));
        }
    }

    fn stderr_tail(&mut self) -> String {
        let mut s = String::new();
        if let Some(e) = self.child.stderr.as_mut() {
            let mut buf = vec![0u8; 4096];
            if let Ok(n) = e.read(&mut buf) {
                s = String::from_utf8_lossy(&buf[..n])
                    .trim()
                    .lines()
                    .rev()
                    .take(3)
                    .collect::<Vec<_>>()
                    .into_iter()
                    .rev()
                    .collect::<Vec<_>>()
                    .join(" | ");
            }
        }
        if s.is_empty() {
            s
        } else {
            format!(": {s}")
        }
    }

    /// Has the container's process ended? (`None` while it runs.)
    pub fn exited(&mut self) -> Option<String> {
        match self.child.try_wait() {
            Ok(Some(st)) => Some(format!("exited {st}")),
            Ok(None) => None,
            Err(e) => Some(format!("gone ({e})")),
        }
    }

    pub fn up_for(&self) -> Duration {
        self.started.elapsed()
    }

    /// Kill and delete the container.
    pub fn stop(&mut self) {
        self.rt.kill(&self.spec.name);
        let _ = self.child.kill();
        // A PROCESS wait (the clamd child), not a lock wait: spelled by its type so the one-poison
        // rule census (`lock::tests`) does not read it as a Condvar.
        let _ = std::process::Child::wait(&mut self.child);
        self.rt.delete(&self.spec.name);
    }
}

/// The wait before restart attempt `n` (0-based): 2 s, 4 s, 8 s, ... capped at a minute.
pub fn backoff_after(n: u32) -> Duration {
    Duration::from_secs(2u64.saturating_mul(1u64 << n.min(5)).min(60))
}

/// **Keep clamd alive**: look every `every`; when the container ended, start it
/// again, with [`backoff_after`] between failed attempts, forever. The intake's
/// `required: true` ClamAV scanner refuses every scan while it is down; nothing
/// here ever gives up on it. Never returns.
pub async fn supervise(c: Clamd, every: Duration) {
    let (rt, spec, socket) = (c.rt.clone(), c.spec.clone(), c.socket.clone());
    let mut running = Some(c);
    let mut failed_attempts: u32 = 0;
    loop {
        tokio::time::sleep(every).await;
        if let Some(c) = running.as_mut() {
            match c.exited() {
                None => continue,
                Some(why) => {
                    log::warn!("[holger-sandbox-12] the clamd container {why} after {} s; starting it again", c.up_for().as_secs());
                    running = None;
                }
            }
        }
        let (rt2, spec2, socket2) = (rt.clone(), spec.clone(), socket.clone());
        match tokio::task::spawn_blocking(move || Clamd::start(rt2, spec2, socket2, START_BUDGET))
            .await
        {
            Ok(Ok(next)) => {
                log::info!("clamd is back in its sandbox at {}", next.socket.display());
                running = Some(next);
                failed_attempts = 0;
            }
            Ok(Err(e)) => {
                let wait = backoff_after(failed_attempts);
                failed_attempts = failed_attempts.saturating_add(1);
                log::error!("{e}; next attempt in {} s", wait.as_secs());
                tokio::time::sleep(wait).await;
            }
            Err(e) => {
                let wait = backoff_after(failed_attempts);
                failed_attempts = failed_attempts.saturating_add(1);
                log::error!(
                    "[holger-sandbox-12] the clamd restart task failed: {e}; next attempt in {} s",
                    wait.as_secs()
                );
                tokio::time::sleep(wait).await;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use holger_sandbox::crun::oci_config;

    #[test]
    fn the_clamd_container_is_hardened_like_the_script() {
        let layout = Layout::under(Path::new("/data/holger"));
        let s = spec(&layout, 100_000, true);
        assert_eq!(s.uid, 100);
        assert_eq!(s.gid, 101);
        assert_eq!(
            s.rootfs,
            PathBuf::from("/data/holger/sandbox/clamav/rootfs")
        );
        assert_eq!(
            s.argv,
            [CLAMD_BIN, "--config-file=/etc/clamav/clamd-sandbox.conf"]
        );
        let c = oci_config(&s);
        assert_eq!(c["root"]["readonly"], true);
        assert_eq!(c["process"]["noNewPrivileges"], true);
        assert_eq!(
            c["process"]["capabilities"]["bounding"],
            serde_json::json!([])
        );
        assert_eq!(c["linux"]["resources"]["memory"]["limit"], 2048i64 << 20);
        assert_eq!(c["linux"]["resources"]["pids"]["limit"], 256);
        assert_eq!(c["linux"]["uidMappings"][0]["hostID"], 100_000);
        let mounts = c["mounts"].as_array().unwrap();
        let run = mounts
            .iter()
            .find(|m| m["destination"] == CLAMD_RUN_IN)
            .expect("the run directory");
        assert_eq!(run["source"], "/data/holger/sandbox/clamd");
        assert!(run["options"].as_array().unwrap().iter().any(|o| o == "rw"));
        let conf = mounts
            .iter()
            .find(|m| m["destination"] == CLAMD_CONF_IN)
            .expect("the config");
        assert!(conf["options"]
            .as_array()
            .unwrap()
            .iter()
            .any(|o| o == "ro"));
        let tmp = mounts
            .iter()
            .find(|m| m["destination"] == "/tmp")
            .expect("a capped /tmp");
        assert!(tmp["options"]
            .as_array()
            .unwrap()
            .iter()
            .any(|o| o == "size=1024m"));
        assert!(
            c["linux"]["namespaces"]
                .as_array()
                .unwrap()
                .iter()
                .any(|n| n["type"] == "network"),
            "no network"
        );
        assert_eq!(c["hostname"], "holger-clamd");
    }

    /// ★ HOL-NCSC: once a signed signature bundle is installed, clamd reads ITS database
    /// (`<sandbox>/signatures/clamav`, read-only over `/var/lib/clamav`); before, the image's own.
    #[test]
    fn an_installed_signature_bundle_is_clamds_database() {
        let tmp = tempfile::tempdir().unwrap();
        let layout = Layout::under(tmp.path());
        let db_mount = |s: &Spec| {
            oci_config(s)["mounts"]
                .as_array()
                .unwrap()
                .iter()
                .find(|m| m["destination"] == CLAMD_DB_IN)
                .cloned()
        };
        assert!(
            db_mount(&spec(&layout, 0, false)).is_none(),
            "no bundle yet: the image's own database"
        );
        let sigs = layout.dir.join("signatures");
        std::fs::create_dir_all(sigs.join("clamav")).unwrap();
        std::fs::write(sigs.join("installed.json"), b"{}").unwrap();
        let m = db_mount(&spec(&layout, 0, false)).expect("the installed signatures are mounted");
        assert_eq!(m["source"], sigs.join("clamav").display().to_string());
        assert!(
            m["options"].as_array().unwrap().iter().any(|o| o == "ro"),
            "read-only: {m}"
        );
    }

    #[test]
    fn the_config_laid_beside_the_socket_names_the_run_directory() {
        assert!(super::super::media::CLAMD_CONF.contains("LocalSocket /run/clamd/clamd.sock"));
        assert!(super::super::media::CLAMD_CONF.contains("DatabaseDirectory /var/lib/clamav"));
        assert!(super::super::media::CLAMD_CONF.contains("Foreground yes"));
    }

    #[test]
    fn the_restart_backoff_doubles_from_two_seconds_and_caps_at_a_minute() {
        let secs: Vec<u64> = (0..8).map(|n| backoff_after(n).as_secs()).collect();
        assert_eq!(secs, [2, 4, 8, 16, 32, 60, 60, 60]);
        assert_eq!(backoff_after(u32::MAX).as_secs(), 60);
    }

    #[test]
    fn ping_wants_a_pong() {
        let dir = tempfile::tempdir().unwrap();
        let sock = dir.path().join("clamd.sock");
        let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
        let answer = std::thread::spawn(move || {
            for reply in [&b"PONG\n"[..], b"NOPE"] {
                let (mut s, _) = listener.accept().unwrap();
                let mut b = [0u8; 8];
                let n = s.read(&mut b).unwrap();
                assert_eq!(&b[..n], b"zPING\0");
                s.write_all(reply).unwrap();
            }
        });
        ping(&sock, Duration::from_secs(2)).unwrap();
        assert!(ping(&sock, Duration::from_secs(2))
            .unwrap_err()
            .contains("not PONG"));
        answer.join().unwrap();
        assert!(ping(&dir.path().join("none.sock"), Duration::from_secs(1)).is_err());
    }
}