OXYGENHUB
# 8bf49b95b09e oxygenflow / crates / flow / src / syscall.rs
35.1 KiB · Rust · last changed Prune build caches before they are kept 16 days ago source blame history raw
1
//! Tier 1 (spec §4.2, docs/m0.md §3): the syscall sandbox, for machines
2
//! without hardware virtualisation. The job runs in new user, mount, PID,
3
//! network, IPC and UTS namespaces, on the rootfs image's tree (unpacked
4
//! once per hash, read-only) with the workspace directory at `/src` and a
5
//! tmpfs at `/tmp`. Landlock limits writes to `/src`, `/tmp` and `/dev`; a
6
//! seccomp filter refuses sockets other than `AF_UNIX` and the calls that
7
//! reach past the sandbox (`ptrace`, `mount`, `bpf`, `perf_event_open`,
8
//! keys, modules, io_uring); and the sandbox's CPU and memory limits apply
9
//! through a cgroup v2 group of the runner's own delegated cgroup. Without
10
//! that delegation the tier is not offered, so no job runs unbounded.
11
//!
12
//! The runner starts `flow __sandbox <spec>` (this same binary, through
13
//! `/proc/self/exe`) for each job. That helper joins the job's cgroup,
14
//! makes the namespaces and forks the sandbox's PID 1, which builds the new
15
//! root, pivots into it and runs the job as an unprivileged user, the way
16
//! `flow-init` does in a microVM. Records, receipts and artifacts are the
17
//! same as tier 2's.
18
19
use crate::guest::{dev_links, job_env, loopback_up, resolve_program, PATH};
20
use crate::home::Home;
21
use crate::microvm::{Outcome, Sink};
22
use anyhow::{bail, Context, Result};
23
use flow_core::api::{Record, SecretInput};
24
use flow_core::{Environment, Job, MountSource, Network, Sandbox};
25
use serde::{Deserialize, Serialize};
26
use std::ffi::CString;
27
use std::io::{BufRead, BufReader, Read};
28
use std::path::{Path, PathBuf};
29
use std::sync::{mpsc, OnceLock};
30
use std::time::{Duration, Instant};
31
32
/// The user and group a job runs as inside the sandbox; the runner's own
33
/// on the host.
34
const JOB_ID: u32 = 1000;
35
36
/// The receipt environment for a job in this tier: the sandbox is this
37
/// binary, so its hash stands where tier 2 has Firecracker's and
38
/// flow-init's, with the host kernel's release.
39
pub fn environment(sandbox: &Sandbox, job: &Job) -> Environment {
40
    Environment {
41
        runtime: "syscall".into(),
42
        parts: vec![
43
            ("flow".into(), self_hash().to_string()),
44
            ("kernel".into(), kernel_release()),
45
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
46
            ("cpus".into(), sandbox.cpus.to_string()),
47
        ]
48
        .into_iter()
49
        .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib))))
50
        .collect(),
51
        rootfs: sandbox.rootfs.clone(),
52
        argv: job.exec.clone(),
53
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
54
    }
55
}
56
57
fn self_hash() -> &'static str {
58
    static HASH: OnceLock<String> = OnceLock::new();
59
    HASH.get_or_init(|| crate::home::hash_file(Path::new("/proc/self/exe")).unwrap_or_else(|_| "unknown".into()))
