delonix-rust tutorial

Projecto: minicontainer · 30 min de leitura

3 · Namespaces e rootfs#

Este é o capítulo que transforma um processo normal num container. Segue a ordem em que as coisas acontecem — e a ordem é o conteúdo: cada passo depende do anterior.

mc createCLI · volta ao terminal fork supervisorsetsid · sem terminal unshare(user,pid,uts,ipc,net)map_ids · lo up · attach cgroupwaitpid → grava «stopped»+ exit code + OOM fork init — PID 1 do container unshare(mnt) · MS_PRIVATEmounts · /dev · pivot_rootreadonly · hostname · caps · no_new_privsread(FIFO) … bloqueiadepois: exec(processo do utilizador) relatório (pipe): K<pid> | E<msg> relatório mc startoutro processo, depois escreve 1 byte start.fifomkfifo no create $MC_ROOT/<id>/ state.json · lock · start.fifo · output.logtoda a comunicação entre invocações passa por ficheiros (flock + rename atómico) — sem daemon, sem socket

Passo 1 — o supervisor entra nos namespaces#

O supervisor faz um único unshare com cinco flags:

unshare(
    CloneFlags::CLONE_NEWUSER
        | CloneFlags::CLONE_NEWPID
        | CloneFlags::CLONE_NEWUTS
        | CloneFlags::CLONE_NEWIPC
        | CloneFlags::CLONE_NEWNET,
)?;

O mnt fica de fora de propósito: quem faz o unshare(NEWNS) é o init, mais tarde. Assim o supervisor mantém a vista do host do sistema de ficheiros, e pode gravar o state.json e apagar o cgroup depois de o container morrer.

E o pid? Lembra-te: CLONE_NEWPID só vale para os filhos. Por isso a seguir vem um fork — o filho é o PID 1.

Passo 2 — mapear os ids (e a armadilha do getuid)#

Um user namespace acabado de criar não tem mapa: és «ninguém» (uid 65534). Sem mapa, nem sequer podes escrever num ficheiro. O processo escreve o seu próprio mapa em /proc/self:

minicontainer/src/container.rs · map-idsver no GitHub ↗
/// Mapeamento de um só uid/gid (root dentro = o teu utilizador fora). Para vários uids seria
/// preciso `newuidmap` + `/etc/subuid`.
fn map_ids(uid: nix::unistd::Uid, gid: nix::unistd::Gid) -> Result<()> {
    let w = |f: &str, v: String| {
        fs::write(format!("/proc/self/{f}"), v).ctx(|| format!("writing /proc/self/{f}"))
    };
    w("setgroups", "deny".into())?; // obrigatório antes do gid_map sem privilégio
    w("uid_map", format!("0 {uid} 1"))?;
    w("gid_map", format!("0 {gid} 1"))
}

Três detalhes, todos aprendidos a falhar:

  1. setgroups = deny primeiro. Sem privilégio, o kernel só deixa escrever o gid_map se se prometer nunca usar setgroups(2) (senão um utilizador poderia largar grupos que o restringem).
  2. Mapeia um só uid: 0 <uid> 1 — «o uid 0 dentro é o meu uid fora, num intervalo de 1».
  3. Os ids são parâmetros, lidos antes do unshare. O primeiro código chamava getuid() dentro da função, e depois do unshare isso devolve 65534 → o kernel recusa o mapa com EPERM, sem dizer porquê. Passar uid/gid como argumentos torna a ordem impossível de errar:
// Ler os ids ANTES do unshare: depois dele `getuid()` devolve o uid «overflow» (65534)
// e o `uid_map` seria recusado com EPERM.
let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());

Passo 3 — o lo do namespace de rede#

Um net namespace novo nasce só com o lo, em baixo. Sem o subir, 127.0.0.1 não responde. Uma das poucas unsafe do projecto — um ioctl que o nix não embrulha:

