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());
}
}