diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index 883b49b7..b4702ace 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -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, @@ -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, Vec) { (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//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 { + 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 { + 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 = 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 = 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 { "sí" }, 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()); diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index cb4aba57..2a507bf3 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -310,12 +310,12 @@ arranca bajo Landlock** (D6). Por eso `nesting = true` cuesta la política de fi pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no puso. -**Conflicto 2 — `root` adentro y anidar se pelean.** Medido: con el uid remapeado a 0 (`--uid 0`), un -userns anidado **no puede escribir su `uid_map`**. Sin remapear, anida bien pero el gestor de -paquetes se queja. ⇒ la instancia de juegos y la de gestor de paquetes quieren mapeos **opuestos**, -y el manifiesto lo declara (`root`, encendido por defecto). Las dos mitades se curan con lo mismo que -el impuesto de §NO-resuelve 2: **un rango real de subuid** mapeado con `newuidmap` y pasado a bwrap -con `--userns FD`. Ése es hoy el ticket que más desbloquea del ADR entero. +**Conflicto 2 — ~~`root` adentro y anidar se pelean~~, RESUELTO el mismo día.** Con el userns de un +solo id que crea bwrap, `--uid 0` impedía que un userns anidado escribiera su `uid_map`: la +instancia de juegos y la de gestor de paquetes querían mapeos **opuestos**. Con el mapeo por rango +de §NO-resuelve 2 el conflicto desaparece —anida **con `root = true`**, verificado— y `root` deja de +ser una elección incómoda. El campo se queda en el manifiesto porque sigue describiendo quién sos +adentro, no porque haya que elegir entre dos males. **Y un detalle que ordena las expectativas:** bwrap **tira todas las capabilities**, así que dentro de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios, @@ -334,23 +334,30 @@ Se escriben acá para que no se descubran en producción. capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario. Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR. -2. **UID mapping — MEDIDO 2026-09-03, y el veredicto es mejor de lo que decía este párrafo.** - Sin userns privilegiados, `root` adentro es tu UID afuera. La instancia ve `uid_map: 0 1001 1` y - `setgroups: deny`, y eso rompe todo lo que quiera *otro* id. Medido en las dos imágenes curadas: +2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda + por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt` + (no podía bajar a `_apt`), `pacman` (no podía chownear su descarga a `alpm`) y el userns anidado + de pressure-vessel. **Tres síntomas, una causa.** - | gestor | qué pasa | severidad | - |---|---|---| - | `apt` (Ubuntu 24.04) | el método http hace `setgroups` para bajar a `_apt` ⇒ `Operation not permitted` | **para**, pero se saltea con `-o APT::Sandbox::User=root`, y entonces baja 34 MB, instala y corre los triggers de dpkg enteros. Único residuo: `W: chown to root:adm … failed`, un **aviso** | - | `pacman` (Arch) | chownea el directorio de descarga al usuario `alpm` ⇒ `Invalid argument` | **para en duro**; con `DownloadUser` comentado sincroniza, y tras `pacman-key --init && --populate` instala y el binario corre | + La cura entera —`hammer qorpa run` la aplica sola cuando puede— tiene **tres partes, y ninguna + sobra**: - ⇒ **subuid no es un muro, es un impuesto.** Un solo id alcanza para instalar paquetes reales en - los dos gestores; lo que rompe es el `chown`/`setgroups` a *otro* id, y cada gestor lo hace en un - sitio distinto. Lo que sigue faltando para quitar el impuesto: `newuidmap`/`newgidmap` con - `cap_setuid`/`cap_setgid` (`recipes/shadow.toml` los INSTALA pero no los provisiona — el mismo - hueco espera en la distro propia) **y** una vía para que bwrap use ese mapa: bwrap crea el userns - con un solo id a propósito y no llama a `newuidmap`, así que hay que crear el namespace aparte, - mapear el rango y pasárselo con `--userns FD`. `scripts/qorpa/qorpa-preflight.sh` ya mide las dos - mitades. + | pieza | por qué | + |---|---| + | `setcap cap_setuid+ep newuidmap` (y `cap_setgid` en `newgidmap`) | el binario está en el corpus (`recipes/shadow.toml`) pero **sin provisionar**; sin la capability no puede escribir el mapa | + | crear el userns **nosotros**, mapear el rango de `/etc/subuid` 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 | + | devolver las **capabilities** dentro del namespace (`--cap-add`) | **bwrap las tira todas**, y en Linux ser root es tener `CAP_SETUID`, no tener uid 0. Sin esto `apt` sigue sin poder `seteuid(42)` — un síntoma que *parecía* de subuid y no lo era | + + Con las tres: `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. Las capabilities + concedidas son seguras por construcción: dentro de un user namespace sólo alcanzan a lo que ese + namespace posee, o sea nuestros propios subuid. `CAP_SYS_ADMIN` queda fuera y cuelga de `nesting`. + + **Y una consecuencia que sólo aparece después:** el `upper/` pasa a contener ficheros de los + subuid (apt deja los suyos con uid 165577) que **nuestro uid normal no puede borrar** ⇒ + `recreate` y la poda tienen que entrar a un userns mapeado para limpiar + (`unshare -U --map-auto -r rm -rf`). Quitar el impuesto mueve el problema, no lo evapora. 3. **Segunda cadena de suministro, sin ninguna garantía de hammer.** Sin repro, sin cierre firmado, sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3 diff --git a/recipes/incoming-cosmic/dbus-shared.toml b/recipes/dbus-shared.toml similarity index 100% rename from recipes/incoming-cosmic/dbus-shared.toml rename to recipes/dbus-shared.toml diff --git a/recipes/incoming-cosmic/libsndfile.toml b/recipes/libsndfile.toml similarity index 100% rename from recipes/incoming-cosmic/libsndfile.toml rename to recipes/libsndfile.toml diff --git a/recipes/incoming-cosmic/pipewire.toml b/recipes/pipewire.toml similarity index 100% rename from recipes/incoming-cosmic/pipewire.toml rename to recipes/pipewire.toml diff --git a/recipes/incoming-cosmic/pulseaudio.toml b/recipes/pulseaudio.toml similarity index 100% rename from recipes/incoming-cosmic/pulseaudio.toml rename to recipes/pulseaudio.toml diff --git a/scripts/qorpa/qorpa-preflight.sh b/scripts/qorpa/qorpa-preflight.sh index 52f36ff6..5321e3cf 100755 --- a/scripts/qorpa/qorpa-preflight.sh +++ b/scripts/qorpa/qorpa-preflight.sh @@ -149,7 +149,12 @@ if [ -n "$RU" ] && [ -n "$RG" ] && [ "$herramienta_ok" = 1 ]; then d="$(mktemp -d)"; mkfifo "$d/listo" "$d/adelante" set -- $RU; ui=$1; uc=$2 set -- $RG; gi=$1; gc=$2 - timeout 20 unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" & + # SIN `timeout` acá, y no es un descuido: `timeout` FORKEA, así que `$!` sería SU pid y no el + # del proceso que entró al namespace ⇒ newuidmap apuntaría al proceso equivocado y el kernel + # respondería "Operation not permitted" — un falso negativo que se lee igual que un fallo real. + # (Costó una medición: el guardián decía que subuid no andaba cuando a mano andaba.) + # El corte por tiempo lo ponen los `timeout` de los fifos de abajo. + unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" & hijo=$! if timeout 10 head -c 1 "$d/listo" >/dev/null 2>&1; then err="$( { newuidmap "$hijo" 0 "$(id -u)" 1 1 "$ui" "$uc" && \