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