Files
takana/scripts/qorpa/qorpa-preflight.sh
T
SergioandClaude Opus 5 89da9c822f 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
2026-09-03 05:27:03 +00:00

221 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# qorpa-preflight.sh — ¿puede ESTA máquina alojar un huésped?
#
# ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
# El ADR 0015 dice, en su §Orden de trabajo 1, que lo PRIMERO es provisionar subuid «porque es lo
# primero que va a fallar y define si el resto es fácil o difícil». Este script es esa medición,
# hecha como guardián y no a mano: un huésped depende de cinco capacidades del ENTORNO que no
# están en ningún grafo (user namespaces sin privilegios, mapeo multi-id, overlayfs sin root, los
# nodos del borde, y disco), y las cinco fallan de formas que se parecen entre sí desde arriba
# —«bwrap: Operation not permitted»— pero se arreglan de formas distintas.
#
# Es deliberadamente el patrón `yupana preflight`: PREDECIR el hueco de entorno antes de quemar
# la campaña, en vez de descubrirlo con medio Fedora bajado. Y es deliberadamente RUIDOSO ante la
# ausencia (CLAUDE.md regla 3): cada prueba dice qué faltó y cómo se repara, nunca sólo "no".
#
# ── LOS TRES NIVELES ────────────────────────────────────────────────────────────────────────────
# BLOQUEA — ninguna instancia arranca en esta máquina.
# LIMITA — arranca, pero una CLASE concreta de uso falla (se nombra cuál).
# NOTA — informativo; no afecta a arrancar.
# Salida: 0 todo verde · 1 hay BLOQUEA · 2 sólo LIMITA. El 2 es una respuesta legítima, no un error:
# una instancia de sólo-correr-un-binario no necesita subuid.
#
# Uso: scripts/qorpa/qorpa-preflight.sh [--raiz /var/lib/hammer/qorpa]
set -uo pipefail # -e NO: queremos correr TODAS las pruebas y reportar el cuadro entero.
RAIZ="${QORPA_RAIZ:-/var/lib/hammer/qorpa}"
[ "${1:-}" = "--raiz" ] && { RAIZ="$2"; shift 2; }
BLOQUEA=0; LIMITA=0
c_ok=$'\033[1;32m'; c_mal=$'\033[1;31m'; c_lim=$'\033[1;33m'; c_dim=$'\033[2m'; c_off=$'\033[0m'
titulo() { printf '\n%s── %s %s\n' $'\033[1;34m' "$*" "$c_off"; }
ok() { printf ' %s✓%s %s\n' "$c_ok" "$c_off" "$*"; }
nota() { printf ' %s·%s %s\n' "$c_dim" "$c_off" "$*"; }
mal() { printf ' %s✗ BLOQUEA%s %s\n' "$c_mal" "$c_off" "$*"; BLOQUEA=$((BLOQUEA+1)); }
lim() { printf ' %s! LIMITA%s %s\n' "$c_lim" "$c_off" "$*"; LIMITA=$((LIMITA+1)); }
comofix(){ printf ' %s↳ %s%s\n' "$c_dim" "$*" "$c_off"; }
echo "qorpa preflight — $(hostname) · kernel $(uname -r) · usuario $(id -un) ($(id -u))"
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "1. el kernel trae lo necesario"
CONFIG=""
if [ -r /proc/config.gz ]; then CONFIG="zcat /proc/config.gz"
elif [ -r "/boot/config-$(uname -r)" ]; then CONFIG="cat /boot/config-$(uname -r)"
fi
if [ -z "$CONFIG" ]; then
nota "sin .config legible (ni /proc/config.gz ni /boot/config-\$(uname -r)) — se mide por runtime"
else
# Lo que el ADR §Lo que ya está construido enumera de linux-metal.toml:111-114.
for sym in USER_NS OVERLAY_FS SECCOMP SECURITY_LANDLOCK; do
v="$($CONFIG | grep -E "^CONFIG_${sym}=" | head -1)"
case "$v" in
*=y) ok "CONFIG_${sym}=y" ;;
*=m) nota "CONFIG_${sym}=m — módulo. NO es limitación por sí sola: quien manda es la prueba
funcional de más abajo, que lo monta de verdad o no." ;;
*) if [ "$sym" = SECURITY_LANDLOCK ]; then
lim "CONFIG_${sym} apagado — la jaula cae a seccomp+namespaces, sin política de grano fino"
else
mal "CONFIG_${sym} apagado — sin esto no hay instancia"
fi ;;
esac
done
fi
max_ns="$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo '?')"
if [ "$max_ns" = 0 ]; then
mal "user.max_user_namespaces = 0 — los userns están apagados por sysctl"
comofix "sysctl -w user.max_user_namespaces=15000"
else
ok "user.max_user_namespaces = $max_ns"
fi
if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
if [ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = 1 ]; then
ok "kernel.unprivileged_userns_clone = 1"
else
mal "kernel.unprivileged_userns_clone = 0 — userns sin privilegios prohibidos"
comofix "sysctl -w kernel.unprivileged_userns_clone=1"
fi
fi
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "2. user namespaces sin privilegios"
if timeout 10 unshare -Ur true 2>/dev/null; then
ok "unshare -Ur — userns con mapeo de un solo id"
else
mal "unshare -Ur falla — sin esto NADA de qorpa funciona"
fi
# D6: pressure-vessel crea un userns DESDE DENTRO del nuestro. El ADR lo marca como «verificar, no
# asumir», y es barato verificarlo acá en vez de con un juego real.
if timeout 10 unshare -Ur unshare -Ur true 2>/dev/null; then
ok "userns ANIDADO — pressure-vessel de Valve puede crear el suyo adentro (ADR D6)"
else
lim "userns anidado falla — Steam/pressure-vessel (ADR D6) no arrancaría; el resto sí"
fi
if command -v bwrap >/dev/null 2>&1; then
if timeout 10 bwrap --unshare-user --ro-bind / / /bin/true 2>/dev/null; then
ok "bwrap --unshare-user (la pieza que ya es dep del lab de build)"
else
mal "bwrap presente pero no puede crear el userns"
fi
else
lim "no hay bwrap en el PATH del host — presente en el corpus (recipes/bwrap.toml), falta hidratarlo"
fi
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "3. subuid — el mapeo multi-id (ADR §NO-resuelve 2, «la primera cosa que va a fallar»)"
USUARIO="$(id -un)"
leer_rango() { # $1 = fichero, $2 = usuario → imprime "inicio cantidad" o nada
[ -r "$1" ] || return 1
awk -F: -v u="$2" '$1==u {print $2, $3; exit}' "$1"
}
RU="$(leer_rango /etc/subuid "$USUARIO")"
RG="$(leer_rango /etc/subgid "$USUARIO")"
if [ -z "$RU" ] || [ -z "$RG" ]; then
lim "sin rango en /etc/subuid|/etc/subgid para '$USUARIO' — dnf/rpm rompen al hacer chown (tipo 1)"
comofix "usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USUARIO"
else
ok "rango declarado: subuid=[$RU] subgid=[$RG]"
fi
herramienta_ok=1
for h in newuidmap newgidmap; do
ruta="$(command -v $h 2>/dev/null)"
if [ -z "$ruta" ]; then
lim "$h no está — sin él el rango declarado es papel mojado"
comofix "receta shadow.toml ya lo instala; en el host, paquete 'shadow'"
herramienta_ok=0; continue
fi
caps="$(getcap "$ruta" 2>/dev/null)"
perm="$(stat -c %a "$ruta")"
if printf '%s' "$caps" | grep -q 'cap_set[ug]id'; then
ok "$ruta con $(printf '%s' "$caps" | sed 's/.* //')"
elif [ "${perm:0:1}" = 4 ] || [ "$(( 8#$perm & 04000 ))" -ne 0 ] 2>/dev/null; then
ok "$ruta setuid-root (modo $perm)"
else
cap=cap_setuid; mapa=uid_map
[ "$h" = newgidmap ] && { cap=cap_setgid; mapa=gid_map; }
lim "$ruta SIN setuid y SIN capability (modo $perm) — no puede escribir el $mapa"
comofix "sudo setcap ${cap}+ep $ruta"
herramienta_ok=0
fi
done
# La prueba FUNCIONAL. Las tres de arriba son condiciones necesarias declaradas; ésta es la que
# realmente responde «¿puedo mapear el rango?». Se hace con un hijo en un userns SIN mapa que
# espera en un fifo mientras el padre le escribe el mapa desde fuera — que es exactamente la
# coreografía que hace un runtime de contenedores.
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
# 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" && \
newgidmap "$hijo" 0 "$(id -g)" 1 1 "$gi" "$gc"; } 2>&1 )"
rc=$?
timeout 5 sh -c "echo g > $d/adelante" 2>/dev/null
wait "$hijo" 2>/dev/null
if [ "$rc" = 0 ] && [ "$(cat "$d/uid" 2>/dev/null)" = 0 ] && [ "$(cat "$d/lineas" 2>/dev/null)" -ge 2 ] 2>/dev/null; then
ok "PRUEBA FUNCIONAL: el rango se mapeó ($uc uids) — dnf/rpm pueden chownear"
else
lim "PRUEBA FUNCIONAL falla: ${err:-mapa no aplicado} — root-adentro sí, multi-id no (tipo 1 cojo)"
fi
else
kill "$hijo" 2>/dev/null; lim "PRUEBA FUNCIONAL: el hijo no llegó al fifo (userns no se creó)"
fi
rm -rf "$d"
else
nota "prueba funcional omitida — falta una condición previa de arriba"
fi
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "4. overlayfs sin privilegios (la capa mutable de D3)"
if timeout 15 unshare -Urm sh -c '
mkdir -p /tmp/.qorpa-ovl 2>/dev/null || exit 90
mount -t tmpfs none /tmp/.qorpa-ovl || exit 91
mkdir -p /tmp/.qorpa-ovl/lower /tmp/.qorpa-ovl/upper /tmp/.qorpa-ovl/work /tmp/.qorpa-ovl/merged
mount -t overlay overlay -o lowerdir=/tmp/.qorpa-ovl/lower,upperdir=/tmp/.qorpa-ovl/upper,workdir=/tmp/.qorpa-ovl/work /tmp/.qorpa-ovl/merged
' 2>/dev/null; then
ok "overlay montado dentro del userns — el upper/ de la instancia es viable sin root"
else
lim "overlayfs no monta sin privilegios — la instancia queda de sólo lectura (o pide fuse-overlayfs)"
comofix "kernel ≥5.11 con CONFIG_OVERLAY_FS y userns; si no, fuse-overlayfs como plan B"
fi
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "5. el borde (D2) — qué hay para conceder"
soc_wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/${WAYLAND_DISPLAY:-wayland-0}"
[ -S "$soc_wl" ] && ok "wayland: $soc_wl" || nota "wayland: sin socket (no hay sesión gráfica acá)"
soc_pw="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pipewire-0"
[ -S "$soc_pw" ] && ok "pipewire: $soc_pw" || nota "pipewire: sin socket"
if compgen -G "/dev/dri/*" >/dev/null; then ok "GPU: $(echo /dev/dri/* | tr ' ' ' ')"; else nota "GPU: sin /dev/dri (sin aceleración adentro)"; fi
[ -e /dev/ntsync ] && ok "/dev/ntsync (juegos)" || nota "/dev/ntsync ausente — Proton cae a esync/fsync"
[ -r /etc/resolv.conf ] && ok "/etc/resolv.conf legible (segundo fallo clásico de la 1ª corrida)" \
|| lim "/etc/resolv.conf ilegible — dnf no resuelve nombres adentro"
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "6. disco (§NO-resuelve 5: un rootfs son 1-2 G y no lo alcanza store-gc)"
destino="$RAIZ"; while [ ! -d "$destino" ] && [ "$destino" != / ]; do destino="$(dirname "$destino")"; done
libre_k="$(df -Pk "$destino" | awk 'NR==2{print $4}')"
libre_g=$(( libre_k / 1024 / 1024 ))
if [ "$libre_g" -ge 8 ]; then ok "$destino: ${libre_g} G libres (raíz qorpa: $RAIZ)"
elif [ "$libre_g" -ge 3 ]; then lim "$destino: sólo ${libre_g} G libres — alcanza para UNA imagen y nada más"
else mal "$destino: ${libre_g} G libres — no entra ni un rootfs base"; fi
# ────────────────────────────────────────────────────────────────────────────────────────────────
titulo "veredicto"
if [ "$BLOQUEA" -gt 0 ]; then
printf ' %sBLOQUEA %d · LIMITA %d%s — esta máquina NO puede alojar una instancia todavía.\n' "$c_mal" "$BLOQUEA" "$LIMITA" "$c_off"; exit 1
elif [ "$LIMITA" -gt 0 ]; then
printf ' %sLIMITA %d%s — arranca, pero con las clases de uso marcadas arriba recortadas.\n' "$c_lim" "$LIMITA" "$c_off"; exit 2
else
printf ' %stodo verde%s — esta máquina puede alojar una instancia qorpa.\n' "$c_ok" "$c_off"; exit 0
fi