qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga

Resuelve §NO-resuelve 2 del ADR 0015, que era el ticket que más desbloqueaba.
Tres síntomas que parecían distintos —apt sin poder bajar a `_apt`, pacman sin
poder chownear a `alpm`, pressure-vessel sin poder escribir su uid_map— eran la
misma causa: bwrap crea el userns con UN SOLO id.

La cura resultó tener TRES partes, y ninguna sobra:

1. `setcap cap_setuid+ep newuidmap` (+ cap_setgid en newgidmap). shadow.toml los
   instala pero no los provisiona; sin la capability no escriben el mapa.
2. Crear el userns nosotros, mapear el rango de /etc/subuid con newuidmap y
   pasárselo a bwrap con `--userns FD`. bwrap crea el suyo con un solo id A
   PROPÓSITO y nunca llama a newuidmap: el trabajo es de quien lo invoca. El fd
   lo abre la shell (`exec 3<…`), porque un fd sólo cruza el exec si no es
   CLOEXEC y no valía la pena una dep de C para un fcntl.
3. Devolver las capabilities DENTRO del namespace. Ésta no estaba en el plan y
   es la que costó: bwrap las tira todas, y en Linux ser root es tener
   CAP_SETUID, no tener uid 0. Sin ella apt seguía sin poder seteuid(42) — un
   síntoma que parecía de subuid y no lo era. Son seguras por construcción:
   dentro de un userns sólo alcanzan lo que ese namespace posee, o sea nuestros
   propios subuid. CAP_SYS_ADMIN queda fuera y sigue colgando de `nesting`.

MEDIDO después: uid_map de 65537 ids, setgroups: allow, apt instala SIN el
APT::Sandbox::User=root, pacman sincroniza con DownloadUser=alpm INTACTO, y el
userns anidado monta con root=true ⇒ el conflicto 2 de D9 se disuelve solo.

Dos cosas más que salieron por medir, no por pensar:

- El guardián MENTÍA. qorpa-preflight envolvía al hijo en `timeout`, que forkea,
  así que newuidmap apuntaba al PID equivocado y el kernel respondía "Operation
  not permitted" — un falso negativo idéntico a un fallo real. Decía que subuid
  no andaba cuando a mano andaba. Ahora sale exit 0.
- Quitar el impuesto MUEVE el problema: el upper pasa a contener ficheros de los
  subuid (apt deja los suyos con uid 165577) que nuestro uid no puede borrar ⇒
  recreate entra a un userns mapeado para limpiar. Y cuando no hay rango, se
  degrada diciendo la causa exacta en vez de quedar en misterio.

Y un detalle que no es cosmético: `--perms 1777` antes del `--tmpfs /tmp`, o el
_apt al que apt baja no puede escribir su fichero temporal. Un /tmp que no es
1777 no es /tmp.