minicontainer/src/container.rs · loopbackver no GitHub ↗
/// Sobe `lo` no netns novo (ioctl SIOCSIFFLAGS). Sem isto `127.0.0.1` não responde.
fn loopback_up() -> Result<()> {
    // SAFETY: `ifreq` é POD de zeros válido; o socket é fechado no fim; nomes ≤ IFNAMSIZ.
    unsafe {
        let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
        if sock < 0 {
            return Err(Error::Sys(nix::Error::last()));
        }
        let mut ifr: libc::ifreq = std::mem::zeroed();
        for (d, s) in ifr.ifr_name.iter_mut().zip(b"lo\0") {
            *d = *s as libc::c_char;
        }
        ifr.ifr_ifru.ifru_flags = (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
        let rc = libc::ioctl(sock, libc::SIOCSIFFLAGS as _, &ifr);
        let err = nix::Error::last();
        libc::close(sock);
        if rc < 0 { Err(Error::Sys(err)) } else { Ok(()) }
    }
}

Repara no que se faz antes de fechar o socket: guarda-se o errno (nix::Error::last()) antes do close, porque o close pode sobrescrevê-lo.

Passo 4 — o init monta o rootfs#

Agora, já como PID 1 num user namespace onde o processo tem todas as capabilities (sobre o namespace), o init prepara o seu mundo:

minicontainer/src/container.rs · initver no GitHub ↗
/// PID 1 do container. Só regressa com erro — no sucesso faz `exec`.
fn container_init(
    spec: &Spec,
    rootfs: &Path,
    fifo: &Path,
    report: &OwnedFd,
) -> Result<std::convert::Infallible> {
    unshare(CloneFlags::CLONE_NEWNS)?;
    // Sem isto os nossos mounts propagavam para o host.
    mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)?;
    // pivot_root exige que o novo root seja um ponto de montagem.
    mount(Some(rootfs), rootfs, None::<&str>, MsFlags::MS_BIND | MsFlags::MS_REC, None::<&str>)?;

    let mut dev_mounted = false;
    for m in &spec.mounts {
        apply_mount(rootfs, m)?;
        dev_mounted |= m.destination == Path::new("/dev");
    }
    if dev_mounted {
        setup_dev(rootfs)?;
    }

    // Aberto ANTES do pivot: depois dele o caminho do FIFO já não existe.
    // O_RDWR não bloqueia na abertura; o `read` bloqueia até `start` escrever.
    let fifo_fd = nix::fcntl::open(fifo, OFlag::O_RDWR | OFlag::O_CLOEXEC, Mode::empty())?;

    chdir(rootfs)?;
    pivot_root(".", ".")?; // truque: raiz nova e antiga empilhadas no mesmo sítio…
    umount2(".", MntFlags::MNT_DETACH)?; // …e a antiga desmonta-se.
    chdir("/")?;
    if spec.root.readonly {
        remount_ro("/")?;
    }
    if let Some(h) = &spec.hostname {
        sethostname(h)?;
    }
    drop_capabilities()?;
    // SAFETY: prctl com argumentos inteiros constantes.
    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
        return Err(Error::Sys(nix::Error::last()));
    }

    // Tudo pronto: diz ao supervisor e espera pelo `start`.
    send(report, b"K");
    let mut byte = [0u8; 1];
    nix::unistd::read(&fifo_fd, &mut byte)?;
    drop(fifo_fd);

    chdir(spec.process.cwd.as_str())?;
    let args: Vec<CString> = spec.process.args.iter().map(|a| cstr(a)).collect::<Result<_>>()?;
    let env: Vec<CString> = spec.process.env.iter().map(|a| cstr(a)).collect::<Result<_>>()?;
    let err = nix::unistd::execvpe(&args[0], &args, &env).unwrap_err();
    // Já depois do `start`: o relatório fechou; diz-se no stderr e sai-se como a shell (127).
    eprintln!("mc: cannot exec {:?}: {err}", spec.process.args[0]);
    std::process::exit(if err == nix::Error::ENOENT { 127 } else { 126 });
}

Vamos por partes.

MS_PRIVATE em toda a árvore#

mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)?;

Sem isto, os mounts que fazemos propagariam para o host (a propagation por omissão em muitas distros é shared). É a primeira linha depois do unshare(NEWNS) em qualquer runtime, e a mais fácil de esquecer.

O rootfs como ponto de montagem#

O pivot_root exige que a nova raiz seja um ponto de montagem. Uma pasta comum não é — fazemos um bind mount dela sobre si própria.

Mounts do config.json, sem sair do rootfs#

