Files
windmill/backend/windmill-worker/src/docker_v2.rs
T
Ruben FiszelandClaude Opus 4.8 7590b28108 feat(sandbox): pull/extract images with crane instead of podman (#9455)
* feat(sandbox): pull/extract images with crane instead of podman (+ add to image)

The sandboxed container runtime (`# sandbox <image>`) only ever pulls + flattens an
image (nsjail does the run), so a full container engine is overkill — and podman was
never actually in any Dockerfile, so the merged feature couldn't run in the shipped
image. Switch to crane (google/go-containerregistry): a single ~25MB static binary,
no daemon/store/root/privileged.

- docker_v2.rs: crane export -> flattened rootfs tar, crane config -> OCI config,
  crane digest -> content-addressed rootfs+config cache (cross-job dedup + automatic
  freshness), crane manifest -> pre-download size guard. DOCKER_CONFIG authfile dir.
  Cache eviction prunes the rootfs-tar cache by mtime (LRU). Pull policy honored via a
  ref->digest cache (missing/never reuse without a registry hit).
- Dockerfile + docker/DockerfileSlim{,Ee}: install the crane binary (Full/FullEe and
  the EE image inherit it via FROM the base image).
- docs + UI text + instance-setting descriptions updated (download size is compressed;
  cache is the rootfs-tar cache).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): address CI review — digest-pinned fetch, size cap on every job, eviction race

Codex P1s:
- Fetch by the resolved digest (name@digest), not the mutable tag, so content can't
  diverge from the digest the cache is keyed under if a tag moves mid-fetch.
- Enforce the size cap on EVERY job via a cached {digest}.size sidecar (no registry call
  on cache reuse), so lowering the limit rejects already-cached oversized images.
- Eviction race: hardlink the cache tar into the job dir before tar -xf (pins the inode
  against concurrent eviction) and re-fetch if it was evicted first.
Claude P2s: atomic config sidecar (tmp+rename) + tolerate torn parse; soften the LRU
comment (mtime = creation order); sweep orphaned *.tmp.* and .size on eviction.
+digest_key/ref_key unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): P1 cross-fs cache staging (EXDEV), Dockerfile arch fail-fast

CI re-review (Claude + Codex P1): the eviction-race hardlink crosses filesystems in the
shipped deployments — the cache is its own volume (/tmp/windmill/cache) while the job dir
is on the container fs — so hard_link returns EXDEV (not NotFound) and every sandbox job
fails. Fall back to tokio::fs::copy on a non-NotFound link error; copy reads through the
source inode so it still survives a concurrent eviction.
Also: Dockerfiles fail fast with a clear error on an unsupported arch instead of building
a 404 crane URL; ref->digest file written via tmp+rename (no torn read under missing/never).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sandbox): say 'oldest by creation time' not 'LRU' for cache eviction