2 tests nuevos (el rango se lee por usuario; CAP_SYS_ADMIN NO está en las caps
de root, o `nesting` dejaría de ser una decisión). 45/45.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
This commit is contained in:
Sergio
2026-09-03 05:27:03 +00:00
co-authored by Claude Opus 5
parent f19b8b3e4a
commit 89da9c822f
7 changed files with 280 additions and 36 deletions
+246 -14
View File
@@ -106,6 +106,10 @@ pub enum QorpaCmd {
/// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
#[arg(long)]
no_jail: bool,
/// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`.
/// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
#[arg(long)]
single_id: bool,
/// Comando dentro (default: el shell de la imagen). Va después de `--`.
#[arg(last = true)]
cmd: Vec<String>,
@@ -126,8 +130,8 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
create(&resolve_root(root), &id, &base, distro.as_deref())
}
QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
QorpaCmd::Run { id, root, dry_run, no_jail, cmd } => {
run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail)
QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
}
QorpaCmd::Export { .. } => bail!(
"sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
@@ -439,7 +443,27 @@ fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
}
}
}
std::fs::remove_dir_all(p)
match std::fs::remove_dir_all(p) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia
// contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y
// nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los
// creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap,
// así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a
// salir tal cual, que es lo correcto.
let st = Command::new("unshare")
.args(["-U", "--map-auto", "-r", "rm", "-rf"])
.arg(p)
.status()?;
if st.success() && !p.exists() {
Ok(())
} else {
Err(e)
}
}
Err(e) => Err(e),
}
}
enum TreeState { Missing, Empty, Populated }
@@ -558,6 +582,24 @@ fn harkaq_exec() -> PathBuf {
.join("harkaq-exec")
}
/// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD.
///
/// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener
/// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear —
/// que es exactamente el síntoma que parecía de subuid y no lo era.
///
/// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que
/// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el
/// host. Por eso se conceden con `root = true` sin pedir permiso aparte.
///
/// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión
/// `nesting` declarada. Abrir sólo lo declarado, también acá.
const CAPS_ROOT: &[&str] = &[
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD",
"CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT",
"CAP_KILL", "CAP_AUDIT_WRITE",
];
/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
@@ -791,7 +833,126 @@ fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
(a, warn)
}
fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool) -> Result<()> {
// ── el mapeo por rango (subuid) ──────────────────────────────────────────────────────────────────
//
// EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado
// (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no
// puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su
// propio mapa. Tres síntomas distintos, una sola causa.
//
// LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y
// pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con
// capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca.
//
// El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd
// `/proc/<pid>/ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera,
// hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`.
struct Rango { inicio: u32, cantidad: u32 }
fn leer_subid(fichero: &str, usuario: &str) -> Option<Rango> {
let txt = std::fs::read_to_string(fichero).ok()?;
txt.lines().find_map(|l| {
let mut c = l.split(':');
let (u, i, n) = (c.next()?, c.next()?, c.next()?);
if u != usuario {
return None;
}
Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? })
})
}
/// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece.
struct UsernsMapeado {
tenedor: std::process::Child,
ns_path: String,
ids: u32,
}
impl Drop for UsernsMapeado {
fn drop(&mut self) {
// El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos
// un proceso colgado por cada instancia arrancada.
let _ = self.tenedor.kill();
let _ = self.tenedor.wait();
}
}
/// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de
/// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude".
fn userns_con_rango() -> Result<UsernsMapeado> {
let uid = std::fs::metadata("/proc/self")
.map(|m| std::os::unix::fs::MetadataExt::uid(&m))
.unwrap_or(1000);
let gid = std::fs::metadata("/proc/self")
.map(|m| std::os::unix::fs::MetadataExt::gid(&m))
.unwrap_or(1000);
let usuario = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_default();
let ru = leer_subid("/etc/subuid", &usuario)
.with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?;
let rg = leer_subid("/etc/subgid", &usuario)
.with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?;
let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id()));
std::fs::create_dir_all(&tmp)?;
let listo = tmp.join("listo");
let adelante = tmp.join("adelante");
for f in [&listo, &adelante] {
let _ = std::fs::remove_file(f);
let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?;
if !st.success() {
bail!("no pude crear el fifo {}", f.display());
}
}
let tenedor = Command::new("unshare")
.arg("-U")
.arg("sh")
.arg("-c")
.arg(format!(
"echo r > {l}; read _ < {a}",
l = listo.display(), a = adelante.display()
))
.spawn()
.context("no pude ejecutar `unshare -U` — ¿está util-linux?")?;
let pid = tenedor.id();
let mut guard = UsernsMapeado {
tenedor,
ns_path: format!("/proc/{pid}/ns/user"),
ids: 1 + ru.cantidad,
};
// Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos
// el namespace del padre, que es el del host.
std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?;
let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> {
let out = Command::new(bin)
.args([pid.to_string(), "0".into(), id.to_string(), "1".into(),
"1".into(), r.inicio.to_string(), r.cantidad.to_string()])
.output()
.with_context(|| format!("no pude ejecutar {bin}"))?;
if !out.status.success() {
bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(())
};
mapear("newuidmap", uid, &ru)?;
mapear("newgidmap", gid, &rg)?;
// Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo
// matemos. Lo que importa es que el proceso VIVA, no lo que haga.
let _ = std::fs::remove_file(&listo);
let _ = std::fs::remove_file(&adelante);
guard.ids = 1 + ru.cantidad;
Ok(guard)
}
fn run_instance(
root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool, single_id: bool,
) -> Result<()> {
let (dir, inst) = read_instance(root, id)?;
let (sha, tree) = resolve_image(root, &inst.base)?;
let (grant_args, warns) = grants_to_args(&inst.grants);
@@ -799,20 +960,45 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
// El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
// después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
// que no se declara, no entra (D2, "por defecto NADA").
// El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de
// `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root
// adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con
// `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio
// haría que "no anda adentro" fuese un misterio en vez de una línea.
let mut mapeado: Option<UsernsMapeado> = None;
if !single_id && inst.grants.root {
match userns_con_rango() {
Ok(m) => mapeado = Some(m),
Err(e) => eprintln!(
"⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \
no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \
escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh"
),
}
}
let mut args: Vec<String> = vec![
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
"--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
"--hostname", "qorpa",
"--clearenv",
"--die-with-parent",
].into_iter().map(String::from).collect();
if mapeado.is_none() {
// bwrap crea el suyo, con un solo id.
args.insert(0, "--unshare-user".into());
if inst.grants.root {
args.extend(["--uid", "0", "--gid", "0"].map(String::from));
}
}
if inst.grants.root {
args.extend(["--uid", "0", "--gid", "0"].map(String::from));
for c in CAPS_ROOT {
args.push("--cap-add".into());
args.push((*c).to_string());
}
if inst.grants.nesting {
eprintln!(
"⚠ `nesting` y `root` juntos NO funcionan hoy: con el uid remapeado a 0, un userns \
anidado no puede escribir su uid_map ⇒ pressure-vessel no arranca. Poné \
`root = false` para juegos, o esperá al mapeo por rango (subuid + --userns FD)."
);
// La que permite montar. Sólo con la concesión declarada.
args.push("--cap-add".into());
args.push("CAP_SYS_ADMIN".into());
}
}
@@ -823,7 +1009,11 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
args.push("/".into());
// /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
// `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el
// `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal.
// Un /tmp que no es 1777 no es /tmp.
args.extend(["--proc", "/proc", "--dev", "/dev",
"--perms", "1777", "--tmpfs", "/tmp",
"--dir", "/run/user/0", "--chdir", "/"].map(String::from));
args.extend([
"--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@@ -884,6 +1074,10 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
eprintln!("{w}");
}
if dry_run {
if let Some(m) = &mapeado {
println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids);
println!("# exec 3<{} ⇒ bwrap --userns 3 …", m.ns_path);
}
// Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
let mut linea = String::from("bwrap");
for a in &args {
@@ -899,9 +1093,25 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: b
inst.grants.devices.len(), inst.grants.dirs.len(),
if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "" },
if inst.grants.seal_image { "sellada" } else { "escribible" });
eprintln!(" ids mapeados: {}",
match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() });
let st = Command::new("bwrap").args(&args).status()
.context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
// Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el
// `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell:
// sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar
// exactamente lo que pasa.
let st = match &mapeado {
Some(m) => Command::new("sh")
.arg("-c")
.arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#)
.arg("qorpa")
.arg(&m.ns_path)
.args(&args)
.status()
.context("no pude ejecutar sh/bwrap")?,
None => Command::new("bwrap").args(&args).status()
.context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?,
};
match st.code() {
Some(0) => Ok(()),
Some(c) => std::process::exit(c),
@@ -1225,6 +1435,28 @@ mod tests {
assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
}
#[test]
fn el_rango_de_subuid_se_lee_por_usuario() {
let d = tempfile::tempdir().unwrap();
let f = d.path().join("subuid");
std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap();
let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap();
assert_eq!((r.inicio, r.cantidad), (165536, 65536));
assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none());
}
#[test]
fn las_caps_de_root_no_incluyen_la_de_montar() {
// CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting`
// declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el
// comportamiento por defecto — que es justo lo que D7 prohíbe.
assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}");
// Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad.
for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] {
assert!(CAPS_ROOT.contains(&c), "falta {c}");
}
}
#[test]
fn sha_mal_formado_falla_antes_de_tocar_la_red() {
assert!(normalize_sha256("abc").is_err());