60
}
61
62
fn kernel_release() -> String {
63
    std::fs::read_to_string("/proc/sys/kernel/osrelease").map(|s| s.trim().to_string()).unwrap_or_else(|_| "unknown".into())
64
}
65
66
/// Whether this machine can run the tier now: a cgroup this runner may
67
/// manage, and namespaces, Landlock and seccomp that work for this binary
68
/// (Ubuntu's AppArmor can deny unprivileged user namespaces to programs
69
/// without a profile). `Err` says what is missing.
70
pub fn available() -> std::result::Result<(), String> {
71
    Cgroups::delegated()?;
72
    let out = std::process::Command::new("/proc/self/exe")
73
        .arg("__sandbox-probe")
74
        .stdin(std::process::Stdio::null())
75
        .output()
76
        .map_err(|e| format!("cannot start the probe: {e}"))?;
77
    if out.status.success() {
78
        Ok(())
79
    } else {
80
        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
81
    }
82
}
83
84
/// `flow __sandbox-probe`: try each kernel feature the sandbox uses, in a
85
/// throwaway process, and say which one failed.
86
pub fn probe_main() -> i32 {
87
    let r = (|| -> std::result::Result<(), String> {
88
        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
89
        enter_namespaces(libc::CLONE_NEWUSER | libc::CLONE_NEWNS, uid, gid)
90
            .map_err(|e| format!("user namespaces are not allowed for this program ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 denies them"))?;
91
        mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")
92
            .and_then(|_| mount("tmpfs", "/tmp", "tmpfs", libc::MS_NOSUID | libc::MS_NODEV, ""))
93
            .map_err(|e| {
94
                format!(
95
                    "user namespaces give no mount rights here ({e}); on Ubuntu, kernel.apparmor_restrict_unprivileged_userns=1 does this"
96
                )
97
            })?;
98
        let abi = landlock_abi();
99
        if abi < 1 {
100
            return Err("Landlock is not enabled in this kernel (it must be in the lsm= list)".into());
101
        }
102
        no_new_privs()?;
103
        seccomp(&[]).map_err(|e| format!("seccomp filters are not available: {e}"))
104
    })();
105
    match r {
106
        Ok(()) => 0,
107
        Err(e) => {
108
            eprintln!("{e}");
109
            1
110
        }
111
    }
112
}
113
114
/// What the helper reads from the spec file the runner wrote (0600, in
115
/// the job's scratch directory, which the job never sees); it removes it
116
/// once read.
117
#[derive(Serialize, Deserialize)]
118
struct Spec {
119
    /// The image's unpacked tree.
120
    rootfs: PathBuf,
121
    /// An empty directory to build the new root on.
122
    root: PathBuf,
123
    workspace: PathBuf,
124
    /// The job's cgroup, joined before anything else.
125
    cgroup: PathBuf,
126
    argv: Vec<String>,
127
    env: Vec<(String, String)>,
128
    mounts: Vec<MountSpec>,
129
    loopback: bool,
130
    secrets: Vec<(String, String)>,
131
}
132
133
#[derive(Serialize, Deserialize)]
134
struct MountSpec {
135
    target: String,
136
    size_mib: u32,
137
    /// A host directory bound at `target` (`ephemeral://disk`); a tmpfs
138
    /// of `size_mib` when there is none.
139
    dir: Option<PathBuf>,
140
}
141
142
/// Run `job` with `workspace` (a directory) as `/src`. `scratch` is a
143
/// directory this call may fill; `name` names the job's cgroup.
144
#[allow(clippy::too_many_arguments)]
145
pub fn run(
146
    home: &Home,
147
    sandbox: &Sandbox,
148
    job: &Job,
149
    secrets: &[SecretInput],
150
    workspace: &Path,
151
    scratch: &Path,
152
    timeout: Duration,
153
    sink: &mut dyn Sink,
154
    name: &str,
155
) -> Result<Outcome> {
156
    let cgroups = Cgroups::delegated().map_err(|e| anyhow::anyhow!("the syscall tier cannot run here: {e}"))?;
157
    let image = home.rootfs(&sandbox.rootfs);
158
    if !image.is_file() {
159
        bail!("rootfs {} is not on this runner (import it with `flow rootfs import`)", sandbox.rootfs);
160
    }
161
    let image = home.verified(&image, &sandbox.rootfs)?;
162
    let rootfs = unpacked(home, &sandbox.rootfs, &image)?;
163
    let environment = environment(sandbox, job);
164
165
    let mut mounts = Vec::new();
166
    for (i, m) in job.mounts.iter().enumerate() {
167
        let dir = match m.source {
168
            MountSource::Tmpfs => None,
169
            MountSource::Disk => {
170
                let d = disk_dir(scratch, job.mounts.iter().take(i).filter(|m| m.source == MountSource::Disk).count());
171
                std::fs::create_dir_all(&d)?;
172
                Some(d)
173
            }
174
        };
175
        mounts.push(MountSpec { target: m.target.clone(), size_mib: m.size_mib, dir });
176
    }
177
    let root = scratch.join("root");
178
    std::fs::create_dir_all(&root)?;
179
    let group = cgroups.job(name, sandbox.memory_mib, sandbox.cpus)?;
180
    let spec = Spec {
181
        rootfs,
182
        root,
183
        workspace: workspace.to_path_buf(),
184
        cgroup: group.dir.clone(),
185
        argv: job.exec.clone(),
186
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
187
        mounts,
188
        loopback: sandbox.network == Network::Loopback,
189
        secrets: secrets.iter().map(|s| (s.path.clone(), s.value.clone())).collect(),
190
    };
191
    let spec_path = scratch.join("sandbox.json");
192
    {
193
        use std::io::Write;
194
        use std::os::unix::fs::OpenOptionsExt;
195
        let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&spec_path)?;
196
        f.write_all(&serde_json::to_vec(&spec)?)?;
197
    }
198
199
    let started = Instant::now();
200
    let mut child = std::process::Command::new("/proc/self/exe")
201
        .arg("__sandbox")
202
        .arg(&spec_path)
203
        .env_clear()
204
        .current_dir(scratch)
205
        .stdin(std::process::Stdio::null())
206
        .stdout(std::process::Stdio::piped())
207
        .stderr(std::process::Stdio::piped())
208
        .spawn()
209
        .context("start the sandbox")?;
210
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
211
    let out = child.stdout.take().unwrap();
212
    let err = child.stderr.take().unwrap();
213
    let tx2 = tx.clone();
214
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
215
    let mut failed_to_start = false;
216
    let mut timed_out = false;
217
    let mut cancelled = false;
218
    let deadline = started + timeout;
