Files
takana/scripts/farm/respaldo-subcuenta.sh
T
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +00:00

172 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# respaldo-subcuenta.sh — crea (o verifica) la subcuenta de SÓLO LECTURA del Storage Box que usa la
# granja para tirar del respaldo por la red interna de Hetzner.
#
# ── POR QUÉ EXISTE: 440 kB/s ────────────────────────────────────────────────────────────────────
# Medido el 2026-08-09 contra el propio Storage Box: el uplink de la oficina da **440 kB/s**. Con eso
# los 127 G del store son **82 horas** — subirlo desde el laptop no es lento, es imposible. Pero el
# Storage Box está en hel1 y el worker está en hel1: entre ellos la copia va por dentro de Hetzner.
# ⇒ El laptop deja de ser el camino de los datos. Sólo manda las RECETAS (26 M).
#
# ── POR QUÉ UNA SUBCUENTA Y NO LA CUENTA PRINCIPAL ─────────────────────────────────────────────
# El worker es EFÍMERO y se borra solo; darle la credencial principal sería darle permiso de borrado
# sobre el ÚNICO respaldo que existe. Un respaldo al que puede escribir la máquina de la que hay que
# protegerse no es un respaldo. La subcuenta acota el daño posible a cero por construcción:
# --readonly el sistema de ficheros se monta de sólo lectura del lado del box
# --reachable-externally=false sólo desde la red de Hetzner: la credencial no sirve fuera
# --home-directory takana no ve el resto del box
#
# ── LA VERIFICACIÓN ES NEGATIVA, Y ESO NO ES OPCIONAL ──────────────────────────────────────────
# «Sólo lectura» es una afirmación sobre lo que el sistema **impide**, así que no se comprueba
# leyendo (eso sólo prueba que lee). Hay que INTENTAR ESCRIBIR Y BORRAR y exigir que fallen. Medido
# el 2026-08-09: `rm` → «Read-only file system», `scp` → «dest open: Failure», respaldo intacto.
# Sin ese paso lo único que sabríamos es que la subcuenta funciona, no que está acotada.
#
# ── LA LLAVE VIVE EN EL WORKER Y MUERE CON ÉL ──────────────────────────────────────────────────
# Se genera en el worker (nunca viaja una privada desde el laptop) y su pública se autoriza en
# `takana/.ssh/authorized_keys` del box, escrita CON LA CUENTA PRINCIPAL — la subcuenta no puede
# escribir ni su propio authorized_keys, que es justo lo que se quería.
# El home es `takana` y no `takana/store` a propósito: el authorized_keys tiene que estar en el home,
# y no queremos un `.ssh` dentro del store, que es CAS y se cuenta por entradas.
#
# ⚠ La shell del Storage Box es RESTRINGIDA: acepta `ls`/`mkdir`/`rm` pero **no redirección**.
# `echo k > authorized_keys` falla EN SILENCIO (crea el directorio y no el fichero). Va con `scp`.
#
# ── DOS SUBCUENTAS, DOS VERBOS (2026-08-09) ────────────────────────────────────────────────────
# `sub1` (home `takana`, --readonly) es para MIRAR qué hay ya respaldado y no volver a subirlo.
# `sub2` (home `incoming`, escritura) es el BUZÓN donde el worker DEPOSITA lo nuevo.
#
# No hay una sola subcuenta que escriba sin poder borrar: los permisos de la API son binarios
# (`--readonly` sí o no; verificado, no recordado). La contención se hace por ALCANCE — el buzón no
# tiene a `takana/store` dentro de su home, así que el respaldo consolidado no existe para él. Lo que
# el worker puede destruir se reduce a lo que él mismo depositó y aún no se promovió, que sigue
# estando en el volumen. Promoción: `scripts/farm/respaldo-promover.sh` (un `mv`, cero bytes de red).
#
# Uso: scripts/farm/respaldo-subcuenta.sh crear # crea y guarda la clave en ~/.config/hammer
# scripts/farm/respaldo-subcuenta.sh autorizar <ip-worker> # sub1 (lectura)
# scripts/farm/respaldo-subcuenta.sh verificar <ip-worker> # la prueba NEGATIVA
# scripts/farm/respaldo-subcuenta.sh autorizar-buzon <ip-worker> # sub2 (escritura)
# scripts/farm/respaldo-subcuenta.sh verificar-buzon <ip-worker> # escribe SÍ, respaldo NO
#
# ── ⚠ AL TIRAR DEL RESPALDO, `rsync -aH` — LA H NO ES OPCIONAL ─────────────────────────────────
# El store comparte ficheros entre artefactos por ENLACE DURO. `rsync -a` sin `-H` no los preserva:
# copia cada enlace como un fichero independiente y el store llega INFLADO. Medido el 2026-08-09:
# 76 G en el box → 159 G en el volumen, con **0 ficheros de nlink>1** al llegar (la prueba de que se
# perdieron; el 76 G del box es además ZFS comprimido, así que las dos cosas se sumaban).
# El contenido es correcto —es CAS, los hashes casan— pero ocupa de más y el volumen no sobra.
#
# ── Y AL LLEGAR, PODAR CON EL REGISTRO DE SUPERADOS ────────────────────────────────────────────
# El respaldo conserva artefactos que el hub YA podó: son SUPERADOS (existe otro con el hash vigente)
# y no pueden dar cache-hit nunca, porque la receta que los nombraba ya cambió. Medido: 544 de 1536
# en el volumen, 31 G. Se podan con `work/store-gc-superados.txt` como lista.
# El guardián es RECONTAR después del rm: «borré N» no es lo mismo que «hay N menos».
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
BOX="${BOX:-hammer-respaldo}"
SUB_HOST="${SUB_HOST:-u647150-sub1.your-storagebox.de}"
SUB_USER="${SUB_USER:-u647150-sub1}"
MAIN="${MAIN:-u647150@u647150.your-storagebox.de}"
BUZ_HOST="${BUZ_HOST:-u647150-sub2.your-storagebox.de}"
BUZ_USER="${BUZ_USER:-u647150-sub2}"
KEY="${KEY:-$HOME/.ssh/github5}"
CRED="$HOME/.config/hammer/storagebox-worker.env"
# ── LA GRANJA REUSA IPs, ASÍ QUE LA HOST KEY CAMBIA CON RAZÓN ──────────────────────────────────
# Los workers son EFÍMEROS y Hetzner recicla las direcciones: una IP que ayer fue `hworker-1` hoy es
# otra máquina distinta con otra llave. Sin esto, `ssh` corta con «Host key verification failed» —
# y como estas funciones mandaban stderr a /dev/null, el fallo salía como «no obtuve la pública del
# worker», que apunta al worker cuando el problema estaba en el known_hosts del laptop. Un mensaje
# que culpa al sitio equivocado cuesta más que no tener mensaje.
# Purgar la entrada vieja es correcto ACÁ y sólo acá: la identidad del worker no es su llave, es el
# label `role=takana-worker` del proyecto hcloud; el secreto no viaja por este canal.
ssh_worker() { # $1 = ip, resto = orden remota
ip="$1"; shift
ssh-keygen -R "$ip" >/dev/null 2>&1
ssh -i "$KEY" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 "root@$ip" "$@"
}
case "${1:-}" in
crear)
# La contraseña la exige la API aunque después se use llave. Debe traer mayúscula, minúscula,
# número y símbolo, o la API la rechaza con `invalid_input`.
PW="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-18)Aa1_%"
hcloud storage-box subaccount create "$BOX" \
--home-directory hammer --password "$PW" --readonly --enable-ssh \
--reachable-externally=false \
--description "worker de la granja: lectura del respaldo, red interna" || exit 1
umask 077; printf 'SUB_PASSWORD=%s\n' "$PW" > "$CRED"; chmod 600 "$CRED"
echo "==> subcuenta creada · credencial en $CRED (600, FUERA del repo, nunca a git)"
;;
autorizar)
IP="${2:?falta la IP del worker}"
PUB=$(ssh_worker "$IP" 'mkdir -p /root/.ssh
[ -f /root/.ssh/sb_sub ] || ssh-keygen -q -t ed25519 -N "" -C "hworker→respaldo(ro)" -f /root/.ssh/sb_sub
cat /root/.ssh/sb_sub.pub' | tail -1)
[ -z "$PUB" ] && { echo "!! no obtuve la pública del worker"; exit 1; }
T=$(mktemp); printf '%s\n' "$PUB" > "$T"
ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p hammer/.ssh' >/dev/null 2>&1
# scp y NO `echo >`: la shell restringida del box no hace redirección y falla sin decirlo.
scp -4 -P 23 -i "$KEY" "$T" "$MAIN:hammer/.ssh/authorized_keys" || { rm -f "$T"; exit 1; }
rm -f "$T"; echo "==> pública del worker autorizada en el box"
;;
verificar)
IP="${2:?falta la IP del worker}"
ssh_worker "$IP" "SB=\"ssh -4 -p 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no $SUB_USER@$SUB_HOST\"
lee=\$(\$SB 'ls' 2>/dev/null | tr '\n' ' ')
borra=\$(\$SB 'rm -rf estado' 2>&1 | head -1)
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)
sigue=\$(\$SB 'ls' 2>/dev/null | tr '\n' ' ')
echo \"lee: \$lee\"
echo \"borrar: \$borra\"
echo \"escribir: \$esc\"
echo \"intacto: \$sigue\"
case \"\$borra\$esc\" in *[Rr]ead-only*|*Failure*) echo 'VEREDICTO: acotada ✓' ;;
*) echo 'VEREDICTO: ⚠ ESCRIBE — no usar'; exit 1 ;; esac"
;;
autorizar-buzon)
IP="${2:?falta la IP del worker}"
# La MISMA llave del worker sirve para las dos subcuentas: son dos autorizaciones distintas del
# box, no dos identidades del worker. Se genera allá y nunca viaja una privada desde el laptop.
PUB=$(ssh_worker "$IP" 'mkdir -p /root/.ssh
[ -f /root/.ssh/sb_sub ] || ssh-keygen -q -t ed25519 -N "" -C "hworker→respaldo" -f /root/.ssh/sb_sub
cat /root/.ssh/sb_sub.pub' | tail -1)
[ -z "$PUB" ] && { echo "!! no obtuve la pública del worker"; exit 1; }
T=$(mktemp); printf '%s\n' "$PUB" > "$T"
ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p incoming/.ssh' >/dev/null 2>&1
ssh -4 -p 23 -i "$KEY" "$MAIN" 'mkdir -p incoming/store' >/dev/null 2>&1
# scp y NO `echo >`: la shell restringida del box no hace redirección y falla sin decirlo.
scp -4 -P 23 -i "$KEY" "$T" "$MAIN:incoming/.ssh/authorized_keys" || { rm -f "$T"; exit 1; }
rm -f "$T"; echo "==> pública del worker autorizada en el BUZÓN"
;;
verificar-buzon)
IP="${2:?falta la IP del worker}"
# La verificación tiene DOS mitades y las dos son necesarias: que SÍ escriba (si no, el respaldo
# no se alimenta y nos enteramos el día que haga falta) y que NO alcance el respaldo consolidado
# (si lo alcanza, el aislamiento es decorativo). Una sola mitad no prueba nada útil.
# ⚠ DOS DETALLES QUE HICIERON QUE ESTA PRUEBA MINTIERA LA PRIMERA VEZ (2026-08-09):
# 1. el «Warning: Permanently added …» del host key sale por stderr y se comía el `head -1`, así
# que el resultado de escribir era el aviso y no el resultado. Se filtra explícitamente.
# 2. el testigo se llamaba `.btest` y `ls` NO muestra los ficheros con punto ⇒ el listado salía
# vacío tanto si escribió como si no. Un testigo que no se puede ver no prueba nada.
ssh_worker "$IP" "SB=\"ssh -4 -p 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no $BUZ_USER@$BUZ_HOST\"
limpia() { grep -v 'Warning: Permanently added' | head -1; }
echo testigo > /tmp/testigo-buzon.txt
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)
ve=\$(\$SB 'ls store' 2>/dev/null | grep -c testigo-buzon)
fuera=\$(\$SB 'ls ../hammer/store' 2>&1 | limpia)
borra=\$(\$SB 'rm -f store/testigo-buzon.txt' 2>&1 | limpia)
queda=\$(\$SB 'ls store' 2>/dev/null | grep -c testigo-buzon)
echo \"escribir en buzón : \${esc:-(sin error)}\"
echo \"testigo visible : \$ve (1 = escribió de verdad)\"
echo \"limpia lo suyo : \${borra:-(sin error)} → queda \$queda\"
echo \"alcanza respaldo : \$fuera\"
ok=1
[ \"\$ve\" = 1 ] || { echo 'VEREDICTO: ⚠ NO ESCRIBE — el respaldo no se alimentaría'; ok=0; }
case \"\$fuera\" in *[Nn]o\ such*|*denied*|*[Ff]ailure*) ;;
*) echo 'VEREDICTO: ⚠ ALCANZA EL RESPALDO — no usar'; ok=0 ;; esac
[ \"\$ok\" = 1 ] && echo 'VEREDICTO: escribe en su buzón ✓ · no alcanza el respaldo ✓'
[ \"\$ok\" = 1 ] || exit 1"
;;
*) sed -n '/^# Uso:/,/verificar-buzon/p' "$0"; exit 2 ;;
esac