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…
fix(instalador): la cuenta de quien va a usar la maquina se pregunta antes de instalar
1#!/bin/sh
2# lib/cuenta.sh — LA CUENTA DE QUIEN VA A USAR LA MÁQUINA: se pregunta antes de instalar y se
3# escribe al terminar. Es la sección que faltaba en los dos instaladores.
4#
5# ── El defecto, medido en metal el 2026-10-14/15 ─────────────────────────────────────────────────
6# El operador instaló, arrancó, y **no podía hacer nada**: ni entrar como root, ni escalar. Lo dijo
7# así: *«al correr su me dice que debe ser suid, así que se cagó ese boot»* y *«¿de dónde salió el
8# usuario sergio? me di cuenta que debe ser una sección antes de instalar»*.
9#
10# Las dos preguntas tienen la misma respuesta, y es ésta: **nadie preguntó nunca por una cuenta.**
11#
12# · `takana-live-install.sh` (disco entero) pregunta hostname, zona, teclado y la credencial de
13# ROOT — y ningún usuario. En una imagen de escritorio eso es una máquina donde el greeter no
14# puede autenticar a nadie, porque no hay nadie.
15# · `takana-install-partition.sh` (el camino de `--keep`/`--adopt`, el que usó él) **no pregunta
16# NADA**. Los usuarios salen de `lib/usuarios-del-home.sh`, que los cosecha del `/home` que se
17# conserva —un usuario por directorio, con el uid de sus archivos— y les pone `!` en
18# `/etc/shadow`, que es «cuenta sin contraseña posible». Correcto de su parte: inventar una
19# contraseña sería peor. Pero nadie, después, la pedía.
20#
21# Resultado medido: `sergio:!` + `root:!` + ningún `doas.conf` = una máquina instalada, arrancada y
22# **inadministrable**. El encabezado de `takana-core/src/setuid.rs` lo había previsto textualmente
23# («hoy es LATENTE: el instalador sólo pone contraseña de root, así que no hay un humano no-root que
24# lo choque») y el 14 de octubre lo hubo.
25#
26# ── Por qué una librería y no dos secciones ──────────────────────────────────────────────────────
27# El mismo argumento que `lib/usuarios-del-home.sh` escribe para sí: son DOS caminos y UNA pregunta,
28# y dos implementaciones divergen en el caso raro. Acá el caso raro es justo el del operador —el
29# usuario YA existe, cosechado del `/home` conservado, y lo único que falta es su contraseña— y una
30# de las dos copias se lo habría perdido.
31#
32# ── La regla de root, sin criterio ───────────────────────────────────────────────────────────────
33# Root queda **cerrado** si el usuario puede escalar (hay `doas` en la imagen y se pidió que
34# administre): es lo correcto, y es lo que hacen las distros desde hace una década. Si NO puede
35# escalar, entonces se pide contraseña de root **obligatoriamente** — porque una máquina sin
36# ninguna de las dos no se administra, y eso es exactamente lo que acabamos de medir.
37#
38# Se usa así (los dos instaladores):
39#
40# . "$(dirname "$0")/lib/cuenta.sh"
41# cuenta_preguntar "$RAIZ_DEL_MEDIO" # TUI; no pregunta si no hay tty o si es desatendido
42# ...
43# cuenta_aplicar "$ARBOL" # escribe passwd/group/shadow/home/doas.conf
44#
45# Desatendido (arneses y `--unattended`): `TAKANA_USER`, `TAKANA_USER_HASH` (un hash, NO una
46# contraseña), `TAKANA_USER_PW` (sólo donde no haya más remedio), `TAKANA_ADMIN=1|0`,
47# `TAKANA_ROOT_PW` / `TAKANA_ROOT_HASH` como ya existían.
48#
49# ⚠ **La contraseña NO viaja por la cmdline del kernel.** `/proc/cmdline` lo lee cualquiera, así que
50# un hash ahí es un ataque de diccionario offline servido en bandeja. Por eso esto pregunta en el
51# instalador —que tiene tty— en vez de recibirlo del que hizo el kexec.
52
53CUENTA_USER="${TAKANA_USER:-}"
54CUENTA_HASH="${TAKANA_USER_HASH:-}"
55CUENTA_ADMIN="${TAKANA_ADMIN:-1}"
56CUENTA_ROOT_HASH="${TAKANA_ROOT_HASH:-${HAMMER_ROOT_HASH:-}}"
57
58# Los dos instaladores ya traen sus propios `ask`/`read_secret`/`bb`; si falta alguno (al usar la
59# librería desde un arnés) se definen acá. Un `command -v` sobre una función de shell la encuentra.
60command -v bb >/dev/null 2>&1 || bb() { busybox "$@" 2>/dev/null || command "$@"; }
61command -v ask >/dev/null 2>&1 || ask() {
62 _a_v="$1"; printf '%s%s: ' "$2" "$([ -n "${3:-}" ] && printf ' [%s]' "$3")"
63 IFS= read -r _a_r || _a_r=""
64 [ -n "$_a_r" ] || _a_r="${3:-}"
65 eval "$_a_v=\$_a_r"
66}
67command -v read_secret >/dev/null 2>&1 || read_secret() {
68 printf '%s: ' "$2"; stty -echo 2>/dev/null || true
69 IFS= read -r _rs || _rs=""; stty echo 2>/dev/null || true; echo
70 eval "$1=\$_rs"
71}
72
73# cuenta_hash <contraseña> — SHA-512 por `cryptpw`. Devuelve vacío si este busybox no lo trae, y el
74# llamador lo dice: un hash vacío escribiría una cuenta SIN CONTRASEÑA, que es peor que no tenerla.
75cuenta_hash() {
76 printf '%s' "$1" | bb cryptpw -m sha512 2>/dev/null || true
77}
78
79# cuenta_login_valido <nombre> — el mismo criterio que `lib/usuarios-del-home.sh` usa al cosechar,
80# porque los dos juegos de usuarios tienen que poder convivir en el mismo `/etc/passwd`.
81cuenta_login_valido() {
82 case "$1" in
83 "" | -* | *[!a-z0-9_-]*) return 1 ;;
84 root | sshd | bin | daemon | nobody | messagebus) return 1 ;;
85 *) [ "${#1}" -le 32 ] ;;
86 esac
87}
88
89# cuenta_preguntar [raíz-del-medio] — la sección. No pregunta si no hay tty, si es desatendido, o si
90# ya vino todo por el ambiente.
91cuenta_preguntar() {
92 _cp_raiz="${1:-/}"
93 [ "${TAKANA_UNATTENDED:-0}" = 1 ] && return 0
94 # ⚠ La condición es la MISMA que usa el instalador para decidir si es interactivo, y no sólo
95 # `[ -t 0 ]`: con stdin alimentado por un heredoc —que es como se prueba una TUI sin una persona
96 # delante— no hay tty, y la sección se saltaba entera dejando el arnés en verde sobre un
97 # instalador que no preguntaba nada. Medido escribiéndolo.
98 [ -t 0 ] || [ "${TAKANA_INSTALL_INTERACTIVE:-${HAMMER_INSTALL_INTERACTIVE:-}}" = 1 ] || return 0
99 [ -n "$CUENTA_USER" ] && [ -n "$CUENTA_HASH" ] && return 0
100
101 # ¿Hay con qué escalar en la imagen que se está instalando? La respuesta cambia la regla de root,
102 # así que se MIRA y no se supone.
103 _cp_doas=no
104 for _d in usr/bin/doas bin/doas usr/local/bin/doas usr/bin/sudo; do
105 [ -e "$_cp_raiz/$_d" ] && { _cp_doas=sí; break; }
106 done
107
108 echo
109 echo "Tu cuenta en esta máquina"
110 echo " ─────────────────────────"
111 echo " Es la que vas a usar para entrar al escritorio. Sin una cuenta con contraseña,"
112 echo " el escritorio arranca y no deja entrar a nadie."
113 while :; do
114 ask CUENTA_USER "Nombre de usuario" "${CUENTA_USER:-}"
115 [ -z "$CUENTA_USER" ] && { echo " (sin usuario: la máquina queda sólo con root)"; break; }
116 cuenta_login_valido "$CUENTA_USER" && break
117 echo " ✗ «$CUENTA_USER» no sirve de login: minúsculas, dígitos, «_» y «-», sin empezar con «-»,"
118 echo " y no puede ser un usuario de sistema (root, sshd, nobody…)."
119 CUENTA_USER=""
120 done
121
122 if [ -n "$CUENTA_USER" ]; then
123 if [ -z "$CUENTA_HASH" ]; then
124 if [ -n "$(cuenta_hash x)" ]; then
125 while :; do
126 read_secret _cp_p1 "Contraseña para $CUENTA_USER"
127 read_secret _cp_p2 "Repetir"
128 [ "$_cp_p1" = "$_cp_p2" ] && [ -n "$_cp_p1" ] || { echo " no coinciden (o vacía) — reintentá"; continue; }
129 CUENTA_HASH=$(cuenta_hash "$_cp_p1")
130 [ -n "$CUENTA_HASH" ] || { echo " ⚠ no pude generar el hash"; break; }
131 break
132 done
133 _cp_p1=; _cp_p2=
134 else
135 echo " ⚠ este busybox no trae cryptpw: no puedo poner contraseña y la cuenta queda cerrada."
136 fi
137 fi
138 if [ "$_cp_doas" = sí ]; then
139 ask CUENTA_ADMIN "¿$CUENTA_USER administra la máquina (doas)? 1=sí 0=no" "1"
140 else
141 echo " ⚠ esta imagen no trae doas ni sudo: nadie puede escalar, así que root necesita contraseña."
142 CUENTA_ADMIN=0
143 fi
144 fi
145
146 # La regla de root, aplicada: cerrada si hay quien escale, obligatoria si no.
147 if [ -n "$CUENTA_USER" ] && [ "$CUENTA_ADMIN" = 1 ] && [ -n "$CUENTA_HASH" ]; then
148 [ -n "$CUENTA_ROOT_HASH" ] || echo " · root queda CERRADO: se administra con «doas» desde $CUENTA_USER."
149 elif [ -z "$CUENTA_ROOT_HASH" ]; then
150 echo
151 echo " Nadie puede escalar todavía ⇒ root necesita contraseña, o la máquina queda inadministrable."
152 if [ -n "$(cuenta_hash x)" ]; then
153 while :; do
154 read_secret _cp_r1 "Contraseña de root"
155 read_secret _cp_r2 "Repetir"
156 [ "$_cp_r1" = "$_cp_r2" ] && [ -n "$_cp_r1" ] || { echo " no coinciden (o vacía) — reintentá"; continue; }
157 CUENTA_ROOT_HASH=$(cuenta_hash "$_cp_r1"); break
158 done
159 _cp_r1=; _cp_r2=
160 fi
161 fi
162}
163
164# cuenta_resumen — una línea para el resumen de confirmación del instalador.
165cuenta_resumen() {
166 if [ -z "$CUENTA_USER" ]; then printf 'sin usuario (sólo root)'; return; fi
167 printf '%s' "$CUENTA_USER"
168 [ -n "$CUENTA_HASH" ] && printf ' con contraseña' || printf ' SIN contraseña (cuenta cerrada)'
169 [ "$CUENTA_ADMIN" = 1 ] && printf ', administra (doas)'
170}
171
172# _cuenta_romper_hardlink <fichero> — el árbol del stage son HARDLINKS del rootfs sellado (ADR
173# 0005): un `>>` sobre `etc/passwd` escribiría DENTRO del artefacto del store y lo envenenaría para
174# todas las imágenes, sin que nada falle. Es la misma precaución que toma `usuarios_aplicar`, y la
175# razón de que acá no haya un solo `>>` directo.
176_cuenta_romper_hardlink() {
177 [ -e "$1" ] || return 0
178 cp "$1" "$1.cuenta-tmp" && mv "$1.cuenta-tmp" "$1"
179}
180
181# cuenta_aplicar <árbol> — escribe la cuenta en la raíz nueva. Idempotente: si el usuario ya existe
182# (cosechado del `/home` conservado) NO lo vuelve a crear y sólo le pone su contraseña, que es
183# exactamente el caso del operador.
184cuenta_aplicar() {
185 _ca_t="$1"
186 bb mkdir -p "$_ca_t/etc"
187 for _f in etc/passwd etc/group etc/shadow; do _cuenta_romper_hardlink "$_ca_t/$_f"; done
188 [ -e "$_ca_t/etc/passwd" ] || : > "$_ca_t/etc/passwd"
189 [ -e "$_ca_t/etc/group" ] || : > "$_ca_t/etc/group"
190 [ -e "$_ca_t/etc/shadow" ] || { : > "$_ca_t/etc/shadow"; bb chmod 600 "$_ca_t/etc/shadow"; }
191
192 if [ -n "$CUENTA_USER" ]; then
193 if bb awk -F: -v n="$CUENTA_USER" '$1==n{f=1} END{exit !f}' "$_ca_t/etc/passwd"; then
194 _ca_uid=$(bb awk -F: -v n="$CUENTA_USER" '$1==n{print $3; exit}' "$_ca_t/etc/passwd")
195 echo " · «$CUENTA_USER» ya existe con uid $_ca_uid (del /home conservado): le pongo su contraseña"
196 else
197 # El primer uid libre desde 1000. Se cuenta sobre el passwd que YA tiene los usuarios
198 # cosechados, así que no puede chocar con ellos.
199 _ca_uid=1000
200 while bb awk -F: -v u="$_ca_uid" '$3==u{f=1} END{exit !f}' "$_ca_t/etc/passwd"; do
201 _ca_uid=$((_ca_uid + 1))
202 done
203 printf '%s:x:%s:%s:%s:/home/%s:/bin/sh\n' \
204 "$CUENTA_USER" "$_ca_uid" "$_ca_uid" "$CUENTA_USER" "$CUENTA_USER" >> "$_ca_t/etc/passwd"
205 bb awk -F: -v g="$_ca_uid" '$3==g{f=1} END{exit !f}' "$_ca_t/etc/group" \
206 || printf '%s:x:%s:\n' "$CUENTA_USER" "$_ca_uid" >> "$_ca_t/etc/group"
207 echo " · usuario «$CUENTA_USER» creado con uid $_ca_uid"
208 fi
209 # El hogar: sólo si NO existe. Con `--keep` o `--adopt` ya está ahí con sus archivos y sus
210 # dueños, y pisarlo sería el único daño irreparable que puede hacer un instalador.
211 if [ ! -e "$_ca_t/home/$CUENTA_USER" ]; then
212 bb mkdir -p "$_ca_t/home/$CUENTA_USER"
213 bb chown "$_ca_uid:$_ca_uid" "$_ca_t/home/$CUENTA_USER" 2>/dev/null || true
214 bb chmod 700 "$_ca_t/home/$CUENTA_USER"
215 echo " · /home/$CUENTA_USER creado (uid $_ca_uid)"
216 else
217 echo " · /home/$CUENTA_USER ya existe: NO lo toco"
218 fi
219 # Los grupos del hardware: seatd exige `video` POR NOMBRE, y sin `input` libinput no abre los
220 # dispositivos. Sólo si el grupo existe en la imagen — inventar un gid acá sería adivinar.
221 for _g in video input audio seat; do
222 bb awk -F: -v g="$_g" -v u="$CUENTA_USER" '
223 BEGIN{OFS=":"}
224 $1==g { if ($4=="") $4=u; else if ($4 !~ ("(^|,)" u "(,|$)")) $4=$4","u; f=1 }
225 {print}
226 END{exit !f}' "$_ca_t/etc/group" > "$_ca_t/etc/group.cuenta-tmp" \
227 && mv "$_ca_t/etc/group.cuenta-tmp" "$_ca_t/etc/group" \
228 || rm -f "$_ca_t/etc/group.cuenta-tmp"
229 done
230 if [ -n "$CUENTA_HASH" ]; then
231 _cuenta_set_shadow "$CUENTA_USER" "$CUENTA_HASH" "$_ca_t"
232 else
233 _cuenta_set_shadow "$CUENTA_USER" '!' "$_ca_t"
234 echo " ⚠ «$CUENTA_USER» queda SIN contraseña posible: nadie va a poder entrar con esa cuenta"
235 fi
236 # La regla de escalada. Sin esto doas RECHAZA TODO («no rule matched») aunque esté instalado y
237 # con su bit: quién escala no lo puede decidir el paquete, lo decide esta máquina.
238 if [ "$CUENTA_ADMIN" = 1 ]; then
239 if [ -e "$_ca_t/usr/bin/doas" ] || [ -e "$_ca_t/bin/doas" ]; then
240 if [ -e "$_ca_t/etc/doas.conf" ]; then
241 echo " · /etc/doas.conf ya existe: no lo toco"
242 else
243 printf '# Quién puede ser root en esta máquina. La ÚLTIMA regla que casa es la que vale.\npermit persist %s\n' \
244 "$CUENTA_USER" > "$_ca_t/etc/doas.conf"
245 bb chown 0:0 "$_ca_t/etc/doas.conf" 2>/dev/null || true
246 bb chmod 640 "$_ca_t/etc/doas.conf"
247 echo " · /etc/doas.conf: permit persist $CUENTA_USER"
248 fi
249 else
250 echo " ⚠ se pidió que «$CUENTA_USER» administre y esta imagen NO trae doas: no hay escalada"
251 fi
252 fi
253 fi
254
255 if [ -n "$CUENTA_ROOT_HASH" ]; then
256 _cuenta_set_shadow root "$CUENTA_ROOT_HASH" "$_ca_t"
257 echo " · root con contraseña"
258 fi
259
260 # El entorno de login de esa cuenta. Va acá y no en otra sección porque una cuenta cuyo shell no
261 # encuentra `/usr/local/bin` es media cuenta: es lo que dejó al operador con `claude` instalado y
262 # `claude: not found` en la consola.
263 cuenta_perfil_de_login "$_ca_t"
264
265 # ── El brazo de control, ACÁ y no en un test aparte ───────────────────────────────────────────
266 # Una cuenta mal escrita no se nota hasta el primer arranque, y arreglarla cuesta montar el disco
267 # desde otro sistema. Se comprueba lo que se acaba de escribir, y se dice en voz alta.
268 _ca_mal=0
269 if [ -n "$CUENTA_USER" ]; then
270 bb awk -F: -v n="$CUENTA_USER" '$1==n{f=1} END{exit !f}' "$_ca_t/etc/passwd" \
271 || { echo " ✗ «$CUENTA_USER» NO quedó en /etc/passwd"; _ca_mal=$((_ca_mal+1)); }
272 if [ -n "$CUENTA_HASH" ]; then
273 bb awk -F: -v n="$CUENTA_USER" '$1==n && $2 ~ /^\$/{f=1} END{exit !f}' "$_ca_t/etc/shadow" \
274 || { echo " ✗ «$CUENTA_USER» no quedó con un hash en /etc/shadow"; _ca_mal=$((_ca_mal+1)); }
275 fi
276 fi
277 # El aviso cubre las TRES formas de quedar sin nadie que administre: sin contraseña de root y
278 # (sin usuario | con usuario que no escala | con usuario sin contraseña). La primera versión de
279 # esta línea sólo miraba `ADMIN != 1`, y con el default `ADMIN=1` y usuario VACÍO se callaba —
280 # que es exactamente la combinación con la que arrancó la máquina del operador.
281 if [ -z "$CUENTA_ROOT_HASH" ] \
282 && { [ -z "$CUENTA_USER" ] || [ "$CUENTA_ADMIN" != 1 ] || [ -z "$CUENTA_HASH" ]; }; then
283 echo " ⚠ root cerrado y nadie escala: esta máquina va a arrancar INADMINISTRABLE"
284 fi
285 [ "$_ca_mal" = 0 ]
286}
287
288# cuenta_perfil_de_login <árbol> — `/etc/profile`, que es lo que lee el shell de esa cuenta.
289#
290# ── Por qué está acá y no en una receta ──────────────────────────────────────────────────────────
291# Medido en la máquina del operador: **`/etc/profile` NO EXISTÍA**. Y como el `ash` de busybox
292# sourcea `/etc/profile.d/*` sólo si ese archivo lo hace, `/etc/profile.d/tawasuyu-dev.sh` —que
293# exportaba el PATH del toolchain— estuvo inerte desde el primer arranque: `claude` instalado en
294# `/usr/local/bin` y la consola contestando `claude: not found`. Las recetas no tienen mecanismo
295# para aportar un fichero de `/etc` (no hay `[[file]]`: lo que entra sale del `install` de la
296# receta, y tocar eso re-hashea), así que el sitio donde nace un `/etc` de ESTA máquina es el
297# instalador — el mismo que escribe `hostname`, `hosts`, `localtime` y `shadow`.
298#
299# **Los seis directorios se garantizan uno por uno** en vez de agregar sólo `/usr/local`: un PATH
300# heredado puede traer unos y no otros, y entonces `su` o `wpa_supplicant` «no existen» porque
301# faltaba `/bin` o `/usr/sbin`. Medido dentro de un chroot de la imagen: con el PATH de la otra
302# máquina, `claude` se encontraba y `su` no.
303#
304# Y los del hogar van con `$HOME`, no con una ruta escrita: este fichero viaja a la máquina de
305# cualquiera. La primera versión de este PATH decía `/home/sergio/...` y eso es justo lo que no
306# puede quedar en algo que instala un desconocido.
307cuenta_perfil_de_login() {
308 _cp_t="$1"
309 [ -e "$_cp_t/etc/profile" ] && { echo " · /etc/profile ya existe: no lo toco"; return 0; }
310 bb mkdir -p "$_cp_t/etc"
311 cat > "$_cp_t/etc/profile" <<'PERFIL'
312# /etc/profile — lo que lee un login shell.
313#
314# El `ash` de busybox sourcea ESTE fichero y nada más: `/etc/profile.d/*` no se lee si acá no se
315# lee. Sin esto, la consola de rescate tampoco tiene PATH.
316for dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do
317 case ":${PATH:-}:" in
318 *":$dir:"*) ;;
319 *) PATH="${PATH:+$PATH:}$dir" ;;
320 esac
321done
322# Y los del hogar, al final: lo de la persona no debe tapar lo del sistema.
323for dir in "$HOME/.local/bin" "$HOME/bin"; do
324 [ -d "$dir" ] || continue
325 case ":$PATH:" in *":$dir:"*) ;; *) PATH="$PATH:$dir" ;; esac
326done
327export PATH
328umask 022
329
330for perfil in /etc/profile.d/*.sh; do
331 [ -r "$perfil" ] && . "$perfil"
332done
333unset perfil dir
334PERFIL
335 bb chmod 644 "$_cp_t/etc/profile"
336 echo " · /etc/profile escrito (los seis directorios + los del hogar al final)"
337}
338
339# El mismo `_set_shadow` que ya tenía `takana-live-install.sh`, con otro nombre para no pisárselo.
340_cuenta_set_shadow() {
341 _cs_u="$1"; _cs_h="$2"; _cs_f="$3/etc/shadow"
342 if [ ! -f "$_cs_f" ]; then
343 printf '%s:%s:20000:0:99999:7:::\n' "$_cs_u" "$_cs_h" > "$_cs_f"
344 bb chmod 600 "$_cs_f"
345 return
346 fi
347 bb awk -F: -v u="$_cs_u" -v h="$_cs_h" 'BEGIN{OFS=":"}
348 { if ($1==u){ $2=h; seen=1 } print }
349 END{ if(!seen) print u,h,"20000","0","99999","7","","","" }' "$_cs_f" > "$_cs_f.cuenta-tmp" \
350 && mv "$_cs_f.cuenta-tmp" "$_cs_f"
351 bb chmod 600 "$_cs_f"
352}