#!/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