219
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
220
    let stop = |child: &mut std::process::Child| {
221
        let _ = child.kill();
222
        group.kill();
223
    };
224
    // The sandbox helper's peak resident memory (oxygenflow#41): the job's
225
    // own processes are its children, so this is a floor, not the whole.
226
    let mut peak_memory_mib = None;
227
    loop {
228
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
229
            peak_memory_mib = Some(m);
230
        }
231
        match rx.recv_timeout(Duration::from_millis(200)) {
232
            Ok((stdout, line)) => {
233
                let text = String::from_utf8_lossy(&line).into_owned();
234
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
235
                match text.strip_prefix(FAILED) {
236
                    Some(why) if !stdout => {
237
                        failed_to_start = true;
238
                        sink.record(rec("flow", &format!("the sandbox failed to start the job: {why}")))
239
                    }
240
                    _ => sink.record(rec(if stdout { "out" } else { "err" }, &text)),
241
                }
242
            }
243
            Err(mpsc::RecvTimeoutError::Timeout) => {}
244
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
245
        }
246
        if !timed_out && !cancelled && sink.cancelled() {
247
            cancelled = true;
248
            stop(&mut child);
249
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
250
        }
251
        if Instant::now() >= deadline && !timed_out && !cancelled {
252
            timed_out = true;
253
            stop(&mut child);
254
            sink.record(Record {
255
                t: t(&started),
256
                stream: "flow".into(),
257
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
258
            });
259
        }
260
    }
261
    let status = child.wait()?;
262
    for r in readers {
263
        let _ = r.join();
264
    }
265
    if group.out_of_memory() {
266
        sink.record(Record {
267
            t: t(&started),
268
            stream: "flow".into(),
269
            text: format!("the job ran out of memory: the sandbox allows {} MiB", sandbox.memory_mib),
270
        });
271
    }
272
    let exit_code = status.code();
273
    if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start {
274
        sink.record(Record {
275
            t: t(&started),
276
            stream: "flow".into(),
277
            text: format!("the sandbox ended without the job's exit code ({status})"),
278
        });
279
    }
280
    drop(group);
281
    let _ = std::fs::remove_file(&spec_path);
282
    Ok(Outcome {
283
        exit_code: if timed_out || cancelled { None } else { exit_code },
284
        duration_ms: t(&started),
285
        environment,
286
        timed_out,
287
        cancelled,
288
        peak_memory_mib,
289
        pruned: Vec::new(),
290
    })
291
}
292
293
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
294
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
295
    scratch.join(format!("mount-{n}"))
296
}
297
298
/// How the helper says the job never started, on its stderr.
299
const FAILED: &str = "\u{1e}F ";
300
301
fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
302
    let mut r = BufReader::new(r);
303
    let mut buf = Vec::new();
304
    loop {
305
        buf.clear();
306
        // A long line is cut at 16 KiB, as flow-init cuts it.
307
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
308
            Ok(0) | Err(_) => break,
309
            Ok(_) => {
310
                if buf.last() == Some(&b'\n') {
311
                    buf.pop();
312
                }
313
                if tx.send((stdout, buf.clone())).is_err() {
314
                    break;
315
                }
316
            }
317
        }
318
    }
