OXYGENHUB

Syscall tier: network "loopback" allows IPv4 and IPv6 sockets cc462b56fbbb

In the job's own network namespace they reach only its lo, so a server
it starts on 127.0.0.1 answers it; isolated still refuses all but
AF_UNIX. The conformance test serves and fetches over loopback.

closes #73
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 01:45 UTC
Signature
◈ sealed by Andy McCall
Hash
cc462b56fbbb935df73234cf7ae0ac8de8a839549a4aa7aa26d4b2d038edbc12
Depends on
5cf1e8aefbb6 72772aa01d6b c3db750ed96e e0918b887850 ea56c56eaf90
Depended on by
e6949d87205b 3554afefb6aa
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
16 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **The syscall tier's loopback reaches TCP (#73):** with `network "loopback"`, a job may open IPv4 and IPv6 sockets, which in its own network namespace reach only its `lo`, so a server it starts on 127.0.0.1 answers it (a conformance suite over an emulator's HTTP control port, which skipped every test on narvi-1). With `network "isolated"` sockets other than `AF_UNIX` are still refused.
7 8
- **Runner builds in releases (oxygenhub#322):** a `dist` job (`scripts/dist.sh`, on a change to `Cargo.toml` or the script) builds `flow` stripped for Linux x86_64, aarch64 and riscv64 (glibc 2.28 and later) as `flow-linux-ARCH`, named by the architecture a runner reports, with `SHA256SUMS`: the files a release carries for runners that update themselves.
8 9
- **Runners ask the hub for their update (oxygenhub#322):** once the hub has answered a claim, and every 15 minutes, a runner asks `GET /api/v1/flow/runner/update`; when the hub offers a build, it downloads it (kept in the home's `updates/`), refuses it unless its size and BLAKE3 are those the hub attests, and installs it as `flow runner update` does, leaving within 600 s, or for a dry run checks it and says what would happen. What it did goes back to the hub once per outcome, and a proof or a rollback is told at the next start. `[runner.update] allow = false` refuses and says so; a hub without runner updates (404) is simply asked again later.
9 10
- **The runner update protocol's types (oxygenhub#322):** `UpdateAnswer`, `UpdatePolicy`, `UpdateOffer` and `UpdateReport` in flow-core, for `GET`/`POST /api/v1/flow/runner/update`: the policy that applies to a runner once every level is applied, and the release asset it should run.
…
98 unchanged lines
modified crates/flow/src/syscall.rs +24 −7
…
2 unchanged lines
3 3
//! network, IPC and UTS namespaces, on the rootfs image's tree (unpacked
4 4
//! once per hash, read-only) with the workspace directory at `/src` and a
5 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
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
7 8
//! reach past the sandbox (`ptrace`, `mount`, `bpf`, `perf_event_open`,
8 9
//! keys, modules, io_uring); and the sandbox's CPU and memory limits apply
9 10
//! through a cgroup v2 group of the runner's own delegated cgroup. Without
…
90 unchanged lines
100 101
            return Err("Landlock is not enabled in this kernel (it must be in the lsm= list)".into());
101 102
        }
102 103
        no_new_privs()?;
103 -
        seccomp(&[]).map_err(|e| format!("seccomp filters are not available: {e}"))
104 +
        seccomp(&[], families(false)).map_err(|e| format!("seccomp filters are not available: {e}"))
104 105
    })();
105 106
    match r {
106 107
        Ok(()) => 0,
…
540 unchanged lines
647 648
            }
648 649
            no_new_privs()?;
649 650
            landlock(&writable)?;
650 -
            seccomp(DENIED)
651 +
            seccomp(DENIED, families(spec.loopback))
651 652
        })();
