#!/bin/sh # lib/cuenta.sh — LA CUENTA DE QUIEN VA A USAR LA MÁQUINA: se pregunta antes de instalar y se # escribe al terminar. Es la sección que faltaba en los dos instaladores. # # ── El defecto, medido en metal el 2026-10-14/15 ───────────────────────────────────────────────── # El operador instaló, arrancó, y **no podía hacer nada**: ni entrar como root, ni escalar. Lo dijo # así: *«al correr su me dice que debe ser suid, así que se cagó ese boot»* y *«¿de dónde salió el # usuario sergio? me di cuenta que debe ser una sección antes de instalar»*. # # Las dos preguntas tienen la misma respuesta, y es ésta: **nadie preguntó nunca por una cuenta.** # # · `takana-live-install.sh` (disco entero) pregunta hostname, zona, teclado y la credencial de # ROOT — y ningún usuario. En una imagen de escritorio eso es una máquina donde el greeter no # puede autenticar a nadie, porque no hay nadie. # · `takana-install-partition.sh` (el camino de `--keep`/`--adopt`, el que usó él) **no pregunta # NADA**. Los usuarios salen de `lib/usuarios-del-home.sh`, que los cosecha del `/home` que se # conserva —un usuario por directorio, con el uid de sus archivos— y les pone `!` en # `/etc/shadow`, que es «cuenta sin contraseña posible». Correcto de su parte: inventar una # contraseña sería peor. Pero nadie, después, la pedía. # # Resultado medido: `sergio:!` + `root:!` + ningún `doas.conf` = una máquina instalada, arrancada y # **inadministrable**. El encabezado de `takana-core/src/setuid.rs` lo había previsto textualmente # («hoy es LATENTE: el instalador sólo pone contraseña de root, así que no hay un humano no-root que # lo choque») y el 14 de octubre lo hubo. # # ── Por qué una librería y no dos secciones ────────────────────────────────────────────────────── # El mismo argumento que `lib/usuarios-del-home.sh` escribe para sí: son DOS caminos y UNA pregunta, # y dos implementaciones divergen en el caso raro. Acá el caso raro es justo el del operador —el # usuario YA existe, cosechado del `/home` conservado, y lo único que falta es su contraseña— y una # de las dos copias se lo habría perdido. # # ── La regla de root, sin criterio ─────────────────────────────────────────────────────────────── # Root queda **cerrado** si el usuario puede escalar (hay `doas` en la imagen y se pidió que # administre): es lo correcto, y es lo que hacen las distros desde hace una década. Si NO puede # escalar, entonces se pide contraseña de root **obligatoriamente** — porque una máquina sin # ninguna de las dos no se administra, y eso es exactamente lo que acabamos de medir. # # Se usa así (los dos instaladores): # # . "$(dirname "$0")/lib/cuenta.sh" # cuenta_preguntar "$RAIZ_DEL_MEDIO" # TUI; no pregunta si no hay tty o si es desatendido # ... # cuenta_aplicar "$ARBOL" # escribe passwd/group/shadow/home/doas.conf # # Desatendido (arneses y `--unattended`): `TAKANA_USER`, `TAKANA_USER_HASH` (un hash, NO una # contraseña), `TAKANA_USER_PW` (sólo donde no haya más remedio), `TAKANA_ADMIN=1|0`, # `TAKANA_ROOT_PW` / `TAKANA_ROOT_HASH` como ya existían. # # ⚠ **La contraseña NO viaja por la cmdline del kernel.** `/proc/cmdline` lo lee cualquiera, así que # un hash ahí es un ataque de diccionario offline servido en bandeja. Por eso esto pregunta en el # instalador —que tiene tty— en vez de recibirlo del que hizo el kexec. CUENTA_USER="${TAKANA_USER:-}" CUENTA_HASH="${TAKANA_USER_HASH:-}" CUENTA_ADMIN="${TAKANA_ADMIN:-1}" CUENTA_ROOT_HASH="${TAKANA_ROOT_HASH:-${HAMMER_ROOT_HASH:-}}" # Los dos instaladores ya traen sus propios `ask`/`read_secret`/`bb`; si falta alguno (al usar la # librería desde un arnés) se definen acá. Un `command -v` sobre una función de shell la encuentra. command -v bb >/dev/null 2>&1 || bb() { busybox "$@" 2>/dev/null || command "$@"; } command -v ask >/dev/null 2>&1 || ask() { _a_v="$1"; printf '%s%s: ' "$2" "$([ -n "${3:-}" ] && printf ' [%s]' "$3")" IFS= read -r _a_r || { _a_r=""; CUENTA_EOF=1; } [ -n "$_a_r" ] || _a_r="${3:-}" eval "$_a_v=\$_a_r" } command -v read_secret >/dev/null 2>&1 || read_secret() { printf '%s: ' "$2"; stty -echo 2>/dev/null || true IFS= read -r _rs || { _rs=""; CUENTA_EOF=1; }; stty echo 2>/dev/null || true; echo eval "$1=\$_rs" } # ── ⚠ POR QUÉ LOS BUCLES DE CONTRASEÑA SON ACOTADOS, Y NO ES UN GUSTO (2026-10-15) ───────────── # Los dos `while :;` de abajo reintentaban SIN TOPE, y los `read_secret` —el de acá y el que traen # los dos instaladores— se comen el fallo del `read` (`|| _sec=""`). Juntos: con un stdin en EOF # —un arnés, un `< /dev/null`, un pipe que se cerró— el `read` falla, la variable sale vacía, el # `[ -n ]` no pasa, y el bucle imprime «no coinciden (o vacía) — reintentá» para siempre, sin # dormir ni un milisegundo. # # MEDIDO en el anfitrión: un `takana-live-install.sh` quedó huérfano colgado de PID 1 y en CUATRO # MINUTOS escribió **1,5 G** de esas tres líneas. Y `/tmp` es **tmpfs**, así que eso no era disco: # era RAM, en una caja de 7,7 G que ya tenía 1,8 G en swap. El proceso no se veía: su propio script # ya estaba borrado y nada en su línea de comandos nombraba el fichero que estaba llenando. # # El tope va en el BUCLE y no sólo en el lector a propósito: los instaladores traen su propio # `read_secret` y la librería no manda sobre él, así que un bucle que se defiende solo es lo único # que protege en los tres casos. `CUENTA_EOF` corta en el primer intento cuando el lector SÍ es el # nuestro; el tope corta de todas formas. CUENTA_EOF=0 CUENTA_INTENTOS="${CUENTA_INTENTOS:-3}" # cuenta_hash — SHA-512 por `cryptpw`. Devuelve vacío si este busybox no lo trae, y el # llamador lo dice: un hash vacío escribiría una cuenta SIN CONTRASEÑA, que es peor que no tenerla. cuenta_hash() { printf '%s' "$1" | bb cryptpw -m sha512 2>/dev/null || true } # cuenta_login_valido — el mismo criterio que `lib/usuarios-del-home.sh` usa al cosechar, # porque los dos juegos de usuarios tienen que poder convivir en el mismo `/etc/passwd`. cuenta_login_valido() { case "$1" in "" | -* | *[!a-z0-9_-]*) return 1 ;; root | sshd | bin | daemon | nobody | messagebus) return 1 ;; *) [ "${#1}" -le 32 ] ;; esac } # cuenta_preguntar [raíz-del-medio] — la sección. No pregunta si no hay tty, si es desatendido, o si # ya vino todo por el ambiente. cuenta_preguntar() { _cp_raiz="${1:-/}" [ "${TAKANA_UNATTENDED:-0}" = 1 ] && return 0 # ⚠ La condición es la MISMA que usa el instalador para decidir si es interactivo, y no sólo # `[ -t 0 ]`: con stdin alimentado por un heredoc —que es como se prueba una TUI sin una persona # delante— no hay tty, y la sección se saltaba entera dejando el arnés en verde sobre un # instalador que no preguntaba nada. Medido escribiéndolo. [ -t 0 ] || [ "${TAKANA_INSTALL_INTERACTIVE:-${HAMMER_INSTALL_INTERACTIVE:-}}" = 1 ] || return 0 [ -n "$CUENTA_USER" ] && [ -n "$CUENTA_HASH" ] && return 0 # ¿Hay con qué escalar en la imagen que se está instalando? La respuesta cambia la regla de root, # así que se MIRA y no se supone. _cp_doas=no for _d in usr/bin/doas bin/doas usr/local/bin/doas usr/bin/sudo; do [ -e "$_cp_raiz/$_d" ] && { _cp_doas=sí; break; } done echo echo "Tu cuenta en esta máquina" echo " ─────────────────────────" echo " Es la que vas a usar para entrar al escritorio. Sin una cuenta con contraseña," echo " el escritorio arranca y no deja entrar a nadie." while :; do ask CUENTA_USER "Nombre de usuario" "${CUENTA_USER:-}" [ -z "$CUENTA_USER" ] && { echo " (sin usuario: la máquina queda sólo con root)"; break; } cuenta_login_valido "$CUENTA_USER" && break echo " ✗ «$CUENTA_USER» no sirve de login: minúsculas, dígitos, «_» y «-», sin empezar con «-»," echo " y no puede ser un usuario de sistema (root, sshd, nobody…)." CUENTA_USER="" done if [ -n "$CUENTA_USER" ]; then if [ -z "$CUENTA_HASH" ]; then if [ -n "$(cuenta_hash x)" ]; then _cp_n=0 while :; do _cp_n=$((_cp_n+1)) read_secret _cp_p1 "Contraseña para $CUENTA_USER" read_secret _cp_p2 "Repetir" if [ "$CUENTA_EOF" = 1 ] || [ "$_cp_n" -ge "$CUENTA_INTENTOS" ]; then [ "$_cp_p1" = "$_cp_p2" ] && [ -n "$_cp_p1" ] || { echo " ⚠ no pude leer una contraseña para $CUENTA_USER (¿stdin sin terminal?) — su cuenta queda CERRADA" break; } fi [ "$_cp_p1" = "$_cp_p2" ] && [ -n "$_cp_p1" ] || { echo " no coinciden (o vacía) — reintentá"; continue; } CUENTA_HASH=$(cuenta_hash "$_cp_p1") [ -n "$CUENTA_HASH" ] || { echo " ⚠ no pude generar el hash"; break; } break done _cp_p1=; _cp_p2= else echo " ⚠ este busybox no trae cryptpw: no puedo poner contraseña y la cuenta queda cerrada." fi fi if [ "$_cp_doas" = sí ]; then ask CUENTA_ADMIN "¿$CUENTA_USER administra la máquina (doas)? 1=sí 0=no" "1" else echo " ⚠ esta imagen no trae doas ni sudo: nadie puede escalar, así que root necesita contraseña." CUENTA_ADMIN=0 fi fi # La regla de root, aplicada: cerrada si hay quien escale, obligatoria si no. if [ -n "$CUENTA_USER" ] && [ "$CUENTA_ADMIN" = 1 ] && [ -n "$CUENTA_HASH" ]; then [ -n "$CUENTA_ROOT_HASH" ] || echo " · root queda CERRADO: se administra con «doas» desde $CUENTA_USER." elif [ -z "$CUENTA_ROOT_HASH" ]; then echo echo " Nadie puede escalar todavía ⇒ root necesita contraseña, o la máquina queda inadministrable." if [ -n "$(cuenta_hash x)" ]; then _cp_m=0 while :; do _cp_m=$((_cp_m+1)) read_secret _cp_r1 "Contraseña de root" read_secret _cp_r2 "Repetir" if [ "$CUENTA_EOF" = 1 ] || [ "$_cp_m" -ge "$CUENTA_INTENTOS" ]; then [ "$_cp_r1" = "$_cp_r2" ] && [ -n "$_cp_r1" ] || { echo " ⚠ no pude leer la contraseña de root (¿stdin sin terminal?) — root queda CERRADO" break; } fi [ "$_cp_r1" = "$_cp_r2" ] && [ -n "$_cp_r1" ] || { echo " no coinciden (o vacía) — reintentá"; continue; } CUENTA_ROOT_HASH=$(cuenta_hash "$_cp_r1"); break done _cp_r1=; _cp_r2= fi fi } # cuenta_resumen — una línea para el resumen de confirmación del instalador. cuenta_resumen() { if [ -z "$CUENTA_USER" ]; then printf 'sin usuario (sólo root)'; return; fi printf '%s' "$CUENTA_USER" [ -n "$CUENTA_HASH" ] && printf ' con contraseña' || printf ' SIN contraseña (cuenta cerrada)' [ "$CUENTA_ADMIN" = 1 ] && printf ', administra (doas)' } # _cuenta_romper_hardlink — el árbol del stage son HARDLINKS del rootfs sellado (ADR # 0005): un `>>` sobre `etc/passwd` escribiría DENTRO del artefacto del store y lo envenenaría para # todas las imágenes, sin que nada falle. Es la misma precaución que toma `usuarios_aplicar`, y la # razón de que acá no haya un solo `>>` directo. _cuenta_romper_hardlink() { [ -e "$1" ] || return 0 cp "$1" "$1.cuenta-tmp" && mv "$1.cuenta-tmp" "$1" } # cuenta_aplicar <árbol> — escribe la cuenta en la raíz nueva. Idempotente: si el usuario ya existe # (cosechado del `/home` conservado) NO lo vuelve a crear y sólo le pone su contraseña, que es # exactamente el caso del operador. cuenta_aplicar() { _ca_t="$1" bb mkdir -p "$_ca_t/etc" for _f in etc/passwd etc/group etc/shadow; do _cuenta_romper_hardlink "$_ca_t/$_f"; done [ -e "$_ca_t/etc/passwd" ] || : > "$_ca_t/etc/passwd" [ -e "$_ca_t/etc/group" ] || : > "$_ca_t/etc/group" [ -e "$_ca_t/etc/shadow" ] || { : > "$_ca_t/etc/shadow"; bb chmod 600 "$_ca_t/etc/shadow"; } if [ -n "$CUENTA_USER" ]; then if bb awk -F: -v n="$CUENTA_USER" '$1==n{f=1} END{exit !f}' "$_ca_t/etc/passwd"; then _ca_uid=$(bb awk -F: -v n="$CUENTA_USER" '$1==n{print $3; exit}' "$_ca_t/etc/passwd") echo " · «$CUENTA_USER» ya existe con uid $_ca_uid (del /home conservado): le pongo su contraseña" else # El primer uid libre desde 1000. Se cuenta sobre el passwd que YA tiene los usuarios # cosechados, así que no puede chocar con ellos. _ca_uid=1000 while bb awk -F: -v u="$_ca_uid" '$3==u{f=1} END{exit !f}' "$_ca_t/etc/passwd"; do _ca_uid=$((_ca_uid + 1)) done printf '%s:x:%s:%s:%s:/home/%s:/bin/sh\n' \ "$CUENTA_USER" "$_ca_uid" "$_ca_uid" "$CUENTA_USER" "$CUENTA_USER" >> "$_ca_t/etc/passwd" bb awk -F: -v g="$_ca_uid" '$3==g{f=1} END{exit !f}' "$_ca_t/etc/group" \ || printf '%s:x:%s:\n' "$CUENTA_USER" "$_ca_uid" >> "$_ca_t/etc/group" echo " · usuario «$CUENTA_USER» creado con uid $_ca_uid" fi # El hogar: sólo si NO existe. Con `--keep` o `--adopt` ya está ahí con sus archivos y sus # dueños, y pisarlo sería el único daño irreparable que puede hacer un instalador. if [ ! -e "$_ca_t/home/$CUENTA_USER" ]; then bb mkdir -p "$_ca_t/home/$CUENTA_USER" bb chown "$_ca_uid:$_ca_uid" "$_ca_t/home/$CUENTA_USER" 2>/dev/null || true bb chmod 700 "$_ca_t/home/$CUENTA_USER" echo " · /home/$CUENTA_USER creado (uid $_ca_uid)" else echo " · /home/$CUENTA_USER ya existe: NO lo toco" fi # Los grupos del hardware: seatd exige `video` POR NOMBRE, y sin `input` libinput no abre los # dispositivos. Sólo si el grupo existe en la imagen — inventar un gid acá sería adivinar. for _g in video input audio seat; do bb awk -F: -v g="$_g" -v u="$CUENTA_USER" ' BEGIN{OFS=":"} $1==g { if ($4=="") $4=u; else if ($4 !~ ("(^|,)" u "(,|$)")) $4=$4","u; f=1 } {print} END{exit !f}' "$_ca_t/etc/group" > "$_ca_t/etc/group.cuenta-tmp" \ && mv "$_ca_t/etc/group.cuenta-tmp" "$_ca_t/etc/group" \ || rm -f "$_ca_t/etc/group.cuenta-tmp" done if [ -n "$CUENTA_HASH" ]; then _cuenta_set_shadow "$CUENTA_USER" "$CUENTA_HASH" "$_ca_t" else _cuenta_set_shadow "$CUENTA_USER" '!' "$_ca_t" echo " ⚠ «$CUENTA_USER» queda SIN contraseña posible: nadie va a poder entrar con esa cuenta" fi # La regla de escalada. Sin esto doas RECHAZA TODO («no rule matched») aunque esté instalado y # con su bit: quién escala no lo puede decidir el paquete, lo decide esta máquina. if [ "$CUENTA_ADMIN" = 1 ]; then if [ -e "$_ca_t/usr/bin/doas" ] || [ -e "$_ca_t/bin/doas" ]; then if [ -e "$_ca_t/etc/doas.conf" ]; then echo " · /etc/doas.conf ya existe: no lo toco" else printf '# Quién puede ser root en esta máquina. La ÚLTIMA regla que casa es la que vale.\npermit persist %s\n' \ "$CUENTA_USER" > "$_ca_t/etc/doas.conf" bb chown 0:0 "$_ca_t/etc/doas.conf" 2>/dev/null || true bb chmod 640 "$_ca_t/etc/doas.conf" echo " · /etc/doas.conf: permit persist $CUENTA_USER" fi else echo " ⚠ se pidió que «$CUENTA_USER» administre y esta imagen NO trae doas: no hay escalada" fi fi fi if [ -n "$CUENTA_ROOT_HASH" ]; then _cuenta_set_shadow root "$CUENTA_ROOT_HASH" "$_ca_t" echo " · root con contraseña" fi # El entorno de login de esa cuenta. Va acá y no en otra sección porque una cuenta cuyo shell no # encuentra `/usr/local/bin` es media cuenta: es lo que dejó al operador con `claude` instalado y # `claude: not found` en la consola. cuenta_perfil_de_login "$_ca_t" # ── El brazo de control, ACÁ y no en un test aparte ─────────────────────────────────────────── # Una cuenta mal escrita no se nota hasta el primer arranque, y arreglarla cuesta montar el disco # desde otro sistema. Se comprueba lo que se acaba de escribir, y se dice en voz alta. _ca_mal=0 if [ -n "$CUENTA_USER" ]; then bb awk -F: -v n="$CUENTA_USER" '$1==n{f=1} END{exit !f}' "$_ca_t/etc/passwd" \ || { echo " ✗ «$CUENTA_USER» NO quedó en /etc/passwd"; _ca_mal=$((_ca_mal+1)); } if [ -n "$CUENTA_HASH" ]; then bb awk -F: -v n="$CUENTA_USER" '$1==n && $2 ~ /^\$/{f=1} END{exit !f}' "$_ca_t/etc/shadow" \ || { echo " ✗ «$CUENTA_USER» no quedó con un hash en /etc/shadow"; _ca_mal=$((_ca_mal+1)); } fi fi # El aviso cubre las TRES formas de quedar sin nadie que administre: sin contraseña de root y # (sin usuario | con usuario que no escala | con usuario sin contraseña). La primera versión de # esta línea sólo miraba `ADMIN != 1`, y con el default `ADMIN=1` y usuario VACÍO se callaba — # que es exactamente la combinación con la que arrancó la máquina del operador. if [ -z "$CUENTA_ROOT_HASH" ] \ && { [ -z "$CUENTA_USER" ] || [ "$CUENTA_ADMIN" != 1 ] || [ -z "$CUENTA_HASH" ]; }; then echo " ⚠ root cerrado y nadie escala: esta máquina va a arrancar INADMINISTRABLE" fi [ "$_ca_mal" = 0 ] } # cuenta_perfil_de_login <árbol> — `/etc/profile`, que es lo que lee el shell de esa cuenta. # # ── Por qué está acá y no en una receta ────────────────────────────────────────────────────────── # Medido en la máquina del operador: **`/etc/profile` NO EXISTÍA**. Y como el `ash` de busybox # sourcea `/etc/profile.d/*` sólo si ese archivo lo hace, `/etc/profile.d/tawasuyu-dev.sh` —que # exportaba el PATH del toolchain— estuvo inerte desde el primer arranque: `claude` instalado en # `/usr/local/bin` y la consola contestando `claude: not found`. Las recetas no tienen mecanismo # para aportar un fichero de `/etc` (no hay `[[file]]`: lo que entra sale del `install` de la # receta, y tocar eso re-hashea), así que el sitio donde nace un `/etc` de ESTA máquina es el # instalador — el mismo que escribe `hostname`, `hosts`, `localtime` y `shadow`. # # **Los seis directorios se garantizan uno por uno** en vez de agregar sólo `/usr/local`: un PATH # heredado puede traer unos y no otros, y entonces `su` o `wpa_supplicant` «no existen» porque # faltaba `/bin` o `/usr/sbin`. Medido dentro de un chroot de la imagen: con el PATH de la otra # máquina, `claude` se encontraba y `su` no. # # Y los del hogar van con `$HOME`, no con una ruta escrita: este fichero viaja a la máquina de # cualquiera. La primera versión de este PATH decía `/home/sergio/...` y eso es justo lo que no # puede quedar en algo que instala un desconocido. cuenta_perfil_de_login() { _cp_t="$1" [ -e "$_cp_t/etc/profile" ] && { echo " · /etc/profile ya existe: no lo toco"; return 0; } bb mkdir -p "$_cp_t/etc" cat > "$_cp_t/etc/profile" <<'PERFIL' # /etc/profile — lo que lee un login shell. # # El `ash` de busybox sourcea ESTE fichero y nada más: `/etc/profile.d/*` no se lee si acá no se # lee. Sin esto, la consola de rescate tampoco tiene PATH. for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do case ":${PATH:-}:" in *":$dir:"*) ;; *) PATH="${PATH:+$PATH:}$dir" ;; esac done # Y los del hogar, al final: lo de la persona no debe tapar lo del sistema. for dir in "$HOME/.local/bin" "$HOME/bin"; do [ -d "$dir" ] || continue case ":$PATH:" in *":$dir:"*) ;; *) PATH="$PATH:$dir" ;; esac done export PATH umask 022 for perfil in /etc/profile.d/*.sh; do [ -r "$perfil" ] && . "$perfil" done unset perfil dir PERFIL bb chmod 644 "$_cp_t/etc/profile" echo " · /etc/profile escrito (los seis directorios + los del hogar al final)" } # El mismo `_set_shadow` que ya tenía `takana-live-install.sh`, con otro nombre para no pisárselo. _cuenta_set_shadow() { _cs_u="$1"; _cs_h="$2"; _cs_f="$3/etc/shadow" if [ ! -f "$_cs_f" ]; then printf '%s:%s:20000:0:99999:7:::\n' "$_cs_u" "$_cs_h" > "$_cs_f" bb chmod 600 "$_cs_f" return fi bb awk -F: -v u="$_cs_u" -v h="$_cs_h" 'BEGIN{OFS=":"} { if ($1==u){ $2=h; seen=1 } print } END{ if(!seen) print u,h,"20000","0","99999","7","","","" }' "$_cs_f" > "$_cs_f.cuenta-tmp" \ && mv "$_cs_f.cuenta-tmp" "$_cs_f" bb chmod 600 "$_cs_f" }