319
}
320
321
/// The image's tree, unpacked from its ext4 file once per hash with
322
/// debugfs (no mount rights needed). Files belong to the runner; set-id
323
/// bits and device nodes are dropped, which a sandboxed job cannot use
324
/// anyway.
325
fn unpacked(home: &Home, hash: &str, image: &Path) -> Result<PathBuf> {
326
    let trees = home.root.join("trees");
327
    let dir = trees.join(hash);
328
    if dir.is_dir() {
329
        return Ok(dir);
330
    }
331
    std::fs::create_dir_all(&trees)?;
332
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
333
    let tmp = trees.join(format!(".{hash}.{}.{nanos}", std::process::id()));
334
    std::fs::create_dir_all(&tmp)?;
335
    let out = std::process::Command::new("debugfs")
336
        .arg("-R")
337
        .arg(format!("rdump / \"{}\"", tmp.display()))
338
        .arg(image)
339
        .output()
340
        .context("run debugfs (e2fsprogs) to unpack the rootfs")?;
341
    let empty = std::fs::read_dir(&tmp).map(|mut d| d.next().is_none()).unwrap_or(true);
342
    if !out.status.success() || empty {
343
        let _ = crate::workspace::remove_tree(&tmp);
344
        bail!("unpacking rootfs {}: {}", &hash[..12.min(hash.len())], String::from_utf8_lossy(&out.stderr).trim());
345
    }
346
    match std::fs::rename(&tmp, &dir) {
347
        Ok(()) => Ok(dir),
348
        // Another job unpacked it first.
349
        Err(_) if dir.is_dir() => {
350
            let _ = crate::workspace::remove_tree(&tmp);
351
            Ok(dir)
352
        }
353
        Err(e) => Err(e).with_context(|| format!("keep the unpacked rootfs at {}", dir.display())),
354
    }
355
}
356
357
// ---- cgroups ----
358
359
/// The runner's own cgroup v2 group, when it may manage it (a systemd
360
/// service with `Delegate=yes`, or a user service). The runner moves
361
/// itself into a `flow-main` leaf so the group can hold job groups with
362
/// the cpu and memory controllers on.
363
#[derive(Clone)]
364
pub struct Cgroups {
365
    base: PathBuf,
366
}
367
368
/// The leaf the runner process moves itself into.
369
const MAIN_LEAF: &str = "flow-main";
370
371
impl Cgroups {
372
    pub fn delegated() -> std::result::Result<Cgroups, String> {
373
        static ONCE: OnceLock<std::result::Result<Cgroups, String>> = OnceLock::new();
374
        ONCE.get_or_init(Self::set_up).clone()
375
    }
376
377
    fn set_up() -> std::result::Result<Cgroups, String> {
378
        let own = std::fs::read_to_string("/proc/self/cgroup").map_err(|e| format!("read /proc/self/cgroup: {e}"))?;
379
        let rel = own
380
            .lines()
381
            .find_map(|l| l.strip_prefix("0::"))
382
            .ok_or("this machine does not use cgroup v2 (the unified hierarchy)")?
383
            .trim()
384
            .to_string();
385
        let mut base = Path::new("/sys/fs/cgroup").join(rel.trim_start_matches('/'));
386
        if base.file_name().is_some_and(|n| n == MAIN_LEAF) {
387
            base.pop();
388
        }
389
        let controllers = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
390
        let has = |c: &str| controllers.split_whitespace().any(|x| x == c);
391
        let delegate_hint = "run the runner as a systemd service with Delegate=yes, or as a user service; for `flow run`, use `systemd-run --user --scope -p Delegate=yes flow run …`";
392
        if !has("cpu") || !has("memory") {
393
            return Err(format!("the cgroup {} has no cpu and memory controllers to give jobs; {delegate_hint}", base.display()));
394
        }
395
        let writable =
396
            |p: &Path| CString::new(p.as_os_str().as_encoded_bytes()).is_ok_and(|c| unsafe { libc::access(c.as_ptr(), libc::W_OK) } == 0);
397
        if !writable(&base) || !writable(&base.join("cgroup.subtree_control")) || !writable(&base.join("cgroup.procs")) {
398
            return Err(format!("this runner may not manage its cgroup {}; {delegate_hint}", base.display()));
399
        }
400
        // Only this process may be in the group: anything else would block
401
        // the controllers, and nothing is changed for a shared group.
402
        let me = std::process::id().to_string();
403
        let others = std::fs::read_to_string(base.join("cgroup.procs")).unwrap_or_default();
404
        if others.lines().any(|p| p.trim() != me) {
405
            return Err(format!("the cgroup {} holds other processes; {delegate_hint}", base.display()));
406
        }
407
        let leaf = base.join(MAIN_LEAF);
408
        if !leaf.is_dir() {
409
            std::fs::create_dir(&leaf).map_err(|e| format!("make {}: {e}", leaf.display()))?;
410
        }
411
        std::fs::write(leaf.join("cgroup.procs"), "0").map_err(|e| format!("move into {}: {e}", leaf.display()))?;
412
        let want: Vec<&str> = ["cpu", "memory", "pids"].into_iter().filter(|c| has(c)).collect();
413
        let enable = want.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(" ");
414
        std::fs::write(base.join("cgroup.subtree_control"), &enable)
415
            .map_err(|e| format!("enable {enable} in {}: {e} (another process may still be in it; {delegate_hint})", base.display()))?;
416
        Ok(Cgroups { base })
417
    }
418
419
    /// A fresh group for one job, with its limits set.
420
    pub fn job(&self, name: &str, memory_mib: u32, cpus: u32) -> Result<JobGroup> {
421
        let dir = self.base.join(name);
422
        if dir.is_dir() {
423
            // Left by a runner that stopped mid-job.
424
            drop(JobGroup { dir: dir.clone() });
425
        }
426
        std::fs::create_dir(&dir).with_context(|| format!("make the job's cgroup {}", dir.display()))?;
427
        let group = JobGroup { dir };
428
        let set = |file: &str, value: String| {
429
            std::fs::write(group.dir.join(file), &value).with_context(|| format!("set {file} to {value} in {}", group.dir.display()))
430
        };
431
        set("memory.max", (u64::from(memory_mib) << 20).to_string())?;
432
        if group.dir.join("memory.swap.max").exists() {
433
            set("memory.swap.max", "0".into())?;
434
        }
435
        set("cpu.max", format!("{} 100000", u64::from(cpus.max(1)) * 100_000))?;
436
        Ok(group)
437
    }
438
}
439
440
/// One job's cgroup; dropping it kills what is left in it and removes it.
441
pub struct JobGroup {
442
    dir: PathBuf,
443
}
444
445
impl JobGroup {
446
    fn kill(&self) {
447
        let _ = std::fs::write(self.dir.join("cgroup.kill"), "1");
448
    }
449
450
    fn out_of_memory(&self) -> bool {
451
        std::fs::read_to_string(self.dir.join("memory.events"))
452
            .ok()
453
            .and_then(|s| s.lines().find_map(|l| l.strip_prefix("oom_kill ")).and_then(|n| n.trim().parse::<u64>().ok()))
454
            .is_some_and(|n| n > 0)
455
    }
456
}
457
458
impl Drop for JobGroup {
459
    fn drop(&mut self) {
460
        self.kill();
461
        for _ in 0..100 {
462
            if std::fs::remove_dir(&self.dir).is_ok() || !self.dir.exists() {
463
                return;
464
            }
465
            std::thread::sleep(Duration::from_millis(50));
466
        }
467
        eprintln!("flow: could not remove the cgroup {}", self.dir.display());
468
    }
469
}
470
471
// ---- the helper: `flow __sandbox <spec>` ----
472
473
/// The helper's entry point; returns the job's exit code (125 when the
474
/// sandbox could not start it, after a `FAILED` line on stderr).
475
pub fn sandbox_main(spec_path: &Path) -> i32 {
476
    match helper(spec_path) {
477
        Ok(code) => code,
478
        Err(e) => {
479
            eprintln!("{FAILED}{e}");
480
            125
481
        }
482
    }
483
}
484
485
fn helper(spec_path: &Path) -> std::result::Result<i32, String> {
486
    let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path).map_err(|e| format!("read the spec: {e}"))?)
