OXYGENHUB
# 9a6fac2b40a9 oxygenflow / crates / flow / src / syscall.rs
34.7 KiB · Rust · last changed The syscall tier: jobs without KVM 1 month ago source blame history raw
ea56c56e Andy McCall ↶ 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
    loop {
225
        match rx.recv_timeout(Duration::from_millis(200)) {
226
            Ok((stdout, line)) => {
227
                let text = String::from_utf8_lossy(&line).into_owned();
228
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
229
                match text.strip_prefix(FAILED) {
230
                    Some(why) if !stdout => {
231
                        failed_to_start = true;
232
                        sink.record(rec("flow", &format!("the sandbox failed to start the job: {why}")))
233
                    }
234
                    _ => sink.record(rec(if stdout { "out" } else { "err" }, &text)),
235
                }
236
            }
237
            Err(mpsc::RecvTimeoutError::Timeout) => {}
238
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
239
        }
240
        if !timed_out && !cancelled && sink.cancelled() {
241
            cancelled = true;
242
            stop(&mut child);
243
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
244
        }
245
        if Instant::now() >= deadline && !timed_out && !cancelled {
246
            timed_out = true;
247
            stop(&mut child);
248
            sink.record(Record {
249
                t: t(&started),
250
                stream: "flow".into(),
251
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
252
            });
253
        }
254
    }
255
    let status = child.wait()?;
256
    for r in readers {
257
        let _ = r.join();
258
    }
259
    if group.out_of_memory() {
260
        sink.record(Record {
261
            t: t(&started),
262
            stream: "flow".into(),
263
            text: format!("the job ran out of memory: the sandbox allows {} MiB", sandbox.memory_mib),
264
        });
265
    }
266
    let exit_code = status.code();
267
    if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start {
268
        sink.record(Record {
269
            t: t(&started),
270
            stream: "flow".into(),
271
            text: format!("the sandbox ended without the job's exit code ({status})"),
272
        });
273
    }
274
    drop(group);
275
    let _ = std::fs::remove_file(&spec_path);
276
    Ok(Outcome {
277
        exit_code: if timed_out || cancelled { None } else { exit_code },
278
        duration_ms: t(&started),
279
        environment,
280
        timed_out,
281
        cancelled,
282
    })
283
}
284
285
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
286
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
287
    scratch.join(format!("mount-{n}"))
288
}
289
290
/// How the helper says the job never started, on its stderr.
291
const FAILED: &str = "\u{1e}F ";
292
293
fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
294
    let mut r = BufReader::new(r);
295
    let mut buf = Vec::new();
296
    loop {
297
        buf.clear();
298
        // A long line is cut at 16 KiB, as flow-init cuts it.
299
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
300
            Ok(0) | Err(_) => break,
301
            Ok(_) => {
302
                if buf.last() == Some(&b'\n') {
303
                    buf.pop();
304
                }
305
                if tx.send((stdout, buf.clone())).is_err() {
306
                    break;
307
                }
308
            }
309
        }
310
    }
