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
221 lines
13 KiB
Bash
Executable File
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
|