granja: subcuenta de SÓLO LECTURA del Storage Box — el respaldo entra al volumen por dentro de Hetzner

MEDIDO, no supuesto: el uplink de la oficina da 440 kB/s ⇒ los 127 G del store son 82 horas. Subir
el store desde el laptop no es lento, es imposible. Pero el Storage Box está en hel1 y el worker
está en hel1: por la red interna la misma copia va a 122 MB/s. Son 280×.
⇒ El laptop deja de ser el camino de los datos. Sólo manda las RECETAS (26 M).

POR QUÉ 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 a cero por construcción: --readonly, --reachable-externally=false, home acotado a hammer/.

LA VERIFICACIÓN ES NEGATIVA. «Sólo lectura» es una afirmación sobre lo que el sistema IMPIDE, así
que leer no la prueba. Hay que intentar escribir y borrar y exigir que fallen:
  rm  → «Read-only file system» · scp → «dest open: Failure» · respaldo intacto.
El script trae ese paso como subcomando `verificar` y sale ≠0 si la subcuenta resulta escribir.

Dos gotchas que costaron:
· la API exige contraseña con mayúscula+minúscula+número+símbolo aunque después se use llave;
· la shell del Storage Box es RESTRINGIDA: acepta ls/mkdir/rm pero NO redirección, así que
  `echo k > authorized_keys` falla EN SILENCIO (crea el directorio, no el fichero). Va con scp.

La llave se genera EN EL WORKER (nunca viaja una privada desde el laptop) y muere con él.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:43:16 -04:00
co-authored by Claude Opus 5
parent 4c06eaef88
commit 17c4194f5d
+86
View File
@@ -0,0 +1,86 @@
#!/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 hammer 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
# `hammer/.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 `hammer` y no `hammer/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`.
#
# Uso: scripts/farm/respaldo-subcuenta.sh crear # crea y guarda la clave en ~/.config/hammer
# scripts/farm/respaldo-subcuenta.sh autorizar <ip-worker>
# scripts/farm/respaldo-subcuenta.sh verificar <ip-worker> # la prueba NEGATIVA
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}"
KEY="${KEY:-$HOME/.ssh/github5}"
CRED="$HOME/.config/hammer/storagebox-worker.env"
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 -i "$KEY" "root@$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' 2>/dev/null | 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 -i "$KEY" "root@$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"
;;
*) sed -n '/^# Uso:/,/verificar/p' "$0"; exit 2 ;;
esac