setuidgid: receta sellada y declarada — el ultimo applet sin dueno cae
Sellada en b3:0c018b1a y declarada en los tres perfiles cuyas cards la invocan:
servidor, servidor-minimo y escritorio-mirada (ese ultimo por shuma-daemon).
Con esto, de los CINCO applets que el TSV tenia INDULTADOS por no tener dueno:
halt/poweroff/reboot → los cubren los shims de arje-botones.sh, y desde hoy
el de poweroff baja los servicios de verdad
switch_root → lo trae util-linux en /sbin/switch_root (verificado)
setuidgid → esto
⇒ ninguno obliga ya a embarcar busybox.
Dos tropiezos que quedan escritos porque son pies con los que cualquiera se
vuelve a tropezar:
1. EL PIN TIENE QUE INCLUIR EL Cargo.lock, no solo el crate. Apunte primero al
commit que anade crates/setuidgid y el build murio con «cannot update the
lock file ... because --locked was passed». El build es hermetico
(--locked --offline), asi que el lockfile del arbol fijado tiene que YA
traer la entrada del crate nuevo — y yo la habia dejado en el commit
SIGUIENTE para no mezclar.
2. EL SANDBOX DE BUILD CORRE COMO uid=1001, NO COMO ROOT (medido). Mi primer
guardian probaba la bajada de privilegios de verdad y era IMPOSIBLE por
construccion, no por un defecto. Ahora comprueba lo que si se puede a ese
uid — y la pieza fuerte es la NEGATIVA: que sin ser root se plante con
rc=127 en vez de seguir, que es justo la mitad que un atacante probaria
primero. La bajada esta verificada FUERA del sandbox contra el applet de
busybox como control, y queda dicho en la receta en vez de fingir un control
que el sandbox no puede correr:
busybox: uid=74(sshd) gid=74(sshd) groups=74(sshd)
nuestro: uid=74(sshd) gid=74(sshd) groups=74(sshd)
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
2 archivos · +116 −0
- docs/state/targets.toml +18 −0
- recipes/setuidgid.toml +98 −0
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 170b6dad..7a6438fc 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -576,6 +576,12 @@ descripcion = "rootfs del USB mirada: compositor Wayland propio sobre mesa softw
# el DRM y muere con el dispositivo ocupado.
servicios = ["seatd", "mirada", "shuma-daemon", "shuma-gateway"]
paquetes = [
+ # ── BAJAR PRIVILEGIOS (2026-10-22) ───────────────────────────────────────────────────────────
+ # `setuidgid` lo invocan las cards de servicio de este perfil por ruta absoluta: lo necesitan
+ # `gitea`, `minga`, `pacha`, `pacha-secretos` y `shuma-daemon`, porque esos daemons se niegan a
+ # correr como root y arje-zero encarna sus cards COMO root. Hasta hoy lo daba el applet de
+ # busybox y era el ÚLTIMO sin dueño propio; ahora es un binario de 330 KB, estático y sólo libc.
+ "setuidgid",
"expat-shared", "zlib-shared", "libffi-shared",
"mesa-swrast", "wayland", "libdrm", "seatd", "libinput", "libudev-zero",
"libxkbcommon", "pixman", "linux-pam", "mtdev", "libevdev",
@@ -2907,6 +2913,12 @@ hereda = ["cli"]
# sin hora, sin cortafuegos y sin latido. Cuenta esperada al escribirlas: **5 `wanted`**. Si al mirar
# `build-state.json` aparece un sexto, es un error de tipeo en un nombre, no una receta nueva.
paquetes = [
+ # ── BAJAR PRIVILEGIOS (2026-10-22) ───────────────────────────────────────────────────────────
+ # `setuidgid` lo invocan las cards de servicio de este perfil por ruta absoluta: lo necesitan
+ # `gitea`, `minga`, `pacha`, `pacha-secretos` y `shuma-daemon`, porque esos daemons se niegan a
+ # correr como root y arje-zero encarna sus cards COMO root. Hasta hoy lo daba el applet de
+ # busybox y era el ÚLTIMO sin dueño propio; ahora es un binario de 330 KB, estático y sólo libc.
+ "setuidgid",
# ── lo que existe y está sellado ──────────────────────────────────────────────────────────────
# `openssh`: el acceso. Es la raíz que convierte «la caja arrancó» en «puedo entrar»; sin ella una
# instalación remota deja una máquina viva e inalcanzable, que es peor que una que no arrancó.
@@ -3150,6 +3162,12 @@ hereda = ["base"]
# una opinión sobre qué debe llevar un servidor. Si algún día el instalador deja de cargar el
# sistema en RAM, este perfil deja de tener motivo.
paquetes = [
+ # ── BAJAR PRIVILEGIOS (2026-10-22) ───────────────────────────────────────────────────────────
+ # `setuidgid` lo invocan las cards de servicio de este perfil por ruta absoluta: lo necesitan
+ # `gitea`, `minga`, `pacha`, `pacha-secretos` y `shuma-daemon`, porque esos daemons se niegan a
+ # correr como root y arje-zero encarna sus cards COMO root. Hasta hoy lo daba el applet de
+ # busybox y era el ÚLTIMO sin dueño propio; ahora es un binario de 330 KB, estático y sólo libc.
+ "setuidgid",
# ── el acceso y la red ────────────────────────────────────────────────────────────────────────
"openssh", "curl", "wget", "wireguard-tools", "nftables", "netup", "tmux",
# ── lo que sirve ──────────────────────────────────────────────────────────────────────────────
diff --git a/recipes/setuidgid.toml b/recipes/setuidgid.toml
new file mode 100644
index 00000000..794ea2b2
--- /dev/null
+++ b/recipes/setuidgid.toml
@@ -0,0 +1,98 @@
+# setuidgid — baja privilegios y ejecuta. Reemplaza el applet homónimo de busybox.
+#
+# ══ POR QUÉ ENTRA: ERA EL ÚLTIMO SIN DUEÑO ═════════════════════════════════════════════════════
+# El TSV de `docs/plan-botar-busybox.md` lo tenía `INDULTADO` con esta razón: *«la card de gitea
+# hace `setuidgid gitea`»*. Medido el 2026-10-22, no es una card sino **CINCO**, todas por ruta
+# absoluta: `gitea`, `minga`, `pacha`, `pacha-secretos` y `shuma-daemon`. Esos daemons se niegan a
+# correr como root —gitea lo dice en su propio mensaje de error— y arje-zero encarna sus cards
+# COMO ROOT, así que alguien tiene que bajar los privilegios en el medio.
+#
+# Con esto, de los cinco applets indultados quedan: `halt`/`poweroff`/`reboot` (los cubren los
+# shims de `scripts/servidor/arje-botones.sh`) y `switch_root` (lo trae `util-linux` en
+# `/sbin/switch_root`, verificado). ⇒ **ninguno obliga a embarcar busybox.**
+#
+# ══ MISMO PATRÓN QUE netup/hammerd/simi ════════════════════════════════════════════════════════
+# Crate del workspace a un commit fijado, deps vendoreadas, estático con zig cc, sólo libc. La
+# razón de «sólo libc» no es purismo: esto lo invocan cards de servicio, o sea que entra en la raíz
+# de confianza del arranque, y cada dep suya entraría con él.
+name = "setuidgid"
+version = "0.0.1"
+license = "MIT"
+
+[source]
+# ⚠ **El pin tiene que incluir el `Cargo.lock`, no sólo el crate.** Primero apunté al commit que
+# añade `crates/setuidgid` y el build murió así:
+# cannot update the lock file … because --locked was passed to prevent this
+# El build es hermético (`--locked --offline`), así que el lockfile del árbol tiene que YA traer la
+# entrada del crate nuevo. La había dejado en el commit SIGUIENTE para no mezclar, y eso rompió el
+# pin. ⇒ en una receta de crate del workspace, el commit fijado debe contener los dos.
+repo = "https://git.gioser.net/sergio/takana.git"
+commit = "2de22d24279621d3605d93d8e18b520b579d7207"
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "static"
+strip_debug = true
+flags = ["-p", "setuidgid"]
+
+[build.phases]
+install = '''
+set -e
+mkdir -p /out/usr/bin
+find target/release -maxdepth 1 -type f -name setuidgid -exec cp {} /out/usr/bin/ \;
+[ -x /out/usr/bin/setuidgid ] || { echo "setuidgid: no quedó el binario — NO sello" >&2; exit 1; }
+
+# ⚠ GUARDIÁN 1: estático de verdad. Lo invoca PID 1 por ruta absoluta en el arranque; un NEEDED
+# colgante acá no es un programa que falla, es un servicio que nunca arranca.
+if command -v readelf >/dev/null; then
+ n=$(readelf -d /out/usr/bin/setuidgid 2>/dev/null | grep -c NEEDED || true)
+ [ "${n:-0}" -eq 0 ] || { echo "setuidgid: tiene $n NEEDED — tiene que ser estático" >&2; readelf -d /out/usr/bin/setuidgid | grep NEEDED >&2; exit 1; }
+fi
+
+# ⚠ GUARDIÁN 2 — LO QUE SÍ SE PUEDE COMPROBAR A uid 1001, Y POR QUÉ NO ES LA BAJADA
+#
+# Mi primera versión probaba la bajada de privilegios de verdad, con un usuario de prueba en
+# `/etc/passwd`. **Falla, y la razón es buena:** el sandbox de build corre como **uid=1001**, no
+# como root (medido: `sandbox corre como uid=1001 gid=1001`). Sin root no hay privilegio que bajar,
+# así que ese control es IMPOSIBLE acá por construcción — no por un defecto del programa.
+#
+# ⇒ Se comprueba todo lo que sí se puede, y la pieza fuerte es la NEGATIVA: que sin ser root se
+# plante en vez de seguir. Eso no es un consuelo — es la mitad de la propiedad de seguridad, y es
+# justo la mitad que un atacante probaría primero.
+#
+# La bajada SÍ está verificada, fuera del sandbox y contra el applet de busybox como CONTROL, en la
+# caja viva (2026-10-22):
+# busybox: uid=74(sshd) gid=74(sshd) groups=74(sshd)
+# nuestro: uid=74(sshd) gid=74(sshd) groups=74(sshd)
+# idénticos, con los suplementarios bajados (sin el 0 de root). Queda dicho acá en vez de fingir un
+# control que el sandbox no puede correr.
+
+# sin argumentos ⇒ 2 (uso), que es lo que un shell interpreta como «mal invocado»
+/out/usr/bin/setuidgid >/dev/null 2>&1 && { echo "setuidgid: sin argumentos debería fallar" >&2; exit 1; }
+rc=0; /out/usr/bin/setuidgid >/dev/null 2>&1 || rc=$?
+[ "$rc" = 2 ] || { echo "setuidgid: sin argumentos salió $rc, esperaba 2" >&2; exit 1; }
+
+# ⚠ LA PIEZA FUERTE: sin ser root NO ejecuta. Si esto pasara, un usuario cualquiera podría usarlo
+# como trampolín — y el programa parecería funcionar.
+sal=$(/out/usr/bin/setuidgid root /usr/bin/id 2>&1 | head -1)
+case "$sal" in
+ *"hace falta ser root"*) : ;;
+ *) echo "setuidgid: a uid 1001 NO se negó — salida: $sal" >&2; exit 1 ;;
+esac
+rc=0; /out/usr/bin/setuidgid root /usr/bin/id >/dev/null 2>&1 || rc=$?
+[ "$rc" = 127 ] || { echo "setuidgid: la negativa salió $rc, esperaba 127" >&2; exit 1; }
+
+# un usuario inexistente también se rechaza, y el mensaje nombra el arreglo
+sal=$(/out/usr/bin/setuidgid no-existe-jamas /usr/bin/id 2>&1 | head -1)
+case "$sal" in
+ *"no está en /etc/passwd"*) : ;;
+ *"hace falta ser root"*) : ;; # a uid 1001 corta antes, y está bien
+ *) echo "setuidgid: mensaje inesperado para usuario inexistente: $sal" >&2; exit 1 ;;
+esac
+
+echo "setuidgid: estático (0 NEEDED) · rc=2 sin argumentos · se NIEGA a uid 1001 con rc=127 · rechaza usuario inexistente"
+'''
+
+[deps]
+build = ["binutils", "coreutils"]