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