Codex P2: the code evicts by tar creation time (cache hits don't touch mtime), so the
user-facing docs + instance-setting text shouldn't claim true LRU.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:52:55 +00:00

893 lines
36 KiB
Rust

//! Sandboxed container runtime: run a container as a sandboxed subprogram of the job.
//!
//! Unlike the legacy `# docker` (dind/daemon) path, this has no daemon and no Docker
//! API. It splits *pull* from *run*:
//!
//! 1. **pull/extract** (`crane`, no daemon/store/root): materialize the image's root
//! filesystem into `{job_dir}/rootfs` and read its OCI config
//! (Env/Cmd/Entrypoint/WorkingDir), via a digest-keyed rootfs cache.
//! 2. **run** (the job's own nsjail sandbox): execute the image command with the
//! extracted rootfs bound in as the new root, so the container inherits exactly
//! the job's confinement (filesystem mask, pid namespace, network, uid) and can't
//! escape past what the job itself can reach.
//!
//! Selected by `# sandbox <image>` (a bare `# sandbox` keeps plain nsjail-bash;
//! `# docker` keeps the v1 daemon path). The script body runs inside the image via
//! `/bin/sh`; an empty body runs the image's ENTRYPOINT/CMD.
use std::process::Stdio;
use serde::Deserialize;
use serde_json::{json, value::RawValue};
use sqlx::types::Json;
use tokio::process::Command;
use windmill_common::{client::AuthedClient, scripts::ScriptLang};
use windmill_common::{
error::Error,
worker::{to_raw_value, write_file, Connection},
};
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_args_map, get_reserved_variables, raw_to_string, resolve_nsjail_timeout,
resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
handle_child::handle_child,
DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB,
SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY,
SANDBOX_REGISTRY_AUTH,
};
const NSJAIL_CONFIG_RUN_DOCKER_CONTENT: &str = include_str!("../nsjail/run.docker.config.proto");
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
lazy_static::lazy_static! {
/// `crane` (google/go-containerregistry) — pulls + flattens an image to a rootfs
/// without a daemon, store, root, or privileged container. We never *run* the
/// image via crane (nsjail does the run), so a full container engine is overkill.
pub static ref CRANE_PATH: String =
std::env::var("CRANE_PATH").unwrap_or_else(|_| "crane".to_string());
/// `linux/<arch>` for the worker, pinned on every crane call so multi-arch images
/// resolve deterministically (and `crane manifest` returns a real manifest, not an
/// index).
static ref CRANE_PLATFORM: String = format!("linux/{}", match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
});
/// Content-addressed cache of flattened rootfs tars, keyed by image digest. crane
/// has no persistent store, so this is what gives cross-job dedup (and, since it's
/// digest-keyed, automatic freshness when a moving tag changes).
static ref ROOTFS_CACHE_DIR: String =
format!("{}sandbox_rootfs", *windmill_common::worker::ROOT_CACHE_DIR);
}
/// Guards against overlapping cache-eviction passes across concurrent jobs.
static EVICTION_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// `sandbox_image_pull_policy` instance setting. With the digest-keyed cache, `newer`
/// (default) re-resolves the digest each job (cheap manifest fetch) so moving tags
/// like `:latest` stay fresh while unchanged digests reuse the cache. `missing` skips
/// the registry when a digest is already cached for the ref; `never` only uses the
/// cache (errors if absent); `always` == `newer` here.
async fn pull_policy() -> String {
let p = SANDBOX_IMAGE_PULL_POLICY.read().await.clone();
match p.as_deref() {
Some(p @ ("missing" | "newer" | "always" | "never")) => p.to_string(),
_ => "newer".to_string(),
}
}
/// `sandbox_image_max_size_mb` instance setting; 0 (or unset/non-positive) = no limit.
async fn max_image_size_mb() -> u64 {
SANDBOX_IMAGE_MAX_SIZE_MB.read().await.unwrap_or(0).max(0) as u64
}
/// `sandbox_image_cache_max_mb` instance setting; 0 (or unset/non-positive) = unbounded.
async fn image_cache_max_mb() -> u64 {
SANDBOX_IMAGE_CACHE_MAX_MB.read().await.unwrap_or(0).max(0) as u64
}
/// A ref is registry-qualified if the component before the first `/` looks like a
/// host (contains `.` or `:`, or is `localhost`). Bare repos (`alpine`,
/// `alpine:latest`, `myorg/img`) are unqualified and resolve against docker.io —
/// or the configured default registry.
fn registry_qualified(image: &str) -> bool {
match image.split_once('/') {
None => false,
Some((first, _)) => first.contains('.') || first.contains(':') || first == "localhost",
}
}
/// Prepend the `sandbox_image_default_registry` instance setting to unqualified image
/// refs (fully-qualified refs are left untouched).
async fn resolve_image_ref(image: &str) -> String {
let registry = SANDBOX_IMAGE_DEFAULT_REGISTRY.read().await.clone();
match registry {
Some(registry) if !registry.trim().is_empty() && !registry_qualified(image) => {
format!("{}/{}", registry.trim().trim_end_matches('/'), image)
}
_ => image.to_string(),
}
}
/// If the `sandbox_registry_auth` instance setting holds a docker `auth.json` blob,
/// write it to a per-job `DOCKER_CONFIG` dir (`{job_dir}/.docker/config.json`, 0600,
/// removed with the job) and return the dir to pass to crane via `DOCKER_CONFIG`.
/// Returns `None` when unset. (docker `config.json` and podman `auth.json` share the
/// `{"auths": {...}}` schema, so the same blob works.)
async fn write_auth_dir(job_dir: &str) -> Result<Option<String>, Error> {
let auth = SANDBOX_REGISTRY_AUTH.read().await.clone();
let Some(auth) = auth.filter(|a| !a.trim().is_empty()) else {
return Ok(None);
};
let dir = format!("{job_dir}/.docker");
tokio::fs::create_dir_all(&dir).await?;
let path = format!("{dir}/config.json");
// Create 0600 from the start (registry credentials) — no world-readable window.
#[cfg(unix)]
{
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.await?;
f.write_all(auth.as_bytes()).await?;
}
#[cfg(not(unix))]
tokio::fs::write(&path, auth).await?;
Ok(Some(dir))
}
/// The subset of an image's OCI config we apply to the run.
#[derive(Deserialize, Default, Debug)]
struct OciConfig {
#[serde(default, rename = "Env")]
env: Option<Vec<String>>,
#[serde(default, rename = "Cmd")]
cmd: Option<Vec<String>>,
#[serde(default, rename = "Entrypoint")]
entrypoint: Option<Vec<String>>,
#[serde(default, rename = "WorkingDir")]
working_dir: Option<String>,
}
/// Quote a string as a protobuf-text-format string literal for safe inclusion in
/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets,
/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or
/// newline would otherwise let a hostile image config inject arbitrary nsjail
/// directives and break out of the sandbox. Every byte is emitted as a printable
/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit
/// octal `\NNN` for control/non-ASCII bytes), so the result always parses.
fn proto_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for &b in s.as_bytes() {
match b {
b'"' => out.push_str("\\\""),
b'\\' => out.push_str("\\\\"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
0x20..=0x7e => out.push(b as char),
_ => out.push_str(&format!("\\{b:03o}")),
}
}
out.push('"');
out
}
/// Render container env vars as nsjail `envar:` directives (one per line). Each
/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break
/// the config nor reach nsjail's own process environment.
fn render_envars(env: &[(String, String)]) -> String {
env.iter()
.map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}"))))
.collect::<Vec<_>>()
.join("\n")
}
/// Run `crane` with the optional per-job `DOCKER_CONFIG` auth dir.
async fn crane(args: &[&str], auth_dir: Option<&str>) -> Result<std::process::Output, Error> {
let mut cmd = Command::new(CRANE_PATH.as_str());
cmd.args(args);
if let Some(dir) = auth_dir {
cmd.env("DOCKER_CONFIG", dir);
}
cmd.output()
.await
.map_err(|e| Error::ExecutionErr(format!("failed to run crane {}: {e}", args.join(" "))))
}
/// `crane config` output: the image config (Env/Cmd/Entrypoint/WorkingDir) is nested
/// under the top-level `config` key.
#[derive(Deserialize, Default)]
struct CraneConfig {
#[serde(default)]
config: OciConfig,
}
/// Filesystem-safe cache key for a digest (`sha256:ab..` -> `sha256_ab..`).
fn digest_key(digest: &str) -> String {
digest.replace([':', '/'], "_")
}
/// Filesystem-safe, collision-resistant key for an image ref (the ref->digest file).
fn ref_key(image: &str) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
image.hash(&mut h);
let safe: String = image
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
let safe = &safe[safe.len().saturating_sub(80)..];
format!("{safe}_{:016x}", h.finish())
}
/// Resolve the image ref to a content digest, honoring the pull policy + a ref->digest
/// cache. `missing`/`never` reuse a cached digest without hitting the registry (`never`
/// errors if absent); `newer`/`always` always re-resolve via `crane digest`.
async fn resolve_digest(
image: &str,
policy: &str,
auth_dir: Option<&str>,
) -> Result<String, Error> {
let refs_dir = format!("{}/refs", *ROOTFS_CACHE_DIR);
let ref_file = format!("{refs_dir}/{}", ref_key(image));
if matches!(policy, "missing" | "never") {
if let Ok(d) = tokio::fs::read_to_string(&ref_file).await {
let d = d.trim().to_string();
if !d.is_empty()
&& tokio::fs::metadata(format!("{}/{}.tar", *ROOTFS_CACHE_DIR, digest_key(&d)))
.await
.is_ok()
{
return Ok(d);
}
}
if policy == "never" {
return Err(Error::ExecutionErr(format!(
"image {image} is not in the sandbox cache and SANDBOX_IMAGE_PULL_POLICY=never"
)));
}
}
let out = crane(&["digest", "--platform", &CRANE_PLATFORM, image], auth_dir).await?;
if !out.status.success() {
return Err(Error::ExecutionErr(format!(
"failed to resolve image {image}: {}",
String::from_utf8_lossy(&out.stderr)
)));
}
let digest = String::from_utf8_lossy(&out.stdout).trim().to_string();
let _ = tokio::fs::create_dir_all(&refs_dir).await;
// tmp+rename so a concurrent `missing`/`never` reader never sees a torn ref file.
let ref_tmp = format!("{ref_file}.tmp.{}", digest_key(&digest));
if tokio::fs::write(&ref_tmp, &digest).await.is_ok() {
let _ = tokio::fs::rename(&ref_tmp, &ref_file).await;
}
Ok(digest)
}
/// Pull (if not cached) and unpack `image` into `{job_dir}/rootfs`, returning its OCI
/// config. Uses `crane export`/`config` (no daemon/store/root) with a content-addressed
/// rootfs+config cache keyed by digest for cross-job dedup.
async fn extract_image(image: &str, job_dir: &str) -> Result<OciConfig, Error> {
let rootfs = format!("{job_dir}/rootfs");
tokio::fs::create_dir_all(&rootfs).await?;
tokio::fs::create_dir_all(&*ROOTFS_CACHE_DIR).await?;
let auth_dir = write_auth_dir(job_dir).await?;
let auth = auth_dir.as_deref();
let digest = resolve_digest(image, &pull_policy().await, auth).await?;
// Pin every subsequent fetch to the resolved digest, not the (mutable) tag, so the
// content can't diverge from the digest we cache under if the tag moves mid-fetch.
let pinned = format!("{}@{digest}", image.split('@').next().unwrap_or(image));
let key = digest_key(&digest);
let tar = format!("{}/{key}.tar", *ROOTFS_CACHE_DIR);
let cfg = format!("{}/{key}.json", *ROOTFS_CACHE_DIR);
let size_file = format!("{}/{key}.size", *ROOTFS_CACHE_DIR);
let token = std::path::Path::new(job_dir)
.file_name()
.map(|x| x.to_string_lossy().into_owned())
.unwrap_or_default();
// Enforce the size cap on EVERY job (not just cache misses), using a cached size so
// a cache reuse needs no registry call — lowering the limit rejects cached images too.
enforce_image_size_limit(&pinned, &size_file, auth).await?;
// Materialize the flattened rootfs. The cache tar can be evicted concurrently, so up
// to two attempts: hardlink the cache tar into the job dir (pins the inode against
// eviction) before extracting; if it vanished first, re-fetch.
let job_tar = format!("{job_dir}/rootfs.tar");
for attempt in 0..2 {
if tokio::fs::metadata(&tar).await.is_err() {
fetch_into_cache(&pinned, &tar, &cfg, &token, auth).await?;
}
let config = read_oci_config(&cfg).await;
let _ = tokio::fs::remove_file(&job_tar).await;
// Stage the cache tar into the job dir so concurrent eviction can't unlink it out
// from under `tar -xf`. Prefer a hardlink (free), but the cache volume and the job
// dir are usually on *different* filesystems in the shipped deployments (the cache
// is its own volume/PVC) — there `hard_link` returns EXDEV, so fall back to a copy.
// `copy` reads through the source inode, so an eviction mid-copy still completes.
let staged = match tokio::fs::hard_link(&tar, &job_tar).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(e), // vanished — re-fetch
Err(_) => tokio::fs::copy(&tar, &job_tar).await.map(|_| ()),
};
match staged {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempt == 0 => {
continue; // evicted between the check and the staging — re-fetch
}
Err(e) => return Err(Error::ExecutionErr(format!("failed to stage rootfs: {e}"))),
}
// Extract as the worker user (rootfs is worker-owned → uid 0 inside the jail).
let untar = Command::new("tar")
.args(["-xf", &job_tar, "-C", &rootfs])
.output()
.await
.map_err(|e| Error::ExecutionErr(format!("failed to run tar: {e}")))?;
let _ = tokio::fs::remove_file(&job_tar).await;
if !untar.status.success() {
return Err(Error::ExecutionErr(format!(
"failed to unpack image {image}: {}",
String::from_utf8_lossy(&untar.stderr)
)));
}
return Ok(config);
}
Err(Error::ExecutionErr(format!(
"failed to materialize rootfs for {image} (cache evicted twice)"
)))
}
/// Fetch + flatten `pinned` (a `name@digest` ref) into the cache: export the rootfs tar
/// and write the OCI config sidecar, both via tmp+rename so concurrent readers never see
/// a torn file. The tar is published last (a present tar implies a present config).
async fn fetch_into_cache(
pinned: &str,
tar: &str,
cfg: &str,
token: &str,
auth: Option<&str>,
) -> Result<(), Error> {
let tar_tmp = format!("{tar}.tmp.{token}");
let cfg_tmp = format!("{cfg}.tmp.{token}");
let exported = crane(
&["export", "--platform", &CRANE_PLATFORM, pinned, &tar_tmp],
auth,
)
.await?;
if !exported.status.success() {
let _ = tokio::fs::remove_file(&tar_tmp).await;
return Err(Error::ExecutionErr(format!(
"failed to export image {pinned}: {}",
String::from_utf8_lossy(&exported.stderr)
)));
}
let config = crane(&["config", "--platform", &CRANE_PLATFORM, pinned], auth).await?;
if !config.status.success() {
let _ = tokio::fs::remove_file(&tar_tmp).await;
return Err(Error::ExecutionErr(format!(
"failed to read image {pinned} config: {}",
String::from_utf8_lossy(&config.stderr)
)));
}
let _ = tokio::fs::write(&cfg_tmp, &config.stdout).await;
let _ = tokio::fs::rename(&cfg_tmp, cfg).await;
tokio::fs::rename(&tar_tmp, tar).await?;
Ok(())
}
/// Read the cached OCI config (Env/Cmd/Entrypoint/WorkingDir); tolerate a missing or torn
/// sidecar by falling back to defaults (the run still works off the body + image FS).
async fn read_oci_config(cfg: &str) -> OciConfig {
match tokio::fs::read(cfg).await {
Ok(bytes) => serde_json::from_slice::<CraneConfig>(&bytes)
.map(|c| c.config)
.unwrap_or_default(),
Err(_) => OciConfig::default(),
}
}
/// Manifest descriptor (`crane manifest`), for the pre-download size guard.
#[derive(Deserialize, Default)]
struct CraneDescriptor {
#[serde(default)]
size: u64,
}
#[derive(Deserialize, Default)]
struct CraneManifest {
#[serde(default)]
layers: Vec<CraneDescriptor>,
#[serde(default)]
config: CraneDescriptor,
}
/// Reject the image if its compressed download size exceeds `SANDBOX_IMAGE_MAX_SIZE_MB`.
/// Runs on EVERY job (so lowering the limit rejects already-cached images too); the size
/// is read from a `{digest}.size` sidecar when present (no registry call on cache reuse)
/// and otherwise fetched once via `crane manifest` (before any layer download) and cached.
/// No-op when the limit is 0 (unset).
async fn enforce_image_size_limit(
pinned: &str,
size_file: &str,
auth_dir: Option<&str>,
) -> Result<(), Error> {
let max = max_image_size_mb().await;
if max == 0 {
return Ok(());
}
let bytes = match tokio::fs::read_to_string(size_file)
.await
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
{
Some(b) => b,
None => {
let out = crane(
&["manifest", "--platform", &CRANE_PLATFORM, pinned],
auth_dir,
)
.await?;
if !out.status.success() {
// Don't silently bypass the guard — surface it so an operator can see the
// size limit isn't being enforced for this image.
tracing::warn!(
"sandbox image size guard: `crane manifest {pinned}` failed, not enforcing \
SANDBOX_IMAGE_MAX_SIZE_MB: {}",
String::from_utf8_lossy(&out.stderr)
);
return Ok(());
}
let manifest: CraneManifest = match serde_json::from_slice(&out.stdout) {
Ok(m) => m,
Err(e) => {
tracing::warn!(
"sandbox image size guard: cannot parse `crane manifest` json: {e}"
);
return Ok(());
}
};
let b = manifest.config.size + manifest.layers.iter().map(|l| l.size).sum::<u64>();
let _ = tokio::fs::write(size_file, b.to_string()).await;
b
}
};
let mb = bytes / 1_000_000;
if mb > max {
return Err(Error::ExecutionErr(format!(
"image {pinned} is {mb} MB (compressed), over the SANDBOX_IMAGE_MAX_SIZE_MB limit of {max} MB"
)));
}
Ok(())
}
/// Best-effort eviction: while the cached rootfs tars exceed `SANDBOX_IMAGE_CACHE_MAX_MB`,
/// remove the oldest by mtime (creation order — tars are write-once, cache hits don't
/// touch mtime). No-op when the limit is 0 (unset). Skipped if another pass is already
/// running. The per-job extracted rootfs lives in the job dir (cleaned with the job), so
/// only the content-addressed tar+config+size cache is pruned. Also sweeps orphaned
/// `*.tmp.*` files left by a crashed mid-export.
async fn enforce_image_cache_limit() {
use std::sync::atomic::Ordering;
let max_mb = image_cache_max_mb().await;
if max_mb == 0 {
return;
}
if EVICTION_RUNNING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
// Reset the guard on every exit path (incl. an early `break` or a panic), so a
// stuck flag can never permanently disable eviction until a worker restart.
struct ResetOnDrop;
impl Drop for ResetOnDrop {
fn drop(&mut self) {
EVICTION_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
let _reset = ResetOnDrop;
let max_bytes = max_mb.saturating_mul(1_000_000);
// (path, size, mtime) for every cached rootfs tar; also sweep orphaned tmp files.
async fn list_tars() -> Vec<(std::path::PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(mut rd) = tokio::fs::read_dir(&*ROOTFS_CACHE_DIR).await else {
return out;
};
while let Ok(Some(e)) = rd.next_entry().await {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
// Reclaim leftover `*.tmp.<token>` files from a crashed mid-export.
if name.contains(".tmp.") {
let _ = tokio::fs::remove_file(&p).await;
continue;
}
if p.extension().and_then(|x| x.to_str()) != Some("tar") {
continue;
}
if let Ok(m) = e.metadata().await {
let mtime = m.modified().unwrap_or(std::time::UNIX_EPOCH);
out.push((p, m.len(), mtime));
}
}
out
}
loop {
let mut tars = list_tars().await;
let total: u64 = tars.iter().map(|(_, s, _)| *s).sum();
if total <= max_bytes || tars.is_empty() {
break;
}
tars.sort_by_key(|(_, _, mtime)| *mtime);
let victim = tars[0].0.clone();
if tokio::fs::remove_file(&victim).await.is_err() {
break; // can't reclaim — stop rather than spin on the same victim
}
// Drop the sibling config + size sidecars too.
let _ = tokio::fs::remove_file(victim.with_extension("json")).await;
let _ = tokio::fs::remove_file(victim.with_extension("size")).await;
tracing::info!("sandbox image cache eviction: removed {}", victim.display());
}
// `_reset` drops here and clears EVICTION_RUNNING.
}
/// Build the nsjail mount block that binds each top-level entry of the rootfs in
/// place. Binding the whole rootfs at `/` trips nsjail's read-only remount of its
/// base root in a rootless userns; per-entry binds avoid it. `proc`, `dev`, `tmp`
/// and `sys` are skipped — the profile provides them.
async fn generate_rootfs_mounts(rootfs: &str) -> Result<String, Error> {
let mut block = String::new();
let mut entries = tokio::fs::read_dir(rootfs).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let name = name.to_string_lossy();
if matches!(name.as_ref(), "proc" | "dev" | "tmp" | "sys") {
continue;
}
let src = proto_str(&format!("{rootfs}/{name}"));
let dst = proto_str(&format!("/{name}"));
let file_type = entry.file_type().await?;
if file_type.is_symlink() {
// Recreate top-level symlinks (e.g. usr-merged /bin -> usr/bin) as
// symlinks in the jail. The target is image-controlled but only ever
// *resolved inside the jail* (against the bound rootfs dirs / jail
// pseudo-fs) — there is no host `/` in the jail for it to point at — and
// it is escaped via proto_str, so it can neither escape nor inject config.
let target = tokio::fs::read_link(entry.path())
.await
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
block.push_str(&format!(
"mount {{\n src: {}\n dst: {dst}\n is_symlink: true\n mandatory: false\n}}\n",
proto_str(&target),
));
} else {
block.push_str(&format!(
"mount {{\n src: {src}\n dst: {dst}\n is_bind: true\n rw: true\n mandatory: false\n}}\n",
));
}
}
Ok(block)
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn handle_docker_v2_job(
image: &str,
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
job: &MiniPulledJob,
conn: &Connection,
client: &AuthedClient,
parent_runnable_path: Option<String>,
content: &str,
job_dir: &str,
shared_mount: &str,
base_internal_url: &str,
worker_name: &str,
occupancy_metrics: &mut OccupancyMetrics,
) -> Result<Box<RawValue>, Error> {
// The sandboxed container runtime *is* nsjail, so it requires nsjail. (`# docker`
// keeps the v1 dind path for non-sandboxed workers.)
if NSJAIL_AVAILABLE.is_none() {
return Err(Error::ExecutionErr(format!(
"`# sandbox {image}` runs the image inside nsjail, which is not available on \
this worker. Install nsjail, or use a bare `# docker` (dind) instead."
)));
}
// Apply the default-registry instance setting to unqualified refs.
let resolved_image = resolve_image_ref(image).await;
let image = resolved_image.as_str();
append_logs(
&job.id,
&job.workspace_id,
format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"),
conn,
)
.await;
let config = extract_image(image, job_dir).await?;
let rootfs = format!("{job_dir}/rootfs");
// Best-effort: keep the cached rootfs tars under their size cap (overlaps the run).
tokio::spawn(enforce_image_cache_limit());
// Resolve the script args from the bash signature, like the bash executor.
let args = build_args_map(job, client, conn).await?.map(Json);
let job_args = if args.is_some() {
args.as_ref()
} else {
job.args.as_ref()
};
let args_owned = windmill_parser_bash::parse_bash_sig(content)?
.args
.iter()
.map(|arg| {
job_args
.and_then(|x| x.get(&arg.name).map(|x| raw_to_string(x.get())))
.unwrap_or_else(String::new)
})
.collect::<Vec<String>>();
// The body is everything that isn't a leading `#` annotation/comment line. With
// a body we run it via the image's `/bin/sh`; without one we run the image's
// ENTRYPOINT + CMD.
let has_body = content
.lines()
.any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'));
let cmd_args: Vec<String> = if has_body {
// Pass the body straight to `sh -c` rather than writing a script file into
// the image-controlled rootfs: a malicious image could plant that path as a
// symlink to a host file and capture the worker's write before nsjail starts
// (sandbox-boundary bypass). `sh -c <body> sh <args...>` binds args as $1.. .
let mut v = vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!("set -e\n{content}"),
"sh".to_string(),
];
v.extend(args_owned.iter().cloned());
v
} else {
let mut v = config.entrypoint.clone().unwrap_or_default();
v.extend(config.cmd.clone().unwrap_or_default());
if v.is_empty() {
return Err(Error::ExecutionErr(format!(
"image {image} has no ENTRYPOINT/CMD and the script body is empty — \
nothing to run"
)));
}
v.extend(args_owned.iter().cloned());
v
};
let working_dir = config
.working_dir
.as_deref()
.filter(|w| !w.is_empty())
.unwrap_or("/");
// The image's OCI Env is attacker-controlled (BOTH keys and values), so it must
// NOT enter the nsjail launcher's own process env: a hostile image could set
// LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT and have the dynamic loader run code in
// the nsjail binary as the worker — outside the jail — before it sandboxes.
// Deliver it to the *child only* via proto-escaped `envar:` directives.
let mut container_env: Vec<(String, String)> = Vec::new();
for kv in config.env.unwrap_or_default() {
if let Some((k, v)) = kv.split_once('=') {
container_env.push((k.to_string(), v.to_string()));
}
}
if !container_env.iter().any(|(k, _)| k == "PATH") {
container_env.push(("PATH".to_string(), DEFAULT_PATH.to_string()));
}
if !container_env.iter().any(|(k, _)| k == "HOME") {
container_env.push(("HOME".to_string(), "/root".to_string()));
}
let envars = render_envars(&container_env);
// Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir.
let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await;
let rootfs_mounts = generate_rootfs_mounts(&rootfs).await?;
write_file(
job_dir,
"run.docker.config.proto",
&NSJAIL_CONFIG_RUN_DOCKER_CONTENT
.replace("{TIMEOUT}", &nsjail_timeout)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
// proto_str-quoted: WorkingDir is image-controlled, must not break out
// of the `cwd:` string and inject nsjail directives.
.replace("{WORKDIR}", &proto_str(working_dir))
.replace("{ROOTFS_MOUNTS}", &rootfs_mounts)
.replace(
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
// `# volume` mounts + same-worker shared folder (empty if none).
.replace("{SHARED_MOUNT}", shared_mount)
// Image env as `envar:` directives (child-only), so it never touches
// nsjail's process env.
.replace("{ENVARS}", &envars)
.replace("#{DEV}", DEV_CONF_NSJAIL),
)?;
// nsjail's OWN process env: only windmill-trusted keys (reserved vars so
// `wmill`/API calls work, + proxy). `keep_env: true` forwards these to the
// child. The image env is NOT here — see container_env above.
let mut reserved_variables =
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
reserved_variables.insert("RUST_LOG".to_string(), "info".to_string());
reserved_variables.insert(
"BASE_INTERNAL_URL".to_string(),
base_internal_url.to_string(),
);
let proxy_envs = get_proxy_envs_for_lang(
&ScriptLang::Bash,
job.kind,
&job.id,
&job.workspace_id,
conn,
)
.await?;
let mut nsjail_run_args = vec!["--config", "run.docker.config.proto", "--"];
nsjail_run_args.extend(cmd_args.iter().map(|s| s.as_str()));
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
nsjail_cmd
.current_dir(job_dir)
.env_clear()
.envs(reserved_variables)
.envs(proxy_envs)
.args(nsjail_run_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?;
handle_child(
&job.id,
conn,
mem_peak,
canceled_by,
child,
true,
worker_name,
&job.workspace_id,
"sandboxed container run",
job.timeout,
true,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
Ok(to_raw_value(&json!(format!(
"sandboxed container ({image}) completed successfully"
))))
}
#[cfg(test)]
mod tests {
use super::{digest_key, proto_str, ref_key, registry_qualified, render_envars};
#[test]
fn digest_key_is_filesystem_safe() {
assert_eq!(digest_key("sha256:4d889c14e7d5"), "sha256_4d889c14e7d5");
// No `:` or `/` survives (both would break the cache filename).
let k = digest_key("sha256:ab/cd:ef");
assert!(!k.contains(':') && !k.contains('/'));
}
#[test]
fn ref_key_is_safe_and_stable() {
// Deterministic for a given ref...
assert_eq!(ref_key("ghcr.io/o/i:tag"), ref_key("ghcr.io/o/i:tag"));
// ...distinguishes different refs...
assert_ne!(ref_key("alpine:latest"), ref_key("alpine:edge"));
// ...and is filesystem-safe (no `/` or `:`), incl. for multibyte refs (no panic
// on the trailing-80 byte slice since every char maps to single-byte ASCII).
for r in [
"alpine",
"ghcr.io/o/i:tag",
"localhost:5000/r@sha256:ab",
"rég/imagé:tag",
] {
let k = ref_key(r);
assert!(!k.contains('/') && !k.contains(':'));
}
}
#[test]
fn render_envars_emits_proto_directives() {
// Image-controlled env (incl. loader vars) is rendered as `envar:` directives
// — i.e. delivered to the child via the config, NOT nsjail's process env, so
// it can never set LD_PRELOAD/etc. on the nsjail binary itself.
let env = vec![
("PATH".to_string(), "/usr/bin".to_string()),
("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()),
];
let out = render_envars(&env);
assert_eq!(
out,
"envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\""
);
// A value trying to inject extra directives is escaped, not interpreted.
let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())];
let line = render_envars(&evil);
assert!(line.starts_with("envar: \""));
assert!(!line.contains("\nclone_newuser"));
assert!(line.contains("\\n"));
}
#[test]
fn proto_str_escapes_injection() {
// Normal paths are just wrapped in quotes.
assert_eq!(proto_str("/app"), "\"/app\"");
// A `"` is escaped so it cannot close the surrounding string and inject
// subsequent nsjail directives — this is what the WorkingDir / mount-src
// sandboxing fixes depend on.
let malicious = "/x\"\nmount { src: \"/\" dst: \"/host\" is_bind: true }\n#";
let escaped = proto_str(malicious);
assert!(escaped.starts_with('"') && escaped.ends_with('"'));
// No raw quote or newline survives inside the rendered literal.
let inner = &escaped[1..escaped.len() - 1];
assert!(!inner.contains('\n'));
assert!(!inner.contains("\"") || inner.contains("\\\""));
assert!(escaped.contains("\\\"")); // the inner quote is backslash-escaped
assert!(escaped.contains("\\n")); // the newline is escaped
// Control and non-ASCII bytes render as valid 3-digit octal escapes (never
// a raw byte or an invalid `\u{..}` that nsjail's parser would reject).
assert_eq!(proto_str("a\u{1b}b"), "\"a\\033b\""); // ESC (0x1b)
assert_eq!(proto_str("é"), "\"\\303\\251\""); // UTF-8 bytes 0xc3 0xa9
}
#[test]
fn registry_qualified_classifies_refs() {
// Unqualified: bare repos (with/without tag) and docker.io org/repo.
for img in ["alpine", "alpine:latest", "myorg/img", "myorg/img:1.2"] {
assert!(!registry_qualified(img), "{img} should be unqualified");
}
// Qualified: the first path component is a host (has `.`/`:`) or localhost.
for img in [
"ghcr.io/org/img",
"registry.example.com/img:tag",
"localhost:5000/img",
"localhost/img",
"host:5000/a/b",
] {
assert!(registry_qualified(img), "{img} should be qualified");
}
}
}