487
        .map_err(|e| format!("parse the spec: {e}"))?;
488
    let _ = std::fs::remove_file(spec_path);
489
    if spec.argv.is_empty() {
490
        return Err("empty argv".into());
491
    }
492
    std::fs::write(spec.cgroup.join("cgroup.procs"), "0").map_err(|e| format!("join the job's cgroup: {e}"))?;
493
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
494
    enter_namespaces(
495
        libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWPID | libc::CLONE_NEWNET | libc::CLONE_NEWIPC | libc::CLONE_NEWUTS,
496
        uid,
497
        gid,
498
    )?;
499
    // The first child is PID 1 of the new PID namespace; everything in it
500
    // dies with it, and it dies with this helper.
501
    let pid = unsafe { libc::fork() };
502
    if pid < 0 {
503
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
504
    }
505
    if pid == 0 {
506
        let code = match init(&spec) {
507
            Ok(code) => code,
508
            Err(e) => {
509
                eprintln!("{FAILED}{e}");
510
                125
511
            }
512
        };
513
        unsafe { libc::_exit(code) };
514
    }
515
    Ok(wait_for(pid))
516
}
517
518
/// Wait for `pid`, reaping any other child on the way: its exit code, or
519
/// 128 + the signal that killed it.
520
fn wait_for(pid: libc::pid_t) -> i32 {
521
    let mut status = 0;
522
    loop {
523
        let r = unsafe { libc::waitpid(-1, &mut status, 0) };
524
        if r == pid {
525
            break;
526
        }
527
        if r < 0 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
528
            return 125;
529
        }
530
    }
531
    if libc::WIFEXITED(status) {
532
        libc::WEXITSTATUS(status)
533
    } else {
534
        128 + libc::WTERMSIG(status)
535
    }
536
}
537
538
/// New namespaces of `flags` (which include a user namespace) where
539
/// [`JOB_ID`] is the caller's `uid` and `gid`.
540
fn enter_namespaces(flags: libc::c_int, uid: u32, gid: u32) -> std::result::Result<(), String> {
541
    if unsafe { libc::unshare(flags) } != 0 {
542
        return Err(format!("unshare: {}", std::io::Error::last_os_error()));
543
    }
544
    let write = |file: &str, body: String| std::fs::write(file, body).map_err(|e| format!("write {file}: {e}"));
545
    write("/proc/self/setgroups", "deny".into())?;
546
    write("/proc/self/uid_map", format!("{JOB_ID} {uid} 1\n"))?;
547
    write("/proc/self/gid_map", format!("{JOB_ID} {gid} 1\n"))
548
}
549
550
/// PID 1 of the sandbox: build the root, enter it, run the job, wait.
551
fn init(spec: &Spec) -> std::result::Result<i32, String> {
552
    // Dies with the helper; the job's cgroup is killed at the end as well.
553
    unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) };
554
    let nosuid = libc::MS_NOSUID | libc::MS_NODEV;
555
    mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")?;
556
    let root = spec.root.to_str().ok_or("the root path is not UTF-8")?;
557
    let at = |p: &str| format!("{root}{p}");
558
    bind(spec.rootfs.to_str().ok_or("the rootfs path is not UTF-8")?, root)?;
559
    for d in ["/src", "/tmp", "/proc", "/dev"] {
560
        if !Path::new(&at(d)).is_dir() {
561
            return Err(format!("the image has no {d} directory"));
562
        }
563
    }
564
    remount_bind(root, libc::MS_RDONLY | nosuid)?;
