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:
+246
-14
@@ -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 { "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());
|
||||
|
||||
Reference in New Issue
Block a user