minicontainer/src/container.rs · apply-mountver no GitHub ↗
fn apply_mount(rootfs: &Path, m: &Mount) -> Result<()> {
    let target = resolve_in_root(rootfs, &m.destination, true)?;
    let (mut flags, mut ro) = (MsFlags::empty(), false);
    let mut data: Vec<&str> = Vec::new();
    for o in &m.options {
        match o.as_str() {
            "ro" => ro = true,
            "nosuid" => flags |= MsFlags::MS_NOSUID,
            "nodev" => flags |= MsFlags::MS_NODEV,
            "noexec" => flags |= MsFlags::MS_NOEXEC,
            "bind" => flags |= MsFlags::MS_BIND,
            "rbind" => flags |= MsFlags::MS_BIND | MsFlags::MS_REC,
            "rw" | "relatime" | "strictatime" => {}
            other => data.push(other),
        }
    }
    let kind = m.kind.as_deref().unwrap_or("bind");
    let bind = kind == "bind" || flags.contains(MsFlags::MS_BIND);
    match kind {
        "proc" | "tmpfs" | "bind" => {}
        other => return Err(Error::Unsupported(format!("mount type {other:?}"))),
    }
    let source = m.source.as_deref();
    if bind {
        let src = source.ok_or_else(|| Error::Spec("bind mount without source".into()))?;
        if src.is_file() {
            // o alvo de um bind de ficheiro tem de ser um ficheiro
            fs::remove_dir(&target).ok();
            fs::File::create(&target).ctx(|| format!("creating {}", target.display()))?;
        }
        mount(Some(src), &target, None::<&str>, flags | MsFlags::MS_BIND, None::<&str>)?;
        if ro || !(flags - MsFlags::MS_BIND - MsFlags::MS_REC).is_empty() {
            let t = target.to_string_lossy();
            let mut f = MsFlags::MS_BIND | MsFlags::MS_REMOUNT | flags | inherited_flags(&t);
            if ro {
                f |= MsFlags::MS_RDONLY
            }
            mount(None::<&str>, &target, None::<&str>, f, None::<&str>)?;
        }
    } else {
        let data = data.join(",");
        if ro {
            flags |= MsFlags::MS_RDONLY
        }
        mount(source, &target, Some(kind), flags, if data.is_empty() { None } else { Some(data.as_str()) })?;
    }
    Ok(())
}

O destino de cada mount vem de um ficheiro que não controlamos. Se fosse rootfs.join(destino), uma imagem com um symlink etc -> / faria um bind mount para etc/passwd acabar em /etc/passwd do host. Por isso passa por:

Resolver caminhos sem sair do rootfs#

minicontainer/src/fsutil.rs · resolvever no GitHub ↗
/// Junta `rel` a `root` recusando `..`, prefixos e — sobretudo — **symlinks** pelo caminho.
///
/// Porque não basta `root.join(rel)`: uma imagem pode plantar `etc -> /` e um bind mount
/// para `etc/passwd` acabaria no `/` do *host*. Resolvemos componente a componente e
/// recusamos qualquer symlink (é o que o delonix faz em `safe_bind_target`).
/// Com `create`, os directórios que faltam são criados.
pub fn resolve_in_root(root: &Path, rel: &Path, create: bool) -> Result<PathBuf> {
    let mut cur = root.to_path_buf();
    for comp in rel.components() {
        match comp {
            Component::RootDir | Component::CurDir => {}
            Component::Normal(name) => {
                cur.push(name);
                match fs::symlink_metadata(&cur) {
                    Ok(meta) if meta.file_type().is_symlink() => {
                        return Err(Error::UnsafePath(cur));
                    }
                    Ok(_) => {}
                    Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => {
                        fs::create_dir(&cur).ctx(|| format!("creating {}", cur.display()))?;
                    }
                    Err(e) => return Err(Error::io(format!("stat {}", cur.display()), e)),
                }
            }
            Component::ParentDir | Component::Prefix(_) => {
                return Err(Error::UnsafePath(rel.to_path_buf()));
            }
        }
    }
    Ok(cur)
}

Percorre o caminho componente a componente, e recusa .., prefixos e — o essencial — qualquer symlink. Tem duas variantes (create: cria os directórios em falta). O teste planta o symlink hostil e afirma a recusa:

std::os::unix::fs::symlink("/", dir.path().join("etc")).unwrap();
assert!(matches!(
    resolve_in_root(dir.path(), Path::new("/etc/passwd"), true),
    Err(Error::UnsafePath(_))
));

O motor tem o equivalente, e o comentário dele conta porquê:

delonix-runtime · crates/adapters/delonix-linux/src/lib.rs · linhas 1214–1242ver no GitHub ↗
/// Resolves `target` (an in-container path, already lexically validated by
/// `mount_target_safe`) against `rootfs`, component-by-component, refusing to
/// descend through any symlink an image layer may have planted along the
/// way. `mount_target_safe`'s lexical `..` check is not enough on its own:
/// `create_dir_all`/`OpenOptions::open` (called on the joined path right
/// after this) FOLLOW symlinks, and `bind_volume` runs BEFORE `pivot_root`
/// while `/` is still the real host filesystem — an absolute symlink at ANY
/// path component (a malicious image shipping e.g. `/etc -> /root`, or the
/// final mount-target component itself already existing as one) redirects
/// the destination to an arbitrary real host path, and the subsequent
/// create/open then create real directories/files on the host, as the
/// engine's own uid. Mirrors the confinement technique `cmd::build::
/// confine_to` already uses for the build's COPY (a sibling of this exact
/// bug class, fixed there first) — this is the engine-side equivalent.
fn safe_bind_target(rootfs: &str, target: &str) -> Option<std::path::PathBuf> {
    let mut current = std::path::PathBuf::from(rootfs);
    for comp in std::path::Path::new(target).components() {
        let std::path::Component::Normal(c) = comp else {
            continue;
        };
        current.push(c);
        if let Ok(meta) = std::fs::symlink_metadata(&current) {
            if meta.file_type().is_symlink() {
                return None;
            }
        }
    }
    Some(current)
}

bind_volume corre ANTES do pivot_root

Enquanto o / ainda é o real, create_dir_all e open seguem symlinks. Um symlink absoluto plantado pela imagem redirecciona o destino para um caminho arbitrário do host, e o motor cria ali directórios e ficheiros com o seu próprio uid. Não é hipotético: foi um achado de auditoria.

/dev sem mknod#

Um container precisa de /dev/null, /dev/zero… mas criar device nodes exige CAP_MKNOD no namespace inicial, que não temos. A solução do runtime rootless: um tmpfs em /dev (vem do config.json) e, por cima, bind mounts dos dispositivos do host:

minicontainer/src/container.rs · setup-devver no GitHub ↗
/// Sem root não há `mknod`: os dispositivos básicos são *bind-mounts* dos do host.
fn setup_dev(rootfs: &Path) -> Result<()> {
    let dev = resolve_in_root(rootfs, Path::new("/dev"), false)?; // sem symlinks pelo caminho
    for name in ["null", "zero", "full", "random", "urandom", "tty"] {
        let target = dev.join(name);
        fs::File::create(&target).ctx(|| format!("creating {}", target.display()))?;
        mount(
            Some(&*PathBuf::from("/dev").join(name)),
            &target,
            None::<&str>,
            MsFlags::MS_BIND,
            None::<&str>,
        )?;
    }
    for (link, to) in [
        ("fd", "/proc/self/fd"),
        ("stdin", "/proc/self/fd/0"),
        ("stdout", "/proc/self/fd/1"),
        ("stderr", "/proc/self/fd/2"),
    ] {
        std::os::unix::fs::symlink(to, dev.join(link)).ctx(|| format!("symlink /dev/{link}"))?;
    }
    Ok(())
}

O truque do pivot_root(".", ".")#

chdir(rootfs)?;
pivot_root(".", ".")?;               // raiz nova e antiga empilhadas no mesmo sítio…
umount2(".", MntFlags::MNT_DETACH)?; // …e a antiga desmonta-se
chdir("/")?;

A forma «manual» exigia criar um directório .old dentro do rootfs para pendurar a raiz antiga e depois apagá-lo — um passo que escreve no rootfs, que pode ser só de leitura. Com pivot_root(".", ".") a raiz antiga fica empilhada por baixo da nova, e o MNT_DETACH desfaz-se dela. Não sobra nada por onde fugir.

Rootfs só de leitura, e as flags «trancadas»#

if spec.root.readonly { remount_ro("/")?; }

Um remount em user namespace tem uma regra subtil: as flags (nosuid, nodev, noexec) herdadas do mount do host estão trancadas — um remount que as retire dá EPERM. Por isso o remount_ro lê-as (statvfs) e volta a pô-las:

fn remount_ro(path: &str) -> Result<()> {
    let flags = MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | inherited_flags(path);
    mount(None::<&str>, path, None::<&str>, flags, None::<&str>)?;
    Ok(())
}

O resultado, visto de dentro:

saída real · medida neste host
$ mc run ro -b bundle
touch: /x: Read-only file system
touch /x -> rc=1
touch /tmp/x -> ok
[exit 0]

/x (raiz) recusa a escrita; /tmp (um tmpfs) aceita. É a diferença entre um rootfs imutável e um sítio para trabalhar.

Verifica#

cargo test -p minicontainer --test e2e runs_as_pid_1
cargo test -p minicontainer --test e2e readonly_root

Já temos um container isolado e com uma raiz própria. Falta limitá-lo e tirar-lhe poder: cgroups e capabilities.