565
    bind(spec.workspace.to_str().ok_or("the workspace path is not UTF-8")?, &at("/src"))?;
566
    mount("tmpfs", &at("/tmp"), "tmpfs", nosuid, "mode=1777")?;
567
    mount("proc", &at("/proc"), "proc", nosuid | libc::MS_NOEXEC, "")?;
568
    // A /dev of the harmless devices, bound from the host's.
569
    mount("tmpfs", &at("/dev"), "tmpfs", libc::MS_NOSUID | libc::MS_NOEXEC, "mode=755")?;
570
    for d in ["null", "zero", "full", "random", "urandom"] {
571
        let target = at(&format!("/dev/{d}"));
572
        std::fs::File::create(&target).map_err(|e| format!("make {target}: {e}"))?;
573
        bind(&format!("/dev/{d}"), &target)?;
574
    }
575
    dev_links(&at("/dev"))?;
576
    std::fs::create_dir(at("/dev/shm")).map_err(|e| format!("make /dev/shm: {e}"))?;
577
    mount("tmpfs", &at("/dev/shm"), "tmpfs", nosuid, "mode=1777")?;
578
    if Path::new(&at("/run")).is_dir() {
579
        mount("tmpfs", &at("/run"), "tmpfs", nosuid, "mode=755")?;
580
    }
581
    // Ephemeral mounts: made under the writable /src or /tmp.
582
    for m in &spec.mounts {
583
        let target = at(&m.target);
584
        std::fs::create_dir_all(&target).map_err(|e| format!("make {}: {e}", m.target))?;
585
        match &m.dir {
586
            Some(dir) => bind(dir.to_str().ok_or("a mount path is not UTF-8")?, &target)?,
587
            None => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,mode=755", m.size_mib))?,
588
        }
589
    }
590
    // Secrets: an in-memory mount of their own, each file 0400.
591
    if !spec.secrets.is_empty() {
592
        if !Path::new(&at("/run")).is_dir() {
593
            return Err("the image has no /run, where secrets are mounted".into());
594
        }
595
        let dir = at("/run/secrets");
596
        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
597
        mount("tmpfs", &dir, "tmpfs", nosuid | libc::MS_NOEXEC, "size=1m,mode=700")?;
598
        for (path, value) in &spec.secrets {
599
            let Some(rel) = path.strip_prefix("/run/secrets/") else {
600
                return Err(format!("secret path {path:?} is not under /run/secrets/"));
601
            };
602
            let full = format!("{dir}/{rel}");
603
            if let Some(parent) = Path::new(&full).parent() {
604
                std::fs::create_dir_all(parent).map_err(|e| format!("mkdir for {path}: {e}"))?;
605
            }
606
            std::fs::write(&full, value.as_bytes()).map_err(|e| format!("write {path}: {e}"))?;
607
            std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400))
608
                .map_err(|e| format!("chmod {path}: {e}"))?;
609
        }
610
    }
611
    let host = b"flow";
612
    unsafe { libc::sethostname(host.as_ptr() as *const libc::c_char, host.len()) };
613
    if spec.loopback {
614
        loopback_up()?;
615
    }
616
    // Into the new root; the old one is detached, so the host's files, the
617
    // spec and the scratch directory are out of reach.
618
    std::env::set_current_dir(root).map_err(|e| format!("cd {root}: {e}"))?;
619
    let dot = c".";
620
    if unsafe { libc::syscall(libc::SYS_pivot_root, dot.as_ptr(), dot.as_ptr()) } != 0 {
621
        return Err(format!("pivot_root: {}", std::io::Error::last_os_error()));
622
    }
623
    if unsafe { libc::umount2(dot.as_ptr(), libc::MNT_DETACH) } != 0 {
624
        return Err(format!("detach the old root: {}", std::io::Error::last_os_error()));
625
    }
626
    std::env::set_current_dir("/src").map_err(|e| format!("cd /src: {e}"))?;
627
628
    let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &spec.env);
629
    let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH);
630
    let program =
