Skip to main content

nucleus/resources/
cgroup_device.rs

1//! cgroup v2 device access control via a `BPF_PROG_TYPE_CGROUP_DEVICE` program.
2//!
3//! cgroup v2 has no `devices` controller file; device access is gated by a
4//! classic-BPF (cBPF) program attached to the cgroup. Without one, the default
5//! is allow-all — which is today's Nucleus behavior. When GPU passthrough is
6//! enabled we install an explicit allowlist so a compromised workload cannot
7//! reach host devices other than the (minimal) base + GPU set, even if it
8//! somehow creates a device node.
9//!
10//! The bytecode generator is pure and unit-tested; the `bpf(2)` load/attach
11//! path is best-effort and degrades to a warning when the kernel or the
12//! caller's capabilities do not permit it.
13//!
14//! See `spec/gpu-passthrough.md` for the design.
15
16use std::mem::MaybeUninit;
17use std::os::unix::io::RawFd;
18use std::path::Path;
19
20use tracing::{debug, info, warn};
21
22use crate::error::{NucleusError, Result};
23
24// ---- Kernel constants (linux/bpf.h + linux/cgroup_def.h) -----------------
25
26/// `bpf(2)` command: load a program.
27const BPF_PROG_LOAD: u32 = 5;
28/// `bpf(2)` command: attach a program to a cgroup.
29const BPF_PROG_ATTACH: u32 = 8;
30
31/// Program type: cgroup device filter (classic BPF).
32const BPF_PROG_TYPE_CGROUP_DEVICE: u32 = 9;
33
34/// Attach type for cgroup device programs.
35const BPF_CGROUP_DEVICE: u32 = 6;
36
37/// Device kinds in `bpf_cgroup_dev_ctx.type`.
38const BPF_DEVCG_DEV_CHAR: u32 = 1;
39const BPF_DEVCG_DEV_BLOCK: u32 = 2;
40
41/// Access bits in `bpf_cgroup_dev_ctx.access`.
42const DEV_READ: u32 = 1 << 0;
43const DEV_WRITE: u32 = 1 << 1;
44const DEV_MKNOD: u32 = 1 << 2;
45/// Full rwm access — what we grant to base + GPU devices.
46const DEV_ACCESS_ALL: u32 = DEV_READ | DEV_WRITE | DEV_MKNOD;
47
48// Classic BPF opcodes (linux/filter.h), pre-combined to avoid clippy's
49// `eq_op`/`no_effect` lints on literal bitwise-OR of constants.
50//   BPF_LD   = 0x00  BPF_W = 0x00  BPF_ABS = 0x20
51//   BPF_JMP  = 0x05  BPF_JEQ  = 0x10  BPF_JSET = 0x40  BPF_K = 0x00
52//   BPF_RET  = 0x06
53const BPF_LD_W_ABS: u16 = 0x20; // BPF_LD | BPF_W | BPF_ABS
54const BPF_JMP_JEQ_K: u16 = 0x15; // BPF_JMP | BPF_JEQ | BPF_K
55const BPF_JMP_JSET_K: u16 = 0x45; // BPF_JMP | BPF_JSET | BPF_K
56const BPF_RET_K: u16 = 0x06; // BPF_RET | BPF_K
57
58/// Return values for the device program: 1 = allow, 0 = deny.
59const ALLOW: u32 = 1;
60const DENY: u32 = 0;
61
62/// Offsets into `struct bpf_cgroup_dev_ctx` (all `__u32`).
63const OFF_ACCESS: u32 = 0;
64const OFF_TYPE: u32 = 4;
65const OFF_MAJOR: u32 = 8;
66const OFF_MINOR: u32 = 12;
67
68/// A single device allowlist entry.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct DeviceAllowSpec {
71    pub is_block: bool,
72    pub major: u32,
73    pub minor: u32,
74}
75
76impl DeviceAllowSpec {
77    fn device_type(&self) -> u32 {
78        if self.is_block {
79            BPF_DEVCG_DEV_BLOCK
80        } else {
81            BPF_DEVCG_DEV_CHAR
82        }
83    }
84}
85
86/// The standard base devices Nucleus creates via `create_dev_nodes`.
87///
88/// `(is_block, major, minor)`:
89/// null(1,3) zero(1,5) full(1,7) random(1,8) urandom(1,9) tty(5,0).
90pub fn base_device_specs(include_tty: bool) -> Vec<DeviceAllowSpec> {
91    let mut specs = vec![
92        DeviceAllowSpec { is_block: false, major: 1, minor: 3 },  // null
93        DeviceAllowSpec { is_block: false, major: 1, minor: 5 },  // zero
94        DeviceAllowSpec { is_block: false, major: 1, minor: 7 },  // full
95        DeviceAllowSpec { is_block: false, major: 1, minor: 8 },  // random
96        DeviceAllowSpec { is_block: false, major: 1, minor: 9 },  // urandom
97    ];
98    if include_tty {
99        specs.push(DeviceAllowSpec { is_block: false, major: 5, minor: 0 }); // tty
100    }
101    specs
102}
103
104/// A classic BPF instruction (`struct sock_filter`).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub(crate) struct SockFilter {
107    code: u16,
108    jt: u8,
109    jf: u8,
110    k: u32,
111}
112
113impl SockFilter {
114    const fn ld_abs(off: u32) -> Self {
115        Self { code: BPF_LD_W_ABS, jt: 0, jf: 0, k: off }
116    }
117    const fn jeq_k(k: u32, jt: u8, jf: u8) -> Self {
118        Self { code: BPF_JMP_JEQ_K, jt, jf, k }
119    }
120    const fn jset_k(k: u32, jt: u8, jf: u8) -> Self {
121        Self { code: BPF_JMP_JSET_K, jt, jf, k }
122    }
123    const fn ret_k(k: u32) -> Self {
124        Self { code: BPF_RET_K, jt: 0, jf: 0, k }
125    }
126}
127
128/// Number of instructions emitted per allowlist entry (see [`build_device_program`]).
129const ENTRY_LEN: usize = 9;
130
131/// Compile the cBPF device program for the given specs.
132///
133/// Layout per entry (uniform 9 instructions so jump offsets are constant):
134/// ```text
135///  0 LD   type
136///  1 JEQ  type, jt=0, jf=7        (jf -> next entry)
137///  2 LD   major
138///  3 JEQ  major, jt=0, jf=5
139///  4 LD   minor
140///  5 JEQ  minor, jt=0, jf=3
141///  6 LD   access
142///  7 JSET access_all, jt=0, jf=1  (jt -> allow)
143///  8 RET  ALLOW
144/// ```
145/// A trailing `RET DENY` makes the program deny-by-default.
146pub(crate) fn build_device_program(specs: &[DeviceAllowSpec]) -> Vec<SockFilter> {
147    let mut prog = Vec::with_capacity(specs.len() * ENTRY_LEN + 1);
148    for spec in specs {
149        prog.push(SockFilter::ld_abs(OFF_TYPE));
150        prog.push(SockFilter::jeq_k(spec.device_type(), 0, 7));
151        prog.push(SockFilter::ld_abs(OFF_MAJOR));
152        prog.push(SockFilter::jeq_k(spec.major, 0, 5));
153        prog.push(SockFilter::ld_abs(OFF_MINOR));
154        prog.push(SockFilter::jeq_k(spec.minor, 0, 3));
155        prog.push(SockFilter::ld_abs(OFF_ACCESS));
156        prog.push(SockFilter::jset_k(DEV_ACCESS_ALL, 0, 1));
157        prog.push(SockFilter::ret_k(ALLOW));
158    }
159    prog.push(SockFilter::ret_k(DENY));
160    prog
161}
162
163// ---- bpf(2) syscall wrappers --------------------------------------------
164
165/// `union bpf_attr` subset for `BPF_PROG_LOAD`.
166#[repr(C)]
167#[derive(Default)]
168struct BpfProgAttr {
169    prog_type: u32,
170    insn_cnt: u32,
171    insns: u64,
172    license: u64,
173    log_level: u32,
174    log_size: u32,
175    log_buf: u64,
176    kern_version: u32,
177    prog_flags: u32,
178}
179
180/// `union bpf_attr` subset for `BPF_PROG_ATTACH`.
181#[repr(C)]
182struct BpfAttachAttr {
183    target_fd: u32,
184    attach_bpf_fd: u32,
185    attach_type: u32,
186    attach_flags: u32,
187}
188
189/// Load and attach a cgroup device allowlist program to the cgroup at `path`.
190///
191/// Returns `Ok(true)` if the program was attached, `Ok(false)` if it was
192/// intentionally skipped (kernel/capability unavailable and `best_effort`),
193/// or `Err` on an unexpected failure that callers should treat as fatal.
194pub fn install_device_allowlist(
195    cgroup_path: &Path,
196    specs: &[DeviceAllowSpec],
197    best_effort: bool,
198) -> Result<bool> {
199    if specs.is_empty() {
200        return Ok(false);
201    }
202
203    let prog = build_device_program(specs);
204    debug!(
205        "cgroup device program: {} entries, {} instructions",
206        specs.len(),
207        prog.len()
208    );
209
210    let prog_fd = match load_device_program(&prog, best_effort) {
211        Ok(fd) => fd,
212        Err(e) => {
213            if best_effort {
214                warn!(
215                    "Failed to load cgroup device BPF (continuing without device allowlist): {}",
216                    e
217                );
218                return Ok(false);
219            }
220            return Err(NucleusError::CgroupError(format!(
221                "Failed to load cgroup device BPF: {}",
222                e
223            )));
224        }
225    };
226
227    let cgroup_fd = match open_cgroup(cgroup_path) {
228        Ok(fd) => fd,
229        Err(e) => {
230            close_fd(prog_fd);
231            if best_effort {
232                warn!("Failed to open cgroup for device BPF attach: {}", e);
233                return Ok(false);
234            }
235            return Err(NucleusError::CgroupError(format!(
236                "Failed to open cgroup {:?}: {}",
237                cgroup_path, e
238            )));
239        }
240    };
241
242    let attached = match attach_program(cgroup_fd, prog_fd) {
243        Ok(()) => true,
244        Err(e) => {
245            if best_effort {
246                warn!(
247                    "Failed to attach cgroup device BPF (continuing without device allowlist): {}",
248                    e
249                );
250                false
251            } else {
252                close_fd(prog_fd);
253                close_fd(cgroup_fd);
254                return Err(NucleusError::CgroupError(format!(
255                    "Failed to attach cgroup device BPF: {}",
256                    e
257                )));
258            }
259        }
260    };
261
262    // attach holds a reference; close our copies.
263    close_fd(prog_fd);
264    close_fd(cgroup_fd);
265
266    if attached {
267        info!(
268            "Installed cgroup device allowlist ({} devices) at {:?}",
269            specs.len(),
270            cgroup_path
271        );
272    }
273    Ok(attached)
274}
275
276fn load_device_program(prog: &[SockFilter], best_effort: bool) -> Result<RawFd> {
277    // SAFETY: MaybeUninit<sock_filter> has the same layout as our SockFilter
278    // (repr(Rust) vs C, but fields are u16,u8,u8,u32 with no padding
279    // differences for this layout). We transmute the slice to the kernel's
280    // expected representation. sock_filter is { u16 code; u8 jt; u8 jf; u32 k; }.
281    let license = b"GPL\0";
282    let mut log_buf = vec![0u8; 4096];
283    let attr = BpfProgAttr {
284        prog_type: BPF_PROG_TYPE_CGROUP_DEVICE,
285        insn_cnt: prog.len() as u32,
286        insns: prog.as_ptr() as u64,
287        license: license.as_ptr() as u64,
288        log_level: if best_effort { 0 } else { 1 },
289        log_size: log_buf.len() as u32,
290        log_buf: log_buf.as_mut_ptr() as u64,
291        ..Default::default()
292    };
293
294    // SAFETY: bpf(2) with BPF_PROG_LOAD reads our repr(C) attr and the insns
295    // slice; it returns a new fd or -1. No aliased mutable state is touched.
296    let ret = unsafe {
297        libc::syscall(
298            libc::SYS_bpf,
299            BPF_PROG_LOAD as libc::c_int,
300            &attr as *const BpfProgAttr,
301            std::mem::size_of::<BpfProgAttr>() as libc::c_int,
302        )
303    };
304    if ret < 0 {
305        let err = std::io::Error::last_os_error();
306        if !log_buf.is_empty() && log_buf[0] != 0 {
307            let msg = String::from_utf8_lossy(&log_buf);
308            return Err(NucleusError::CgroupError(format!(
309                "bpf(PROG_LOAD) failed: {} ({})",
310                err,
311                msg.trim_end_matches('\0')
312            )));
313        }
314        return Err(NucleusError::CgroupError(format!(
315            "bpf(PROG_LOAD) failed: {}",
316            err
317        )));
318    }
319    Ok(ret as RawFd)
320}
321
322fn open_cgroup(path: &Path) -> Result<RawFd> {
323    let cstr = std::ffi::CString::new(path.to_string_lossy().to_string()).map_err(|e| {
324        NucleusError::CgroupError(format!("cgroup path contained NUL: {}", e))
325    })?;
326    // SAFETY: open(2) on a NUL-terminated path; returns fd or -1.
327    let fd = unsafe {
328        libc::open(
329            cstring_ptr(&cstr),
330            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
331        )
332    };
333    if fd < 0 {
334        return Err(NucleusError::CgroupError(format!(
335            "open({:?}) failed: {}",
336            path,
337            std::io::Error::last_os_error()
338        )));
339    }
340    Ok(fd)
341}
342
343fn attach_program(cgroup_fd: RawFd, prog_fd: RawFd) -> Result<()> {
344    let attr = BpfAttachAttr {
345        target_fd: cgroup_fd as u32,
346        attach_bpf_fd: prog_fd as u32,
347        attach_type: BPF_CGROUP_DEVICE,
348        attach_flags: 0,
349    };
350    // SAFETY: bpf(2) with BPF_PROG_ATTACH reads our repr(C) attr.
351    let ret = unsafe {
352        libc::syscall(
353            libc::SYS_bpf,
354            BPF_PROG_ATTACH as libc::c_int,
355            &attr as *const BpfAttachAttr,
356            std::mem::size_of::<BpfAttachAttr>() as libc::c_int,
357        )
358    };
359    if ret < 0 {
360        return Err(NucleusError::CgroupError(format!(
361            "bpf(PROG_ATTACH) failed: {}",
362            std::io::Error::last_os_error()
363        )));
364    }
365    Ok(())
366}
367
368fn close_fd(fd: RawFd) {
369    if fd >= 0 {
370        // SAFETY: close(2) on a valid fd.
371        unsafe { libc::close(fd) };
372    }
373}
374
375/// Reborrow a `CString` as a pointer without consuming it.
376///
377/// Kept tiny so the `open_cgroup` safety argument stays local.
378fn cstring_ptr(c: &std::ffi::CString) -> *const libc::c_char {
379    c.as_ptr()
380}
381
382// Suppress unused import warning when the MaybeUninit import is not needed.
383#[allow(dead_code)]
384fn _use_maybeuninit() -> MaybeUninit<u8> {
385    MaybeUninit::uninit()
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn program_is_deny_by_default_when_empty() {
394        let prog = build_device_program(&[]);
395        assert_eq!(prog.len(), 1);
396        assert_eq!(prog[0], SockFilter::ret_k(DENY));
397    }
398
399    #[test]
400    fn each_entry_is_nine_instructions_uniform() {
401        let specs = base_device_specs(true);
402        let prog = build_device_program(&specs);
403        assert_eq!(prog.len(), specs.len() * ENTRY_LEN + 1);
404
405        // Final instruction is always RET DENY.
406        assert_eq!(prog.last().copied(), Some(SockFilter::ret_k(DENY)));
407
408        // Verify the layout of the first entry precisely.
409        let e = &specs[0];
410        assert_eq!(prog[0], SockFilter::ld_abs(OFF_TYPE));
411        assert_eq!(prog[1], SockFilter::jeq_k(e.device_type(), 0, 7));
412        assert_eq!(prog[2], SockFilter::ld_abs(OFF_MAJOR));
413        assert_eq!(prog[3], SockFilter::jeq_k(e.major, 0, 5));
414        assert_eq!(prog[4], SockFilter::ld_abs(OFF_MINOR));
415        assert_eq!(prog[5], SockFilter::jeq_k(e.minor, 0, 3));
416        assert_eq!(prog[6], SockFilter::ld_abs(OFF_ACCESS));
417        assert_eq!(prog[7], SockFilter::jset_k(DEV_ACCESS_ALL, 0, 1));
418        assert_eq!(prog[8], SockFilter::ret_k(ALLOW));
419    }
420
421    #[test]
422    fn jump_offsets_reach_next_entry() {
423        // With two entries, the first entry's jf jumps must land at the start
424        // of the second entry (index ENTRY_LEN), not the final RET.
425        let specs = vec![
426            DeviceAllowSpec { is_block: false, major: 195, minor: 0 },
427            DeviceAllowSpec { is_block: false, major: 226, minor: 128 },
428        ];
429        let prog = build_device_program(&specs);
430        assert_eq!(prog.len(), 2 * ENTRY_LEN + 1);
431        // prog[1] jf=7 means from PC=2 jump 7 -> PC=9 = start of entry 2.
432        assert_eq!(prog[1].jf, 7);
433        // entry 2 starts at index 9: its LD type.
434        assert_eq!(prog[ENTRY_LEN], SockFilter::ld_abs(OFF_TYPE));
435        // And the final RET DENY sits after entry 2.
436        assert_eq!(prog[2 * ENTRY_LEN], SockFilter::ret_k(DENY));
437    }
438
439    #[test]
440    fn base_specs_match_created_dev_nodes() {
441        let specs = base_device_specs(false);
442        let as_pairs: Vec<(u32, u32)> = specs.iter().map(|s| (s.major, s.minor)).collect();
443        assert_eq!(
444            as_pairs,
445            vec![(1, 3), (1, 5), (1, 7), (1, 8), (1, 9)]
446        );
447        let with_tty = base_device_specs(true);
448        assert_eq!(with_tty.len(), 6);
449        assert!(with_tty.iter().any(|s| s.major == 5 && s.minor == 0));
450    }
451
452    #[test]
453    fn block_device_uses_block_type() {
454        let spec = DeviceAllowSpec { is_block: true, major: 8, minor: 0 };
455        assert_eq!(spec.device_type(), BPF_DEVCG_DEV_BLOCK);
456        let char_spec = DeviceAllowSpec { is_block: false, major: 1, minor: 3 };
457        assert_eq!(char_spec.device_type(), BPF_DEVCG_DEV_CHAR);
458    }
459}