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…
takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
1#!/usr/bin/env bash
2# respaldo-subcuenta.sh — crea (o verifica) la subcuenta de SÓLO LECTURA del Storage Box que usa la
3# granja para tirar del respaldo por la red interna de Hetzner.
4#
5# ── POR QUÉ EXISTE: 440 kB/s ────────────────────────────────────────────────────────────────────
6# Medido el 2026-08-09 contra el propio Storage Box: el uplink de la oficina da **440 kB/s**. Con eso
7# los 127 G del store son **82 horas** — subirlo desde el laptop no es lento, es imposible. Pero el
8# Storage Box está en hel1 y el worker está en hel1: entre ellos la copia va por dentro de Hetzner.
9# ⇒ El laptop deja de ser el camino de los datos. Sólo manda las RECETAS (26 M).
10#
11# ── POR QUÉ UNA SUBCUENTA Y NO LA CUENTA PRINCIPAL ─────────────────────────────────────────────
12# El worker es EFÍMERO y se borra solo; darle la credencial principal sería darle permiso de borrado
13# sobre el ÚNICO respaldo que existe. Un respaldo al que puede escribir la máquina de la que hay que
14# protegerse no es un respaldo. La subcuenta acota el daño posible a cero por construcción:
15# --readonly el sistema de ficheros se monta de sólo lectura del lado del box
16# --reachable-externally=false sólo desde la red de Hetzner: la credencial no sirve fuera
17# --home-directory takana no ve el resto del box
18#
19# ── LA VERIFICACIÓN ES NEGATIVA, Y ESO NO ES OPCIONAL ──────────────────────────────────────────
20# «Sólo lectura» es una afirmación sobre lo que el sistema **impide**, así que no se comprueba
21# leyendo (eso sólo prueba que lee). Hay que INTENTAR ESCRIBIR Y BORRAR y exigir que fallen. Medido
22# el 2026-08-09: `rm` → «Read-only file system», `scp` → «dest open: Failure», respaldo intacto.
23# Sin ese paso lo único que sabríamos es que la subcuenta funciona, no que está acotada.
24#
25# ── LA LLAVE VIVE EN EL WORKER Y MUERE CON ÉL ──────────────────────────────────────────────────
26# Se genera en el worker (nunca viaja una privada desde el laptop) y su pública se autoriza en
27# `takana/.ssh/authorized_keys` del box, escrita CON LA CUENTA PRINCIPAL — la subcuenta no puede
28# escribir ni su propio authorized_keys, que es justo lo que se quería.
29# El home es `takana` y no `takana/store` a propósito: el authorized_keys tiene que estar en el home,
30# y no queremos un `.ssh` dentro del store, que es CAS y se cuenta por entradas.
31#
32# ⚠ La shell del Storage Box es RESTRINGIDA: acepta `ls`/`mkdir`/`rm` pero **no redirección**.
33# `echo k > authorized_keys` falla EN SILENCIO (crea el directorio y no el fichero). Va con `scp`.
34#
35# ── DOS SUBCUENTAS, DOS VERBOS (2026-08-09) ────────────────────────────────────────────────────
36# `sub1` (home `takana`, --readonly) es para MIRAR qué hay ya respaldado y no volver a subirlo.
37# `sub2` (home `incoming`, escritura) es el BUZÓN donde el worker DEPOSITA lo nuevo.
38#
39# No hay una sola subcuenta que escriba sin poder borrar: los permisos de la API son binarios
40# (`--readonly` sí o no; verificado, no recordado). La contención se hace por ALCANCE — el buzón no
41# tiene a `takana/store` dentro de su home, así que el respaldo consolidado no existe para él. Lo que
42# el worker puede destruir se reduce a lo que él mismo depositó y aún no se promovió, que sigue
43# estando en el volumen. Promoción: `scripts/farm/respaldo-promover.sh` (un `mv`, cero bytes de red).
44#
45# Uso: scripts/farm/respaldo-subcuenta.sh crear # crea y guarda la clave en ~/.config/hammer
46# scripts/farm/respaldo-subcuenta.sh autorizar <ip-worker> # sub1 (lectura)
47# scripts/farm/respaldo-subcuenta.sh verificar <ip-worker> # la prueba NEGATIVA
48# scripts/farm/respaldo-subcuenta.sh autorizar-buzon <ip-worker> # sub2 (escritura)
49# scripts/farm/respaldo-subcuenta.sh verificar-buzon <ip-worker> # escribe SÍ, respaldo NO
50#
51# ── ⚠ AL TIRAR DEL RESPALDO, `rsync -aH` — LA H NO ES OPCIONAL ─────────────────────────────────
52# El store comparte ficheros entre artefactos por ENLACE DURO. `rsync -a` sin `-H` no los preserva:
53# copia cada enlace como un fichero independiente y el store llega INFLADO. Medido el 2026-08-09:
54# 76 G en el box → 159 G en el volumen, con **0 ficheros de nlink>1** al llegar (la prueba de que se
55# perdieron; el 76 G del box es además ZFS comprimido, así que las dos cosas se sumaban).
56# El contenido es correcto —es CAS, los hashes casan— pero ocupa de más y el volumen no sobra.
57#
58# ── Y AL LLEGAR, PODAR CON EL REGISTRO DE SUPERADOS ────────────────────────────────────────────
59# El respaldo conserva artefactos que el hub YA podó: son SUPERADOS (existe otro con el hash vigente)
60# y no pueden dar cache-hit nunca, porque la receta que los nombraba ya cambió. Medido: 544 de 1536
61# en el volumen, 31 G. Se podan con `work/store-gc-superados.txt` como lista.
62# El guardián es RECONTAR después del rm: «borré N» no es lo mismo que «hay N menos».
63set -uo pipefail
64ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
65BOX="${BOX:-hammer-respaldo}"
66SUB_HOST="${SUB_HOST:-u647150-sub1.your-storagebox.de}"
67SUB_USER="${SUB_USER:-u647150-sub1}"
68MAIN="${MAIN:-u647150@u647150.your-storagebox.de}"
69BUZ_HOST="${BUZ_HOST:-u647150-sub2.your-storagebox.de}"
70BUZ_USER="${BUZ_USER:-u647150-sub2}"
71KEY="${KEY:-$HOME/.ssh/github5}"
72CRED="$HOME/.config/hammer/storagebox-worker.env"
73
74# ── LA GRANJA REUSA IPs, ASÍ QUE LA HOST KEY CAMBIA CON RAZÓN ──────────────────────────────────
75# Los workers son EFÍMEROS y Hetzner recicla las direcciones: una IP que ayer fue `hworker-1` hoy es
76# otra máquina distinta con otra llave. Sin esto, `ssh` corta con «Host key verification failed» —
77# y como estas funciones mandaban stderr a /dev/null, el fallo salía como «no obtuve la pública del
78# worker», que apunta al worker cuando el problema estaba en el known_hosts del laptop. Un mensaje
79# que culpa al sitio equivocado cuesta más que no tener mensaje.
80# Purgar la entrada vieja es correcto ACÁ y sólo acá: la identidad del worker no es su llave, es el
81# label `role=takana-worker` del proyecto hcloud; el secreto no viaja por este canal.
82ssh_worker() { # $1 = ip, resto = orden remota
83 ip="$1"; shift
84 ssh-keygen -R "$ip" >/dev/null 2>&1
85 ssh -i "$KEY" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 "root@$ip" "$@"
86}
87
88case "${1:-}" in
89crear)
90 # La contraseña la exige la API aunque después se use llave. Debe traer mayúscula, minúscula,
91 # número y símbolo, o la API la rechaza con `invalid_input`.
92 PW="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-18)Aa1_%"
93 hcloud storage-box subaccount create "$BOX" \
94 --home-directory hammer --password "$PW" --readonly --enable-ssh \
95 --reachable-externally=false \
96 --description "worker de la granja: lectura del respaldo, red interna" || exit 1
97 umask 077; printf 'SUB_PASSWORD=%s\n' "$PW" > "$CRED"; chmod 600 "$CRED"
98 echo "==> subcuenta creada · credencial en $CRED (600, FUERA del repo, nunca a git)"
99 ;;
100autorizar)
101 IP="${2:?falta la IP del worker}"
102 PUB=$(ssh_worker "$IP" 'mkdir -p /root/.ssh
103 [ -f /root/.ssh/sb_sub ] || ssh-keygen -q -t ed25519 -N "" -C "hworker→respaldo(ro)" -f /root/.ssh/sb_sub
104 cat /root/.ssh/sb_sub.pub' | tail -1)
105 [ -z "$PUB" ] && { echo "!! no obtuve la pública del worker"; exit 1; }
106 T=$(mktemp); printf '%s\n' "$PUB" > "$T"
107 ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p hammer/.ssh' >/dev/null 2>&1
108 # scp y NO `echo >`: la shell restringida del box no hace redirección y falla sin decirlo.
109 scp -4 -P 23 -i "$KEY" "$T" "$MAIN:hammer/.ssh/authorized_keys" || { rm -f "$T"; exit 1; }
110 rm -f "$T"; echo "==> pública del worker autorizada en el box"
111 ;;
112verificar)
113 IP="${2:?falta la IP del worker}"
114 ssh_worker "$IP" "SB=\"ssh -4 -p 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no $SUB_USER@$SUB_HOST\"
115 lee=\$(\$SB 'ls' 2>/dev/null | tr '\n' ' ')
116 borra=\$(\$SB 'rm -rf estado' 2>&1 | head -1)
117 echo hola > /tmp/.wtest; esc=\$(scp -4 -P 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no /tmp/.wtest $SUB_USER@$SUB_HOST:.wtest 2>&1 | head -1)
118 sigue=\$(\$SB 'ls' 2>/dev/null | tr '\n' ' ')
119 echo \"lee: \$lee\"
120 echo \"borrar: \$borra\"
121 echo \"escribir: \$esc\"
122 echo \"intacto: \$sigue\"
123 case \"\$borra\$esc\" in *[Rr]ead-only*|*Failure*) echo 'VEREDICTO: acotada ✓' ;;
124 *) echo 'VEREDICTO: ⚠ ESCRIBE — no usar'; exit 1 ;; esac"
125 ;;
126autorizar-buzon)
127 IP="${2:?falta la IP del worker}"
128 # La MISMA llave del worker sirve para las dos subcuentas: son dos autorizaciones distintas del
129 # box, no dos identidades del worker. Se genera allá y nunca viaja una privada desde el laptop.
130 PUB=$(ssh_worker "$IP" 'mkdir -p /root/.ssh
131 [ -f /root/.ssh/sb_sub ] || ssh-keygen -q -t ed25519 -N "" -C "hworker→respaldo" -f /root/.ssh/sb_sub
132 cat /root/.ssh/sb_sub.pub' | tail -1)
133 [ -z "$PUB" ] && { echo "!! no obtuve la pública del worker"; exit 1; }
134 T=$(mktemp); printf '%s\n' "$PUB" > "$T"
135 ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p incoming/.ssh' >/dev/null 2>&1
136 ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p incoming/store' >/dev/null 2>&1
137 # scp y NO `echo >`: la shell restringida del box no hace redirección y falla sin decirlo.
138 scp -4 -P 23 -i "$KEY" "$T" "$MAIN:incoming/.ssh/authorized_keys" || { rm -f "$T"; exit 1; }
139 rm -f "$T"; echo "==> pública del worker autorizada en el BUZÓN"
140 ;;
141verificar-buzon)
142 IP="${2:?falta la IP del worker}"
143 # La verificación tiene DOS mitades y las dos son necesarias: que SÍ escriba (si no, el respaldo
144 # no se alimenta y nos enteramos el día que haga falta) y que NO alcance el respaldo consolidado
145 # (si lo alcanza, el aislamiento es decorativo). Una sola mitad no prueba nada útil.
146 # ⚠ DOS DETALLES QUE HICIERON QUE ESTA PRUEBA MINTIERA LA PRIMERA VEZ (2026-08-09):
147 # 1. el «Warning: Permanently added …» del host key sale por stderr y se comía el `head -1`, así
148 # que el resultado de escribir era el aviso y no el resultado. Se filtra explícitamente.
149 # 2. el testigo se llamaba `.btest` y `ls` NO muestra los ficheros con punto ⇒ el listado salía
150 # vacío tanto si escribió como si no. Un testigo que no se puede ver no prueba nada.
151 ssh_worker "$IP" "SB=\"ssh -4 -p 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no $BUZ_USER@$BUZ_HOST\"
152 limpia() { grep -v 'Warning: Permanently added' | head -1; }
153 echo testigo > /tmp/testigo-buzon.txt
154 esc=\$(scp -4 -P 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no /tmp/testigo-buzon.txt $BUZ_USER@$BUZ_HOST:store/testigo-buzon.txt 2>&1 | limpia)
155 ve=\$(\$SB 'ls store' 2>/dev/null | grep -c testigo-buzon)
156 fuera=\$(\$SB 'ls ../hammer/store' 2>&1 | limpia)
157 borra=\$(\$SB 'rm -f store/testigo-buzon.txt' 2>&1 | limpia)
158 queda=\$(\$SB 'ls store' 2>/dev/null | grep -c testigo-buzon)
159 echo \"escribir en buzón : \${esc:-(sin error)}\"
160 echo \"testigo visible : \$ve (1 = escribió de verdad)\"
161 echo \"limpia lo suyo : \${borra:-(sin error)} → queda \$queda\"
162 echo \"alcanza respaldo : \$fuera\"
163 ok=1
164 [ \"\$ve\" = 1 ] || { echo 'VEREDICTO: ⚠ NO ESCRIBE — el respaldo no se alimentaría'; ok=0; }
165 case \"\$fuera\" in *[Nn]o\ such*|*denied*|*[Ff]ailure*) ;;
166 *) echo 'VEREDICTO: ⚠ ALCANZA EL RESPALDO — no usar'; ok=0 ;; esac
167 [ \"\$ok\" = 1 ] && echo 'VEREDICTO: escribe en su buzón ✓ · no alcanza el respaldo ✓'
168 [ \"\$ok\" = 1 ] || exit 1"
169 ;;
170*) sed -n '/^# Uso:/,/verificar-buzon/p' "$0"; exit 2 ;;
171esac