631
        cstr(&resolve_program(&spec.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", spec.argv[0]))?)?;
632
    let argv: Vec<CString> = spec.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
633
    let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?;
634
    let mut writable: Vec<String> = ["/src", "/tmp", "/dev"].into_iter().map(String::from).collect();
635
    writable.extend(spec.mounts.iter().map(|m| m.target.clone()));
636
    let pid = unsafe { libc::fork() };
637
    if pid < 0 {
638
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
639
    }
640
    if pid == 0 {
641
        // The job: no new privileges, then the walls, then the program.
642
        let confined = (|| {
643
            unsafe {
644
                let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
645
                libc::dup2(null, 0);
646
                libc::setsid();
647
            }
648
            no_new_privs()?;
649
            landlock(&writable)?;
650
            seccomp(DENIED)
651
        })();
652
        unsafe {
653
            if let Err(e) = confined {
654
                let msg = format!("{FAILED}{e}\n");
655
                libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
656
                libc::_exit(125);
657
            }
658
            let mut a: Vec<*const libc::c_char> = argv.iter().map(|c| c.as_ptr()).collect();
659
            a.push(std::ptr::null());
660
            let mut e: Vec<*const libc::c_char> = env.iter().map(|c| c.as_ptr()).collect();
661
            e.push(std::ptr::null());
662
            libc::execve(program.as_ptr(), a.as_ptr(), e.as_ptr());
663
            let msg = format!("flow: cannot run {:?}: {}\n", spec.argv[0], std::io::Error::last_os_error());
664
            libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
665
            libc::_exit(127);
666
        }
667
    }
668
    Ok(wait_for(pid))
669
}
670
671
fn cstr(s: &str) -> std::result::Result<CString, String> {
672
    CString::new(s).map_err(|_| format!("{s:?} contains NUL"))
673
}
674
675
fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> std::result::Result<(), String> {
676
    let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?);
677
    let fstype = if fstype.is_empty() { std::ptr::null() } else { f.as_ptr() };
678
    let data = if data.is_empty() { std::ptr::null() } else { d.as_ptr() as *const libc::c_void };
679
    if unsafe { libc::mount(s.as_ptr(), t.as_ptr(), fstype, flags, data) } != 0 {
680
        return Err(format!("mount {src} on {target}: {}", std::io::Error::last_os_error()));
681
    }
682
    Ok(())
683
}
684
685
fn bind(src: &str, target: &str) -> std::result::Result<(), String> {
686
    mount(src, target, "", libc::MS_BIND | libc::MS_REC, "")
687
}
688
689
/// Remount a bind mount with `flags` added. In a user namespace the
690
/// flags the mount already had are locked, so they are kept.
691
fn remount_bind(target: &str, flags: libc::c_ulong) -> std::result::Result<(), String> {
692
    let t = cstr(target)?;
693
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
694
    if unsafe { libc::statvfs(t.as_ptr(), &mut st) } != 0 {
695
        return Err(format!("statvfs {target}: {}", std::io::Error::last_os_error()));
696
    }
697
    let kept = [
698
        (libc::ST_NOSUID, libc::MS_NOSUID),
699
        (libc::ST_NODEV, libc::MS_NODEV),
700
        (libc::ST_NOEXEC, libc::MS_NOEXEC),
701
        (libc::ST_NOATIME, libc::MS_NOATIME),
702
        (libc::ST_NODIRATIME, libc::MS_NODIRATIME),
703
        (libc::ST_RELATIME, libc::MS_RELATIME),
704
    ]
705
    .into_iter()
706
    .filter(|(st_flag, _)| st.f_flag & *st_flag != 0)
707
    .fold(0, |acc, (_, ms)| acc | ms);
708
    mount("none", target, "", libc::MS_BIND | libc::MS_REMOUNT | flags | kept, "")
709
}
710
711
fn no_new_privs() -> std::result::Result<(), String> {
712
    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
713
        return Err(format!("no_new_privs: {}", std::io::Error::last_os_error()));
714
    }
715
    Ok(())
716
}
717
718
// ---- Landlock ----
719
720
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1;
721
const LANDLOCK_RULE_PATH_BENEATH: libc::c_int = 1;
722
const ACCESS_EXECUTE: u64 = 1;
723
const ACCESS_READ_FILE: u64 = 1 << 2;
724
const ACCESS_READ_DIR: u64 = 1 << 3;
725
/// Every right of Landlock ABI 1 (execute .. make_sym).
726
const ACCESS_ABI1: u64 = (1 << 13) - 1;
727
const ACCESS_REFER: u64 = 1 << 13;
728
const ACCESS_TRUNCATE: u64 = 1 << 14;
729
730
#[repr(C)]
731
struct RulesetAttr {
732
    handled_access_fs: u64,
733
}
734
735
#[repr(C, packed)]
736
struct PathBeneath {
737
    allowed_access: u64,
738
    parent_fd: i32,
739
}
740
741
/// The kernel's Landlock ABI version; below 1 when there is none.
742
fn landlock_abi() -> i64 {
743
    unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, std::ptr::null::<RulesetAttr>(), 0usize, LANDLOCK_CREATE_RULESET_VERSION) }
744
}
745
746
/// Read and execute everywhere; write only beneath `writable`.
747
fn landlock(writable: &[String]) -> std::result::Result<(), String> {
748
    let abi = landlock_abi();
749
    if abi < 1 {
750
        return Err("Landlock is not available".into());
751
    }
752
    let mut all = ACCESS_ABI1;
753
    if abi >= 2 {
754
        all |= ACCESS_REFER;
755
    }
756
    if abi >= 3 {
757
        all |= ACCESS_TRUNCATE;
758
    }
759
    let attr = RulesetAttr { handled_access_fs: all };
760
    let ruleset = unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, &attr, std::mem::size_of::<RulesetAttr>(), 0) };
761
    if ruleset < 0 {
762
        return Err(format!("landlock ruleset: {}", std::io::Error::last_os_error()));
763
    }
764
    let ruleset = ruleset as libc::c_int;