652 653
        unsafe {
653 654
            if let Err(e) = confined {
…
186 unchanged lines
840 841
const BPF_K: u16 = 0x00;
841 842
const BPF_RET: u16 = 0x06;
842 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 +
843 855
/// Install a filter that fails each of `denied` with EPERM and `socket`
844 -
/// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied`
856 +
/// for a family not in `allowed` with EAFNOSUPPORT (an empty `denied`
845 857
/// still checks sockets). Another architecture's calls kill the process.
846 -
fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> {
858 +
fn seccomp(denied: &[libc::c_long], allowed: &[libc::c_int]) -> std::result::Result<(), String> {
847 859
    const LD: u16 = BPF_LD | BPF_W | BPF_ABS;
848 860
    const JEQ: u16 = BPF_JMP | BPF_JEQ | BPF_K;
849 861
    const RET: u16 = BPF_RET | BPF_K;
…
13 unchanged lines
863 875
        f.push(op(JEQ, *nr as u32, 0, 1));
864 876
        f.push(op(RET, errno(libc::EPERM), 0, 0));
865 877
    }
866 -
    f.push(op(JEQ, libc::SYS_socket as u32, 0, 3));
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));
867 882
    f.push(op(LD, 16, 0, 0));
868 -
    f.push(op(JEQ, libc::AF_UNIX as u32, 1, 0));
883 +
    for (i, fam) in allowed.iter().enumerate() {
884 +
        f.push(op(JEQ, *fam as u32, n - i as u8, 0));
885 +
    }
869 886
    f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0));
870 887
    f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0));
871 888
    let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() };
…
18 unchanged lines
modified crates/flow/tests/syscall.rs +17 −1
…
9 unchanged lines
10 10
11 11
const APPLETS: &[&str] = &[
12 12
    "sh", "echo", "cat", "id", "touch", "wget", "tail", "cut", "tr", "true", "false", "ls", "grep", "wc", "mount", "hostname", "head",
13 -
    "sed",
13 +
    "sed", "httpd", "mkdir", "sleep",
14 14
];
15 15
16 16
/// `flow`, in a delegated cgroup scope of its own when systemd's user
…
52 unchanged lines
69 69
    rootfs "cas://blob/{rootfs}"
70 70
    memory-limit "256MiB"
71 71
}}
72 +
sandbox "bb-lo" {{
73 +
    runtime "syscall"
74 +
    rootfs "cas://blob/{rootfs}"
75 +
    memory-limit "256MiB"
76 +
    network "loopback"
77 +
}}
78 +
job "loopback" {{
79 +
    sandbox "bb-lo"
80 +
    exec "sh" "-c" "mkdir -p /tmp/www && echo SERVED > /tmp/www/x && httpd -p 127.0.0.1:8080 -h /tmp/www && sleep 1 && wget -q -O - http://127.0.0.1:8080/x; wget -q -T 2 -O /dev/null http://1.1.1.1 && echo LO-NETWORK || echo lo-only"
81 +
}}
72 82
job "literal" {{
73 83
    sandbox "bb"
74 84
    exec "echo" "a; touch /src/pwned && $(id) | `id`"
…
40 unchanged lines
115 125
    assert!(stdout.contains("   flow\n"), "its own host name: {all}");
116 126
    assert!(!stdout.contains("   MOUNTED\n") && stdout.contains("   done\n"), "no mounts: {all}");
117 127
    assert!(stderr.contains("Address family not supported"), "sockets other than AF_UNIX are refused: {all}");
128 +
    // oxygenflow#73: with network "loopback", a server on 127.0.0.1 answers
129 +
    // its own job, and nothing past lo is reached.
130 +
    assert!(
131 +
        stdout.contains("   SERVED\n") && stdout.contains("   lo-only\n") && !stdout.contains("   LO-NETWORK\n"),
132 +
        "loopback TCP: {all}"
133 +
    );
118 134
    assert!(!work.join("w").exists(), "jobs write to their own copy: {all}");
119 135
    let procs: u32 = stdout.lines().find_map(|l| l.trim().strip_prefix("procs ")).and_then(|n| n.parse().ok()).expect(&all);
120 136
    assert!(procs < 10, "only the sandbox's processes are visible: {all}");
…
7 unchanged lines
modified docs/m0.md +3 −1
…
167 unchanged lines
168 168
UTS namespaces (no interfaces but loopback), the rootfs image's tree
169 169
(unpacked once per hash, read-only) as `/`, the workspace directory at
170 170
`/src`, tmpfs at `/tmp`; Landlock limits writes to `/src` and `/tmp`; a
171 -
seccomp filter refuses `socket` for anything but `AF_UNIX`, plus
171 +
seccomp filter refuses `socket` for anything but `AF_UNIX` (and, with
172 +
`network "loopback"`, `AF_INET` and `AF_INET6`, which reach only the
173 +
job's own `lo`: oxygenflow#73), plus
172 174
`ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls;
173 175
`cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the
174 176
runner has a delegated cgroup, and the job fails to start otherwise
…
85 unchanged lines