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 |