765
    let rule = |path: &str, access: u64| -> std::result::Result<(), String> {
766
        let p = cstr(path)?;
767
        let fd = unsafe { libc::open(p.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
768
        if fd < 0 {
769
            return Err(format!("open {path} for Landlock: {}", std::io::Error::last_os_error()));
770
        }
771
        let beneath = PathBeneath { allowed_access: access, parent_fd: fd };
772
        let rc = unsafe { libc::syscall(libc::SYS_landlock_add_rule, ruleset, LANDLOCK_RULE_PATH_BENEATH, &beneath, 0) };
773
        unsafe { libc::close(fd) };
774
        if rc != 0 {
775
            return Err(format!("landlock rule for {path}: {}", std::io::Error::last_os_error()));
776
        }
777
        Ok(())
778
    };
779
    rule("/", ACCESS_EXECUTE | ACCESS_READ_FILE | ACCESS_READ_DIR)?;
780
    for w in writable {
781
        rule(w, all)?;
782
    }
783
    if unsafe { libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0) } != 0 {
784
        return Err(format!("landlock restrict: {}", std::io::Error::last_os_error()));
785
    }
786
    unsafe { libc::close(ruleset) };
787
    Ok(())
788
}
789
790
// ---- seccomp ----
791
792
#[cfg(target_arch = "x86_64")]
793
const AUDIT_ARCH: u32 = 0xC000_003E;
794
#[cfg(target_arch = "aarch64")]
795
const AUDIT_ARCH: u32 = 0xC000_00B7;
796
#[cfg(target_arch = "riscv64")]
797
const AUDIT_ARCH: u32 = 0xC000_00F3;
798
799
/// Calls a job never makes: they reach past the sandbox (tracing, mounts,
800
/// kernel programs and keys, modules, kexec, swap, reboot) or around the
801
/// socket check (io_uring).
802
const DENIED: &[libc::c_long] = &[
803
    libc::SYS_ptrace,
804
    libc::SYS_process_vm_readv,
805
    libc::SYS_process_vm_writev,
806
    libc::SYS_mount,
807
    libc::SYS_umount2,
808
    libc::SYS_pivot_root,
809
    libc::SYS_open_tree,
810
    libc::SYS_move_mount,
811
    libc::SYS_fsopen,
812
    libc::SYS_fsconfig,
813
    libc::SYS_fsmount,
814
    libc::SYS_fspick,
815
    libc::SYS_bpf,
816
    libc::SYS_perf_event_open,
817
    libc::SYS_keyctl,
818
    libc::SYS_add_key,
819
    libc::SYS_request_key,
820
    libc::SYS_init_module,
821
    libc::SYS_finit_module,
822
    libc::SYS_delete_module,
823
    libc::SYS_kexec_load,
824
    libc::SYS_swapon,
825
    libc::SYS_swapoff,
826
    libc::SYS_reboot,
827
    libc::SYS_io_uring_setup,
828
    libc::SYS_io_uring_enter,
829
    libc::SYS_io_uring_register,
830
];
831
832
/// Install a filter that fails each of `denied` with EPERM and `socket`
833
/// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied`
834
/// still checks sockets). Another architecture's calls kill the process.
835
fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> {
836
    const LD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16;
837
    const JEQ: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16;
838
    const RET: u16 = (libc::BPF_RET | libc::BPF_K) as u16;
839
    let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k };
840
    let errno = |e: libc::c_int| libc::SECCOMP_RET_ERRNO | (e as u32 & libc::SECCOMP_RET_DATA);
841
    // struct seccomp_data: nr at 0, arch at 4, args[0] at 16 (its low
842
    // half on these little-endian machines).
843
    let mut f = vec![op(LD, 4, 0, 0), op(JEQ, AUDIT_ARCH, 1, 0), op(RET, libc::SECCOMP_RET_KILL_PROCESS, 0, 0), op(LD, 0, 0, 0)];
844
    #[cfg(target_arch = "x86_64")]
845
    {
846
        // x32 calls carry this bit; there are none in a 64-bit job.
847
        const JGE: u16 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16;
848
        f.push(op(JGE, 0x4000_0000, 0, 1));
849
        f.push(op(RET, errno(libc::EPERM), 0, 0));
850
    }
851
    for nr in denied {
852
        f.push(op(JEQ, *nr as u32, 0, 1));
853
        f.push(op(RET, errno(libc::EPERM), 0, 0));
854
    }
855
    f.push(op(JEQ, libc::SYS_socket as u32, 0, 3));
856
    f.push(op(LD, 16, 0, 0));
857
    f.push(op(JEQ, libc::AF_UNIX as u32, 1, 0));
858
    f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0));
859
    f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0));
860
    let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() };
861
    if unsafe { libc::syscall(libc::SYS_seccomp, libc::SECCOMP_SET_MODE_FILTER, 0, &prog) } != 0 {
862
        return Err(format!("seccomp: {}", std::io::Error::last_os_error()));
863
    }
864
    Ok(())
865
}