311
}
312
313
/// The image's tree, unpacked from its ext4 file once per hash with
314
/// debugfs (no mount rights needed). Files belong to the runner; set-id
315
/// bits and device nodes are dropped, which a sandboxed job cannot use
316
/// anyway.
317
fn unpacked(home: &Home, hash: &str, image: &Path) -> Result<PathBuf> {
318
    let trees = home.root.join("trees");
319
    let dir = trees.join(hash);
320
    if dir.is_dir() {
321
        return Ok(dir);
322
    }
323
    std::fs::create_dir_all(&trees)?;
324
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
325
    let tmp = trees.join(format!(".{hash}.{}.{nanos}", std::process::id()));
326
    std::fs::create_dir_all(&tmp)?;
327
    let out = std::process::Command::new("debugfs")
328
        .arg("-R")
329
        .arg(format!("rdump / \"{}\"", tmp.display()))
330
        .arg(image)
331
        .output()
332
        .context("run debugfs (e2fsprogs) to unpack the rootfs")?;
333
    let empty = std::fs::read_dir(&tmp).map(|mut d| d.next().is_none()).unwrap_or(true);
334
    if !out.status.success() || empty {
335
        let _ = crate::workspace::remove_tree(&tmp);
336
        bail!("unpacking rootfs {}: {}", &hash[..12.min(hash.len())], String::from_utf8_lossy(&out.stderr).trim());
337
    }
338
    match std::fs::rename(&tmp, &dir) {
339
        Ok(()) => Ok(dir),
340
        // Another job unpacked it first.
341
        Err(_) if dir.is_dir() => {
342
            let _ = crate::workspace::remove_tree(&tmp);
343
            Ok(dir)
344
        }
345
        Err(e) => Err(e).with_context(|| format!("keep the unpacked rootfs at {}", dir.display())),
346
    }
347
}
348
349
// ---- cgroups ----
350
351
/// The runner's own cgroup v2 group, when it may manage it (a systemd
352
/// service with `Delegate=yes`, or a user service). The runner moves
353
/// itself into a `flow-main` leaf so the group can hold job groups with
354
/// the cpu and memory controllers on.
355
#[derive(Clone)]
356
pub struct Cgroups {
357
    base: PathBuf,
358
}
359
360
/// The leaf the runner process moves itself into.
361
const MAIN_LEAF: &str = "flow-main";
362
363
impl Cgroups {
364
    pub fn delegated() -> std::result::Result<Cgroups, String> {
365
        static ONCE: OnceLock<std::result::Result<Cgroups, String>> = OnceLock::new();
366
        ONCE.get_or_init(Self::set_up).clone()
367
    }
368
369
    fn set_up() -> std::result::Result<Cgroups, String> {
370
        let own = std::fs::read_to_string("/proc/self/cgroup").map_err(|e| format!("read /proc/self/cgroup: {e}"))?;
371
        let rel = own
372
            .lines()
373
            .find_map(|l| l.strip_prefix("0::"))
374
            .ok_or("this machine does not use cgroup v2 (the unified hierarchy)")?
375
            .trim()
376
            .to_string();
377
        let mut base = Path::new("/sys/fs/cgroup").join(rel.trim_start_matches('/'));
378
        if base.file_name().is_some_and(|n| n == MAIN_LEAF) {
379
            base.pop();
380
        }
381
        let controllers = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
382
        let has = |c: &str| controllers.split_whitespace().any(|x| x == c);
383
        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 …`";
384
        if !has("cpu") || !has("memory") {
385
            return Err(format!("the cgroup {} has no cpu and memory controllers to give jobs; {delegate_hint}", base.display()));
386
        }
387
        let writable =
388
            |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);
389
        if !writable(&base) || !writable(&base.join("cgroup.subtree_control")) || !writable(&base.join("cgroup.procs")) {
390
            return Err(format!("this runner may not manage its cgroup {}; {delegate_hint}", base.display()));
391
        }
392
        // Only this process may be in the group: anything else would block
393
        // the controllers, and nothing is changed for a shared group.
394
        let me = std::process::id().to_string();
395
        let others = std::fs::read_to_string(base.join("cgroup.procs")).unwrap_or_default();
396
        if others.lines().any(|p| p.trim() != me) {
397
            return Err(format!("the cgroup {} holds other processes; {delegate_hint}", base.display()));
398
        }
399
        let leaf = base.join(MAIN_LEAF);
400
        if !leaf.is_dir() {
401
            std::fs::create_dir(&leaf).map_err(|e| format!("make {}: {e}", leaf.display()))?;
402
        }
403
        std::fs::write(leaf.join("cgroup.procs"), "0").map_err(|e| format!("move into {}: {e}", leaf.display()))?;
404
        let want: Vec<&str> = ["cpu", "memory", "pids"].into_iter().filter(|c| has(c)).collect();
405
        let enable = want.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(" ");
406
        std::fs::write(base.join("cgroup.subtree_control"), &enable)
407
            .map_err(|e| format!("enable {enable} in {}: {e} (another process may still be in it; {delegate_hint})", base.display()))?;
408
        Ok(Cgroups { base })
409
    }
410
411
    /// A fresh group for one job, with its limits set.
412
    pub fn job(&self, name: &str, memory_mib: u32, cpus: u32) -> Result<JobGroup> {
413
        let dir = self.base.join(name);
414
        if dir.is_dir() {
415
            // Left by a runner that stopped mid-job.
416
            drop(JobGroup { dir: dir.clone() });
417
        }
418
        std::fs::create_dir(&dir).with_context(|| format!("make the job's cgroup {}", dir.display()))?;
419
        let group = JobGroup { dir };
420
        let set = |file: &str, value: String| {
421
            std::fs::write(group.dir.join(file), &value).with_context(|| format!("set {file} to {value} in {}", group.dir.display()))
422
        };
423
        set("memory.max", (u64::from(memory_mib) << 20).to_string())?;
424
        if group.dir.join("memory.swap.max").exists() {
425
            set("memory.swap.max", "0".into())?;
426
        }
427
        set("cpu.max", format!("{} 100000", u64::from(cpus.max(1)) * 100_000))?;
428
        Ok(group)
429
    }
430
}
431
432
/// One job's cgroup; dropping it kills what is left in it and removes it.
433
pub struct JobGroup {
434
    dir: PathBuf,
435
}
436
437
impl JobGroup {
438
    fn kill(&self) {
439
        let _ = std::fs::write(self.dir.join("cgroup.kill"), "1");
440
    }
441
442
    fn out_of_memory(&self) -> bool {
443
        std::fs::read_to_string(self.dir.join("memory.events"))
444
            .ok()
445
            .and_then(|s| s.lines().find_map(|l| l.strip_prefix("oom_kill ")).and_then(|n| n.trim().parse::<u64>().ok()))
446
            .is_some_and(|n| n > 0)
447
    }
448
}
449
450
impl Drop for JobGroup {
451
    fn drop(&mut self) {
452
        self.kill();
453
        for _ in 0..100 {
454
            if std::fs::remove_dir(&self.dir).is_ok() || !self.dir.exists() {
455
                return;
456
            }
457
            std::thread::sleep(Duration::from_millis(50));
458
        }
459
        eprintln!("flow: could not remove the cgroup {}", self.dir.display());
460
    }
461
}
462
463
// ---- the helper: `flow __sandbox <spec>` ----
464
465
/// The helper's entry point; returns the job's exit code (125 when the
466
/// sandbox could not start it, after a `FAILED` line on stderr).
467
pub fn sandbox_main(spec_path: &Path) -> i32 {
468
    match helper(spec_path) {
469
        Ok(code) => code,
470
        Err(e) => {
471
            eprintln!("{FAILED}{e}");
472
            125
473
        }
474
    }
475
}
476
477
fn helper(spec_path: &Path) -> std::result::Result<i32, String> {
478
    let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path).map_err(|e| format!("read the spec: {e}"))?)
479
        .map_err(|e| format!("parse the spec: {e}"))?;
480
    let _ = std::fs::remove_file(spec_path);
481
    if spec.argv.is_empty() {
482
        return Err("empty argv".into());
483
    }
484
    std::fs::write(spec.cgroup.join("cgroup.procs"), "0").map_err(|e| format!("join the job's cgroup: {e}"))?;
485
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
486
    enter_namespaces(
487
        libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWPID | libc::CLONE_NEWNET | libc::CLONE_NEWIPC | libc::CLONE_NEWUTS,
488
        uid,
489
        gid,
490
    )?;
491
    // The first child is PID 1 of the new PID namespace; everything in it
492
    // dies with it, and it dies with this helper.
493
    let pid = unsafe { libc::fork() };
494
    if pid < 0 {
495
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
496
    }
497
    if pid == 0 {
498
        let code = match init(&spec) {
499
            Ok(code) => code,
500
            Err(e) => {
501
                eprintln!("{FAILED}{e}");
502
                125
503
            }
504
        };
505
        unsafe { libc::_exit(code) };
506
    }
507
    Ok(wait_for(pid))
508
}
509
510
/// Wait for `pid`, reaping any other child on the way: its exit code, or
511
/// 128 + the signal that killed it.
512
fn wait_for(pid: libc::pid_t) -> i32 {
513
    let mut status = 0;
514
    loop {
515
        let r = unsafe { libc::waitpid(-1, &mut status, 0) };
516
        if r == pid {
517
            break;
518
        }
519
        if r < 0 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
520
            return 125;
521
        }
522
    }
523
    if libc::WIFEXITED(status) {
524
        libc::WEXITSTATUS(status)
525
    } else {
526
        128 + libc::WTERMSIG(status)
527
    }
528
}
529
530
/// New namespaces of `flags` (which include a user namespace) where
531
/// [`JOB_ID`] is the caller's `uid` and `gid`.
532
fn enter_namespaces(flags: libc::c_int, uid: u32, gid: u32) -> std::result::Result<(), String> {
533
    if unsafe { libc::unshare(flags) } != 0 {
534
        return Err(format!("unshare: {}", std::io::Error::last_os_error()));
535
    }
536
    let write = |file: &str, body: String| std::fs::write(file, body).map_err(|e| format!("write {file}: {e}"));
537
    write("/proc/self/setgroups", "deny".into())?;
538
    write("/proc/self/uid_map", format!("{JOB_ID} {uid} 1\n"))?;
539
    write("/proc/self/gid_map", format!("{JOB_ID} {gid} 1\n"))
540
}
541
542
/// PID 1 of the sandbox: build the root, enter it, run the job, wait.
543
fn init(spec: &Spec) -> std::result::Result<i32, String> {
544
    // Dies with the helper; the job's cgroup is killed at the end as well.
545
    unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) };
546
    let nosuid = libc::MS_NOSUID | libc::MS_NODEV;
547
    mount("none", "/", "", libc::MS_REC | libc::MS_PRIVATE, "")?;
548
    let root = spec.root.to_str().ok_or("the root path is not UTF-8")?;
549
    let at = |p: &str| format!("{root}{p}");
550
    bind(spec.rootfs.to_str().ok_or("the rootfs path is not UTF-8")?, root)?;
551
    for d in ["/src", "/tmp", "/proc", "/dev"] {
552
        if !Path::new(&at(d)).is_dir() {
553
            return Err(format!("the image has no {d} directory"));
554
        }
555
    }
556
    remount_bind(root, libc::MS_RDONLY | nosuid)?;
557
    bind(spec.workspace.to_str().ok_or("the workspace path is not UTF-8")?, &at("/src"))?;
558
    mount("tmpfs", &at("/tmp"), "tmpfs", nosuid, "mode=1777")?;
559
    mount("proc", &at("/proc"), "proc", nosuid | libc::MS_NOEXEC, "")?;
560
    // A /dev of the harmless devices, bound from the host's.
561
    mount("tmpfs", &at("/dev"), "tmpfs", libc::MS_NOSUID | libc::MS_NOEXEC, "mode=755")?;
562
    for d in ["null", "zero", "full", "random", "urandom"] {
563
        let target = at(&format!("/dev/{d}"));
564
        std::fs::File::create(&target).map_err(|e| format!("make {target}: {e}"))?;
565
        bind(&format!("/dev/{d}"), &target)?;
566
    }
567
    dev_links(&at("/dev"))?;
568
    std::fs::create_dir(at("/dev/shm")).map_err(|e| format!("make /dev/shm: {e}"))?;
569
    mount("tmpfs", &at("/dev/shm"), "tmpfs", nosuid, "mode=1777")?;
570
    if Path::new(&at("/run")).is_dir() {
571
        mount("tmpfs", &at("/run"), "tmpfs", nosuid, "mode=755")?;
572
    }
573
    // Ephemeral mounts: made under the writable /src or /tmp.
574
    for m in &spec.mounts {
575
        let target = at(&m.target);
576
        std::fs::create_dir_all(&target).map_err(|e| format!("make {}: {e}", m.target))?;
577
        match &m.dir {
578
            Some(dir) => bind(dir.to_str().ok_or("a mount path is not UTF-8")?, &target)?,
579
            None => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,mode=755", m.size_mib))?,
580
        }
581
    }
582
    // Secrets: an in-memory mount of their own, each file 0400.
583
    if !spec.secrets.is_empty() {
584
        if !Path::new(&at("/run")).is_dir() {
585
            return Err("the image has no /run, where secrets are mounted".into());
586
        }
587
        let dir = at("/run/secrets");
588
        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
589
        mount("tmpfs", &dir, "tmpfs", nosuid | libc::MS_NOEXEC, "size=1m,mode=700")?;
590
        for (path, value) in &spec.secrets {
591
            let Some(rel) = path.strip_prefix("/run/secrets/") else {
592
                return Err(format!("secret path {path:?} is not under /run/secrets/"));
593
            };
594
            let full = format!("{dir}/{rel}");
595
            if let Some(parent) = Path::new(&full).parent() {
596
                std::fs::create_dir_all(parent).map_err(|e| format!("mkdir for {path}: {e}"))?;
597
            }
598
            std::fs::write(&full, value.as_bytes()).map_err(|e| format!("write {path}: {e}"))?;
599
            std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400))
600
                .map_err(|e| format!("chmod {path}: {e}"))?;
601
        }
602
    }
603
    let host = b"flow";
604
    unsafe { libc::sethostname(host.as_ptr() as *const libc::c_char, host.len()) };
605
    if spec.loopback {
606
        loopback_up()?;
607
    }
608
    // Into the new root; the old one is detached, so the host's files, the
609
    // spec and the scratch directory are out of reach.
610
    std::env::set_current_dir(root).map_err(|e| format!("cd {root}: {e}"))?;
611
    let dot = c".";
612
    if unsafe { libc::syscall(libc::SYS_pivot_root, dot.as_ptr(), dot.as_ptr()) } != 0 {
613
        return Err(format!("pivot_root: {}", std::io::Error::last_os_error()));
614
    }
615
    if unsafe { libc::umount2(dot.as_ptr(), libc::MNT_DETACH) } != 0 {
616
        return Err(format!("detach the old root: {}", std::io::Error::last_os_error()));
617
    }
618
    std::env::set_current_dir("/src").map_err(|e| format!("cd /src: {e}"))?;
619
620
    let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &spec.env);
621
    let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH);
622
    let program =
623
        cstr(&resolve_program(&spec.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", spec.argv[0]))?)?;
624
    let argv: Vec<CString> = spec.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
625
    let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?;
626
    let mut writable: Vec<String> = ["/src", "/tmp", "/dev"].into_iter().map(String::from).collect();
627
    writable.extend(spec.mounts.iter().map(|m| m.target.clone()));
628
    let pid = unsafe { libc::fork() };
629
    if pid < 0 {
630
        return Err(format!("fork: {}", std::io::Error::last_os_error()));
631
    }
632
    if pid == 0 {
633
        // The job: no new privileges, then the walls, then the program.
634
        let confined = (|| {
635
            unsafe {
636
                let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
637
                libc::dup2(null, 0);
638
                libc::setsid();
639
            }
640
            no_new_privs()?;
641
            landlock(&writable)?;
642
            seccomp(DENIED)
643
        })();
644
        unsafe {
645
            if let Err(e) = confined {
646
                let msg = format!("{FAILED}{e}\n");
647
                libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
648
                libc::_exit(125);
649
            }
650
            let mut a: Vec<*const libc::c_char> = argv.iter().map(|c| c.as_ptr()).collect();
651
            a.push(std::ptr::null());
652
            let mut e: Vec<*const libc::c_char> = env.iter().map(|c| c.as_ptr()).collect();
653
            e.push(std::ptr::null());
654
            libc::execve(program.as_ptr(), a.as_ptr(), e.as_ptr());
655
            let msg = format!("flow: cannot run {:?}: {}\n", spec.argv[0], std::io::Error::last_os_error());
656
            libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
657
            libc::_exit(127);
658
        }
659
    }
660
    Ok(wait_for(pid))
661
}
662
663
fn cstr(s: &str) -> std::result::Result<CString, String> {
664
    CString::new(s).map_err(|_| format!("{s:?} contains NUL"))
665
}
666
667
fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> std::result::Result<(), String> {
668
    let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?);
669
    let fstype = if fstype.is_empty() { std::ptr::null() } else { f.as_ptr() };
670
    let data = if data.is_empty() { std::ptr::null() } else { d.as_ptr() as *const libc::c_void };
671
    if unsafe { libc::mount(s.as_ptr(), t.as_ptr(), fstype, flags, data) } != 0 {
672
        return Err(format!("mount {src} on {target}: {}", std::io::Error::last_os_error()));
673
    }
674
    Ok(())
675
}
676
677
fn bind(src: &str, target: &str) -> std::result::Result<(), String> {
678
    mount(src, target, "", libc::MS_BIND | libc::MS_REC, "")
679
}
680
681
/// Remount a bind mount with `flags` added. In a user namespace the
682
/// flags the mount already had are locked, so they are kept.
683
fn remount_bind(target: &str, flags: libc::c_ulong) -> std::result::Result<(), String> {
684
    let t = cstr(target)?;
685
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
686
    if unsafe { libc::statvfs(t.as_ptr(), &mut st) } != 0 {
687
        return Err(format!("statvfs {target}: {}", std::io::Error::last_os_error()));
688
    }
689
    let kept = [
690
        (libc::ST_NOSUID, libc::MS_NOSUID),
691
        (libc::ST_NODEV, libc::MS_NODEV),
692
        (libc::ST_NOEXEC, libc::MS_NOEXEC),
693
        (libc::ST_NOATIME, libc::MS_NOATIME),
694
        (libc::ST_NODIRATIME, libc::MS_NODIRATIME),
695
        (libc::ST_RELATIME, libc::MS_RELATIME),
696
    ]
697
    .into_iter()
698
    .filter(|(st_flag, _)| st.f_flag & *st_flag != 0)
699
    .fold(0, |acc, (_, ms)| acc | ms);
700
    mount("none", target, "", libc::MS_BIND | libc::MS_REMOUNT | flags | kept, "")
701
}
702
703
fn no_new_privs() -> std::result::Result<(), String> {
704
    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
705
        return Err(format!("no_new_privs: {}", std::io::Error::last_os_error()));
706
    }
707
    Ok(())
708
}
709
710
// ---- Landlock ----
711
712
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1;
713
const LANDLOCK_RULE_PATH_BENEATH: libc::c_int = 1;
714
const ACCESS_EXECUTE: u64 = 1;
715
const ACCESS_READ_FILE: u64 = 1 << 2;
716
const ACCESS_READ_DIR: u64 = 1 << 3;
717
/// Every right of Landlock ABI 1 (execute .. make_sym).
718
const ACCESS_ABI1: u64 = (1 << 13) - 1;
719
const ACCESS_REFER: u64 = 1 << 13;
720
const ACCESS_TRUNCATE: u64 = 1 << 14;
721
722
#[repr(C)]
723
struct RulesetAttr {
724
    handled_access_fs: u64,
725
}
726
727
#[repr(C, packed)]
728
struct PathBeneath {
729
    allowed_access: u64,
730
    parent_fd: i32,
731
}
732
733
/// The kernel's Landlock ABI version; below 1 when there is none.
734
fn landlock_abi() -> i64 {
735
    unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, std::ptr::null::<RulesetAttr>(), 0usize, LANDLOCK_CREATE_RULESET_VERSION) }
736
}
737
738
/// Read and execute everywhere; write only beneath `writable`.
739
fn landlock(writable: &[String]) -> std::result::Result<(), String> {
740
    let abi = landlock_abi();
741
    if abi < 1 {
742
        return Err("Landlock is not available".into());
743
    }
744
    let mut all = ACCESS_ABI1;
745
    if abi >= 2 {
746
        all |= ACCESS_REFER;
747
    }
748
    if abi >= 3 {
749
        all |= ACCESS_TRUNCATE;
750
    }
751
    let attr = RulesetAttr { handled_access_fs: all };
752
    let ruleset = unsafe { libc::syscall(libc::SYS_landlock_create_ruleset, &attr, std::mem::size_of::<RulesetAttr>(), 0) };
753
    if ruleset < 0 {
754
        return Err(format!("landlock ruleset: {}", std::io::Error::last_os_error()));
755
    }
756
    let ruleset = ruleset as libc::c_int;
757
    let rule = |path: &str, access: u64| -> std::result::Result<(), String> {
758
        let p = cstr(path)?;
759
        let fd = unsafe { libc::open(p.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
760
        if fd < 0 {
761
            return Err(format!("open {path} for Landlock: {}", std::io::Error::last_os_error()));
762
        }
763
        let beneath = PathBeneath { allowed_access: access, parent_fd: fd };
764
        let rc = unsafe { libc::syscall(libc::SYS_landlock_add_rule, ruleset, LANDLOCK_RULE_PATH_BENEATH, &beneath, 0) };
765
        unsafe { libc::close(fd) };
766
        if rc != 0 {
767
            return Err(format!("landlock rule for {path}: {}", std::io::Error::last_os_error()));
768
        }
769
        Ok(())
770
    };
771
    rule("/", ACCESS_EXECUTE | ACCESS_READ_FILE | ACCESS_READ_DIR)?;
772
    for w in writable {
773
        rule(w, all)?;
774
    }
775
    if unsafe { libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0) } != 0 {
776
        return Err(format!("landlock restrict: {}", std::io::Error::last_os_error()));
777
    }
778
    unsafe { libc::close(ruleset) };
779
    Ok(())
780
}
781
782
// ---- seccomp ----
783
784
#[cfg(target_arch = "x86_64")]
785
const AUDIT_ARCH: u32 = 0xC000_003E;
786
#[cfg(target_arch = "aarch64")]
787
const AUDIT_ARCH: u32 = 0xC000_00B7;
788
#[cfg(target_arch = "riscv64")]
789
const AUDIT_ARCH: u32 = 0xC000_00F3;
790
791
/// Calls a job never makes: they reach past the sandbox (tracing, mounts,
792
/// kernel programs and keys, modules, kexec, swap, reboot) or around the
793
/// socket check (io_uring).
794
const DENIED: &[libc::c_long] = &[
795
    libc::SYS_ptrace,
796
    libc::SYS_process_vm_readv,
797
    libc::SYS_process_vm_writev,
798
    libc::SYS_mount,
799
    libc::SYS_umount2,
800
    libc::SYS_pivot_root,
801
    libc::SYS_open_tree,
802
    libc::SYS_move_mount,
803
    libc::SYS_fsopen,
804
    libc::SYS_fsconfig,
805
    libc::SYS_fsmount,
806
    libc::SYS_fspick,
807
    libc::SYS_bpf,
808
    libc::SYS_perf_event_open,
809
    libc::SYS_keyctl,
810
    libc::SYS_add_key,
811
    libc::SYS_request_key,
812
    libc::SYS_init_module,
813
    libc::SYS_finit_module,
814
    libc::SYS_delete_module,
815
    libc::SYS_kexec_load,
816
    libc::SYS_swapon,
817
    libc::SYS_swapoff,
818
    libc::SYS_reboot,
819
    libc::SYS_io_uring_setup,
820
    libc::SYS_io_uring_enter,
821
    libc::SYS_io_uring_register,
822
];
823
824
/// Install a filter that fails each of `denied` with EPERM and `socket`
825
/// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied`
826
/// still checks sockets). Another architecture's calls kill the process.
827
fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> {
828
    const LD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16;
829
    const JEQ: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16;
830
    const RET: u16 = (libc::BPF_RET | libc::BPF_K) as u16;
831
    let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k };
832
    let errno = |e: libc::c_int| libc::SECCOMP_RET_ERRNO | (e as u32 & libc::SECCOMP_RET_DATA);
833
    // struct seccomp_data: nr at 0, arch at 4, args[0] at 16 (its low
834
    // half on these little-endian machines).
835
    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)];
836
    #[cfg(target_arch = "x86_64")]
837
    {
838
        // x32 calls carry this bit; there are none in a 64-bit job.
839
        const JGE: u16 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16;
840
        f.push(op(JGE, 0x4000_0000, 0, 1));
841
        f.push(op(RET, errno(libc::EPERM), 0, 0));
842
    }
843
    for nr in denied {
844
        f.push(op(JEQ, *nr as u32, 0, 1));
845
        f.push(op(RET, errno(libc::EPERM), 0, 0));
846
    }
847
    f.push(op(JEQ, libc::SYS_socket as u32, 0, 3));
848
    f.push(op(LD, 16, 0, 0));
849
    f.push(op(JEQ, libc::AF_UNIX as u32, 1, 0));
850
    f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0));
851
    f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0));
852
    let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() };
853
    if unsafe { libc::syscall(libc::SYS_seccomp, libc::SECCOMP_SET_MODE_FILTER, 0, &prog) } != 0 {
854
        return Err(format!("seccomp: {}", std::io::Error::last_os_error()));
855
    }
856
    Ok(())
857
}