#!/usr/bin/env bash # ¿El «modo foco» del §6.5 (SDD 26) para de verdad al navegador? — la mitad que SÓLO se mide con root. # # scripts/test-foco-egress.sh --rootfs alpine-minirootfs.tar.gz --artifacts artefactos-nft.tar.gz # # Desde el hub se lanza con `scripts/foco-egress-remoto.sh`, que empaqueta los artefactos VIGENTES. # # La tesis del §6.5 es que el foco NO lo sostiene una extensión —que el propio navegador puede # apagar— sino el cortafuegos del sistema: una regla de egress por **cgroup**. Esto lo comprueba como # lo viviría el usuario: un proceso dentro del cgroup del navegador NO abre una conexión y, control # que TIENE que pasar, el mismo binario en el cgroup de al lado SÍ la abre. # # ⚠ POR QUÉ NO CORRE EN EL HUB. Dos cosas piden privilegio y ninguna se puede fingir: # · crear un cgroup (`/sys/fs/cgroup` es de root; medido: `mkdir` da EPERM incluso dentro de un # userns con CAP_ALL, porque el permiso se chequea contra la jerarquía REAL); # · y `nft -c` no es un chequeo de sintaxis: RESUELVE el path del cgroup contra la máquina viva y # falla con «cgroupv2 path fails» si no existe. # Por eso corre donde somos root (hoy: el LXC prestado dev.gioser.net) y SIEMPRE dentro de `unshare`: # una regla mal puesta en el netns de verdad te saca de la máquina. # # ⚠⚠ Y TODO EL MONTAJE VA EN UN MOUNT NAMESPACE PROPIO, `--propagation private`. Esto no es # prolijidad: `/sys/fs/cgroup` está montado **shared**, así que desmontar la COPIA de un `--rbind` # se PROPAGA al montaje real. Medido el 2026-09-10 y a costa del worker: quedó sin `cgroup2` # montado —dos veces— sin que nada fallara ni avisara; los builds seguían, pero la máquina había # perdido su jerarquía de cgroups. Dentro de un namespace privado no hay nada que desmontar al # final: se va con el proceso. # # El `nft` es el NUESTRO —el artefacto sellado, musl— y por eso hace falta el rootfs alpine: la # máquina donde hay root es Debian/glibc y no tiene su intérprete. set -euo pipefail ROOTFS_TAR=""; ARTIFACTS_TAR=""; WORK="/root/foco-lab"; ROTO=0 while [ $# -gt 0 ]; do case "$1" in --rootfs) ROOTFS_TAR="$2"; shift 2 ;; --artifacts) ARTIFACTS_TAR="$2"; shift 2 ;; --work) WORK="$2"; shift 2 ;; # Rotura A PROPÓSITO: le abre egress TAMBIÉN al cgroup en foco. Con esto el guardián TIENE # que fallar; si pasa, no está mirando nada. Es la mitad que a un guardián le falta siempre. --broken-rules) ROTO=1; shift ;; *) echo "opción desconocida: $1" >&2; exit 2 ;; esac done [ -n "$ROOTFS_TAR" ] && [ -n "$ARTIFACTS_TAR" ] || { echo "faltan --rootfs y --artifacts" >&2; exit 2; } [ "$(id -u)" = "0" ] || { echo "✗ esto necesita root: crea cgroups y aplica reglas nft" >&2; exit 2; } grep -q cgroup2 /proc/mounts || { echo "✗ no hay cgroup2 montado" >&2; exit 2; } CG_RAIZ="$(awk '$3=="cgroup2"{print $2; exit}' /proc/mounts)" CG_FOCO="takana-foco-atuq" # el navegador EN foco: sin egress CG_LIBRE="takana-foco-libre" # el control: mismo binario, egress permitido limpiar() { set +e # Los cgroups son lo ÚNICO que vive fuera del namespace, y un cgroup con procesos adentro no se # borra: primero se vacía moviendo lo que quede a la raíz. for cg in "$CG_RAIZ/$CG_FOCO" "$CG_RAIZ/$CG_LIBRE"; do [ -d "$cg" ] || continue while read -r p; do [ -n "$p" ] && echo "$p" > "$CG_RAIZ/cgroup.procs" 2>/dev/null; done < "$cg/cgroup.procs" rmdir "$cg" 2>/dev/null done # Sin montajes que desarmar: estaban en otro namespace. Pero se comprueba antes de borrar, que # es lo que faltó la primera vez. if grep -q " $WORK/" /proc/mounts; then echo "⚠ quedó algo montado bajo $WORK: NO lo borro (mirá /proc/mounts)" >&2 else rm -rf "$WORK" fi } trap limpiar EXIT [ "$ROTO" = "1" ] && echo "== MODO ROTURA A PROPÓSITO: el reglaset deja salir también al cgroup en foco" echo "== armando el lab en $WORK" rm -rf "$WORK"; mkdir -p "$WORK/rootfs" "$WORK/art" tar xzf "$ROOTFS_TAR" -C "$WORK/rootfs" tar xzf "$ARTIFACTS_TAR" -C "$WORK/art" for a in "$WORK/art"/*; do cp -a "$a"/usr/. "$WORK/rootfs/usr/"; done [ -x "$WORK/rootfs/usr/sbin/nft" ] || { echo "✗ el artefacto no trae usr/sbin/nft" >&2; exit 1; } # ⚠ Que `nft` ARRANQUE se comprueba acá, primero. Si le falta una librería, el reglaset no se aplica # y las dos conexiones fallan — que desde afuera se lee EXACTAMENTE como «el foco funciona». Medido: # la primera corrida empaquetó un `gmp` viejo (sólo `libgmp.a`) y `libnftables.so` murió con # `__gmpz_com: symbol not found`. En el hub no se había notado porque ahí el lab tiene su propia # libgmp y la tapaba: el artefacto parecía autosuficiente y no lo era. if ! chroot "$WORK/rootfs" /usr/sbin/nft --version > "$WORK/version.txt" 2>&1; then echo "✗ nuestro nft no arranca en este rootfs:"; sed 's/^/ /' "$WORK/version.txt" echo " (típico: falta una librería del cierre — mirá los NEEDED de usr/lib/libnftables.so)" exit 1 fi echo " nft: $(cat "$WORK/version.txt")" mkdir -p "$CG_RAIZ/$CG_FOCO" "$CG_RAIZ/$CG_LIBRE" echo " cgroups: $CG_FOCO (en foco) · $CG_LIBRE (control)" cat > "$WORK/rootfs/reglas.nft" < "$WORK/rootfs/adentro.sh" <<'EOF' #!/bin/sh # Corre YA dentro del netns y del mount ns privados, y del chroot. Los cgroups llegan por entorno # (CG_FOCO / CG_LIBRE): chroot conserva el entorno, así que no hace falta interpolar nada. set -u # /sbin en el PATH: en alpine, ip e ifconfig son enlaces de busybox en /sbin, y sin eso el # "ip link set lo up" no existe — el loopback queda caído y NINGUNA conexión sale. La línea base lo # cazó; sin ella, ese silencio se habría contado como «el foco funciona». export PATH=/usr/sbin:/usr/bin:/sbin:/bin LD_LIBRARY_PATH=/usr/lib PUERTO=45999 ip link set lo up 2>/dev/null || ifconfig lo up 2>/dev/null echo "LO $(ip addr show lo 2>/dev/null | tr '\n' ' ' | tr -s ' ')" # Un listener por intento: "nc -l ... -e" atiende UNA conexión y se va, y un listener muerto se lee # igual que una conexión bloqueada. El listener vive en el cgroup permitido, así sus respuestas # salen por "established". intento() { # $1 = cgroup del cliente, $2 = etiqueta sh -c "echo \$\$ > /sys/fs/cgroup/$CG_LIBRE/cgroup.procs; exec nc -l -p $PUERTO -e /bin/true" \ < /dev/null > /dev/null 2>&1 & L=$! sleep 1 sh -c "echo \$\$ > /sys/fs/cgroup/$1/cgroup.procs; exec nc -w 3 127.0.0.1 $PUERTO" \ < /dev/null > /dev/null 2>&1 echo "CONEXION $2 exit=$?" kill $L 2>/dev/null wait $L 2>/dev/null } # LÍNEA BASE, sin ninguna regla puesta: si esto ya falla, el harness está roto y cualquier veredicto # posterior sería un falso positivo del foco («no conectó» se lee como «el cortafuegos funciona»). intento "$CG_FOCO" linea-base nft -f /reglas.nft || { echo "RESULTADO nft-no-aplica"; exit 1; } echo "REGLAS $(nft list ruleset | tr '\n' ' ' | tr -s ' ')" intento "$CG_LIBRE" control intento "$CG_FOCO" foco EOF chmod +x "$WORK/rootfs/adentro.sh" echo "== corriendo dentro de un netns y un mount ns privados" # Las montaduras van ADENTRO del namespace y se van solas: nada que desmontar, nada que propagar. CG_FOCO="$CG_FOCO" CG_LIBRE="$CG_LIBRE" unshare -m --propagation private -n sh -c " mount --rbind /sys '$WORK/rootfs/sys' mount -t proc proc '$WORK/rootfs/proc' exec chroot '$WORK/rootfs' /adentro.sh " > "$WORK/adentro.log" 2>&1 || true SALIDA="$(cat "$WORK/adentro.log")" echo "$SALIDA" | sed 's/^/ /' base="$(echo "$SALIDA" | sed -n 's/^CONEXION linea-base exit=//p')" libre="$(echo "$SALIDA" | sed -n 's/^CONEXION control exit=//p')" foco="$(echo "$SALIDA" | sed -n 's/^CONEXION foco exit=//p')" [ -n "$base" ] && [ -n "$libre" ] && [ -n "$foco" ] || { echo "✗ la corrida no dejó los tres veredictos"; exit 1; } if [ "$base" != "0" ]; then echo "✗ HARNESS ROTO: sin ninguna regla puesta tampoco se pudo conectar (exit=$base)." echo " Nada de lo que sigue mediría el foco: un «no conectó» se leería como éxito." exit 1 fi if [ "$libre" != "0" ]; then echo "✗ CONTROL ROTO: con las reglas puestas, el cgroup PERMITIDO tampoco conectó (exit=$libre)" echo " o sea que el reglaset niega de más: el foco no se distingue de cortar todo." exit 1 fi if [ "$foco" = "0" ]; then echo "✗ el cgroup EN FOCO conectó igual: el cortafuegos no está sosteniendo nada" exit 1 fi echo echo "✓ la línea base conecta: el harness mide algo (exit=0 sin reglas puestas)" echo "✓ en foco no hay salida: el proceso del cgroup del navegador no abrió la conexión (exit=$foco)" echo "✓ y el control PASA: el mismo binario, en el cgroup de al lado, conectó (exit=0)" echo "✓ o sea que el foco lo sostiene una regla del sistema — nada que el navegador pueda apagar"