sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga
1#!/usr/bin/env bash
2# qorpa-preflight.sh — ¿puede ESTA máquina alojar un huésped?
3#
4# ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
5# El ADR 0015 dice, en su §Orden de trabajo 1, que lo PRIMERO es provisionar subuid «porque es lo
6# primero que va a fallar y define si el resto es fácil o difícil». Este script es esa medición,
7# hecha como guardián y no a mano: un huésped depende de cinco capacidades del ENTORNO que no
8# están en ningún grafo (user namespaces sin privilegios, mapeo multi-id, overlayfs sin root, los
9# nodos del borde, y disco), y las cinco fallan de formas que se parecen entre sí desde arriba
10# —«bwrap: Operation not permitted»— pero se arreglan de formas distintas.
11#
12# Es deliberadamente el patrón `yupana preflight`: PREDECIR el hueco de entorno antes de quemar
13# la campaña, en vez de descubrirlo con medio Fedora bajado. Y es deliberadamente RUIDOSO ante la
14# ausencia (CLAUDE.md regla 3): cada prueba dice qué faltó y cómo se repara, nunca sólo "no".
15#
16# ── LOS TRES NIVELES ────────────────────────────────────────────────────────────────────────────
17# BLOQUEA — ninguna instancia arranca en esta máquina.
18# LIMITA — arranca, pero una CLASE concreta de uso falla (se nombra cuál).
19# NOTA — informativo; no afecta a arrancar.
20# Salida: 0 todo verde · 1 hay BLOQUEA · 2 sólo LIMITA. El 2 es una respuesta legítima, no un error:
21# una instancia de sólo-correr-un-binario no necesita subuid.
22#
23# Uso: scripts/qorpa/qorpa-preflight.sh [--raiz /var/lib/hammer/qorpa]
24set -uo pipefail # -e NO: queremos correr TODAS las pruebas y reportar el cuadro entero.
25
26RAIZ="${QORPA_RAIZ:-/var/lib/hammer/qorpa}"
27[ "${1:-}" = "--raiz" ] && { RAIZ="$2"; shift 2; }
28
29BLOQUEA=0; LIMITA=0
30c_ok=$'\033[1;32m'; c_mal=$'\033[1;31m'; c_lim=$'\033[1;33m'; c_dim=$'\033[2m'; c_off=$'\033[0m'
31titulo() { printf '\n%s── %s %s\n' $'\033[1;34m' "$*" "$c_off"; }
32ok() { printf ' %s✓%s %s\n' "$c_ok" "$c_off" "$*"; }
33nota() { printf ' %s·%s %s\n' "$c_dim" "$c_off" "$*"; }
34mal() { printf ' %s✗ BLOQUEA%s %s\n' "$c_mal" "$c_off" "$*"; BLOQUEA=$((BLOQUEA+1)); }
35lim() { printf ' %s! LIMITA%s %s\n' "$c_lim" "$c_off" "$*"; LIMITA=$((LIMITA+1)); }
36comofix(){ printf ' %s↳ %s%s\n' "$c_dim" "$*" "$c_off"; }
37
38echo "qorpa preflight — $(hostname) · kernel $(uname -r) · usuario $(id -un) ($(id -u))"
39
40# ────────────────────────────────────────────────────────────────────────────────────────────────
41titulo "1. el kernel trae lo necesario"
42CONFIG=""
43if [ -r /proc/config.gz ]; then CONFIG="zcat /proc/config.gz"
44elif [ -r "/boot/config-$(uname -r)" ]; then CONFIG="cat /boot/config-$(uname -r)"
45fi
46if [ -z "$CONFIG" ]; then
47 nota "sin .config legible (ni /proc/config.gz ni /boot/config-\$(uname -r)) — se mide por runtime"
48else
49 # Lo que el ADR §Lo que ya está construido enumera de linux-metal.toml:111-114.
50 for sym in USER_NS OVERLAY_FS SECCOMP SECURITY_LANDLOCK; do
51 v="$($CONFIG | grep -E "^CONFIG_${sym}=" | head -1)"
52 case "$v" in
53 *=y) ok "CONFIG_${sym}=y" ;;
54 *=m) nota "CONFIG_${sym}=m — módulo. NO es limitación por sí sola: quien manda es la prueba
55 funcional de más abajo, que lo monta de verdad o no." ;;
56 *) if [ "$sym" = SECURITY_LANDLOCK ]; then
57 lim "CONFIG_${sym} apagado — la jaula cae a seccomp+namespaces, sin política de grano fino"
58 else
59 mal "CONFIG_${sym} apagado — sin esto no hay instancia"
60 fi ;;
61 esac
62 done
63fi
64
65max_ns="$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo '?')"
66if [ "$max_ns" = 0 ]; then
67 mal "user.max_user_namespaces = 0 — los userns están apagados por sysctl"
68 comofix "sysctl -w user.max_user_namespaces=15000"
69else
70 ok "user.max_user_namespaces = $max_ns"
71fi
72if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
73 if [ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = 1 ]; then
74 ok "kernel.unprivileged_userns_clone = 1"
75 else
76 mal "kernel.unprivileged_userns_clone = 0 — userns sin privilegios prohibidos"
77 comofix "sysctl -w kernel.unprivileged_userns_clone=1"
78 fi
79fi
80
81# ────────────────────────────────────────────────────────────────────────────────────────────────
82titulo "2. user namespaces sin privilegios"
83if timeout 10 unshare -Ur true 2>/dev/null; then
84 ok "unshare -Ur — userns con mapeo de un solo id"
85else
86 mal "unshare -Ur falla — sin esto NADA de qorpa funciona"
87fi
88# D6: pressure-vessel crea un userns DESDE DENTRO del nuestro. El ADR lo marca como «verificar, no
89# asumir», y es barato verificarlo acá en vez de con un juego real.
90if timeout 10 unshare -Ur unshare -Ur true 2>/dev/null; then
91 ok "userns ANIDADO — pressure-vessel de Valve puede crear el suyo adentro (ADR D6)"
92else
93 lim "userns anidado falla — Steam/pressure-vessel (ADR D6) no arrancaría; el resto sí"
94fi
95if command -v bwrap >/dev/null 2>&1; then
96 if timeout 10 bwrap --unshare-user --ro-bind / / /bin/true 2>/dev/null; then
97 ok "bwrap --unshare-user (la pieza que ya es dep del lab de build)"
98 else
99 mal "bwrap presente pero no puede crear el userns"
100 fi
101else
102 lim "no hay bwrap en el PATH del host — presente en el corpus (recipes/bwrap.toml), falta hidratarlo"
103fi
104
105# ────────────────────────────────────────────────────────────────────────────────────────────────
106titulo "3. subuid — el mapeo multi-id (ADR §NO-resuelve 2, «la primera cosa que va a fallar»)"
107USUARIO="$(id -un)"
108leer_rango() { # $1 = fichero, $2 = usuario → imprime "inicio cantidad" o nada
109 [ -r "$1" ] || return 1
110 awk -F: -v u="$2" '$1==u {print $2, $3; exit}' "$1"
111}
112RU="$(leer_rango /etc/subuid "$USUARIO")"
113RG="$(leer_rango /etc/subgid "$USUARIO")"
114if [ -z "$RU" ] || [ -z "$RG" ]; then
115 lim "sin rango en /etc/subuid|/etc/subgid para '$USUARIO' — dnf/rpm rompen al hacer chown (tipo 1)"
116 comofix "usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USUARIO"
117else
118 ok "rango declarado: subuid=[$RU] subgid=[$RG]"
119fi
120
121herramienta_ok=1
122for h in newuidmap newgidmap; do
123 ruta="$(command -v $h 2>/dev/null)"
124 if [ -z "$ruta" ]; then
125 lim "$h no está — sin él el rango declarado es papel mojado"
126 comofix "receta shadow.toml ya lo instala; en el host, paquete 'shadow'"
127 herramienta_ok=0; continue
128 fi
129 caps="$(getcap "$ruta" 2>/dev/null)"
130 perm="$(stat -c %a "$ruta")"
131 if printf '%s' "$caps" | grep -q 'cap_set[ug]id'; then
132 ok "$ruta con $(printf '%s' "$caps" | sed 's/.* //')"
133 elif [ "${perm:0:1}" = 4 ] || [ "$(( 8#$perm & 04000 ))" -ne 0 ] 2>/dev/null; then
134 ok "$ruta setuid-root (modo $perm)"
135 else
136 cap=cap_setuid; mapa=uid_map
137 [ "$h" = newgidmap ] && { cap=cap_setgid; mapa=gid_map; }
138 lim "$ruta SIN setuid y SIN capability (modo $perm) — no puede escribir el $mapa"
139 comofix "sudo setcap ${cap}+ep $ruta"
140 herramienta_ok=0
141 fi
142done
143
144# La prueba FUNCIONAL. Las tres de arriba son condiciones necesarias declaradas; ésta es la que
145# realmente responde «¿puedo mapear el rango?». Se hace con un hijo en un userns SIN mapa que
146# espera en un fifo mientras el padre le escribe el mapa desde fuera — que es exactamente la
147# coreografía que hace un runtime de contenedores.
148if [ -n "$RU" ] && [ -n "$RG" ] && [ "$herramienta_ok" = 1 ]; then
149 d="$(mktemp -d)"; mkfifo "$d/listo" "$d/adelante"
150 set -- $RU; ui=$1; uc=$2
151 set -- $RG; gi=$1; gc=$2
152 # SIN `timeout` acá, y no es un descuido: `timeout` FORKEA, así que `$!` sería SU pid y no el
153 # del proceso que entró al namespace ⇒ newuidmap apuntaría al proceso equivocado y el kernel
154 # respondería "Operation not permitted" — un falso negativo que se lee igual que un fallo real.
155 # (Costó una medición: el guardián decía que subuid no andaba cuando a mano andaba.)
156 # El corte por tiempo lo ponen los `timeout` de los fifos de abajo.
157 unshare -U sh -c "echo r > $d/listo; read _ < $d/adelante; id -u > $d/uid; wc -l < /proc/self/uid_map > $d/lineas" &
158 hijo=$!
159 if timeout 10 head -c 1 "$d/listo" >/dev/null 2>&1; then
160 err="$( { newuidmap "$hijo" 0 "$(id -u)" 1 1 "$ui" "$uc" && \
161 newgidmap "$hijo" 0 "$(id -g)" 1 1 "$gi" "$gc"; } 2>&1 )"
162 rc=$?
163 timeout 5 sh -c "echo g > $d/adelante" 2>/dev/null
164 wait "$hijo" 2>/dev/null
165 if [ "$rc" = 0 ] && [ "$(cat "$d/uid" 2>/dev/null)" = 0 ] && [ "$(cat "$d/lineas" 2>/dev/null)" -ge 2 ] 2>/dev/null; then
166 ok "PRUEBA FUNCIONAL: el rango se mapeó ($uc uids) — dnf/rpm pueden chownear"
167 else
168 lim "PRUEBA FUNCIONAL falla: ${err:-mapa no aplicado} — root-adentro sí, multi-id no (tipo 1 cojo)"
169 fi
170 else
171 kill "$hijo" 2>/dev/null; lim "PRUEBA FUNCIONAL: el hijo no llegó al fifo (userns no se creó)"
172 fi
173 rm -rf "$d"
174else
175 nota "prueba funcional omitida — falta una condición previa de arriba"
176fi
177
178# ────────────────────────────────────────────────────────────────────────────────────────────────
179titulo "4. overlayfs sin privilegios (la capa mutable de D3)"
180if timeout 15 unshare -Urm sh -c '
181 mkdir -p /tmp/.qorpa-ovl 2>/dev/null || exit 90
182 mount -t tmpfs none /tmp/.qorpa-ovl || exit 91
183 mkdir -p /tmp/.qorpa-ovl/lower /tmp/.qorpa-ovl/upper /tmp/.qorpa-ovl/work /tmp/.qorpa-ovl/merged
184 mount -t overlay overlay -o lowerdir=/tmp/.qorpa-ovl/lower,upperdir=/tmp/.qorpa-ovl/upper,workdir=/tmp/.qorpa-ovl/work /tmp/.qorpa-ovl/merged
185' 2>/dev/null; then
186 ok "overlay montado dentro del userns — el upper/ de la instancia es viable sin root"
187else
188 lim "overlayfs no monta sin privilegios — la instancia queda de sólo lectura (o pide fuse-overlayfs)"
189 comofix "kernel ≥5.11 con CONFIG_OVERLAY_FS y userns; si no, fuse-overlayfs como plan B"
190fi
191
192# ────────────────────────────────────────────────────────────────────────────────────────────────
193titulo "5. el borde (D2) — qué hay para conceder"
194soc_wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/${WAYLAND_DISPLAY:-wayland-0}"
195[ -S "$soc_wl" ] && ok "wayland: $soc_wl" || nota "wayland: sin socket (no hay sesión gráfica acá)"
196soc_pw="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pipewire-0"
197[ -S "$soc_pw" ] && ok "pipewire: $soc_pw" || nota "pipewire: sin socket"
198if compgen -G "/dev/dri/*" >/dev/null; then ok "GPU: $(echo /dev/dri/* | tr ' ' ' ')"; else nota "GPU: sin /dev/dri (sin aceleración adentro)"; fi
199[ -e /dev/ntsync ] && ok "/dev/ntsync (juegos)" || nota "/dev/ntsync ausente — Proton cae a esync/fsync"
200[ -r /etc/resolv.conf ] && ok "/etc/resolv.conf legible (segundo fallo clásico de la 1ª corrida)" \
201 || lim "/etc/resolv.conf ilegible — dnf no resuelve nombres adentro"
202
203# ────────────────────────────────────────────────────────────────────────────────────────────────
204titulo "6. disco (§NO-resuelve 5: un rootfs son 1-2 G y no lo alcanza store-gc)"
205destino="$RAIZ"; while [ ! -d "$destino" ] && [ "$destino" != / ]; do destino="$(dirname "$destino")"; done
206libre_k="$(df -Pk "$destino" | awk 'NR==2{print $4}')"
207libre_g=$(( libre_k / 1024 / 1024 ))
208if [ "$libre_g" -ge 8 ]; then ok "$destino: ${libre_g} G libres (raíz qorpa: $RAIZ)"
209elif [ "$libre_g" -ge 3 ]; then lim "$destino: sólo ${libre_g} G libres — alcanza para UNA imagen y nada más"
210else mal "$destino: ${libre_g} G libres — no entra ni un rootfs base"; fi
211
212# ────────────────────────────────────────────────────────────────────────────────────────────────
213titulo "veredicto"
214if [ "$BLOQUEA" -gt 0 ]; then
215 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
216elif [ "$LIMITA" -gt 0 ]; then
217 printf ' %sLIMITA %d%s — arranca, pero con las clases de uso marcadas arriba recortadas.\n' "$c_lim" "$LIMITA" "$c_off"; exit 2
218else
219 printf ' %stodo verde%s — esta máquina puede alojar una instancia qorpa.\n' "$c_ok" "$c_off"; exit 0
220fi