gioser

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…

⎇ 0f66c9973305b15da33e86b6f5bb2eb2bab9f3ad scripts/lib/cuenta.sh Shell · 352 líneas · 17.9 KB Historia crudo

fix(instalador): la cuenta de quien va a usar la maquina se pregunta antes de instalar 0f66c9973 · sergio ·

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}