Los permisos de subcuenta del Storage Box son BINARIOS: --readonly sí o no. No hay append-only ni write-sin-delete (verificado en la API, no recordado). Así que la contención se hace por alcance: el worker escribe en un BUZÓN cuyo home es incoming/, y hammer/store sencillamente no existe para él. Lo máximo que puede destruir es lo que él mismo depositó y aún no se promovió, que sigue estando en el volumen. Un permiso puede estar mal puesto; un directorio fuera de tu home, no. La promoción es un mv DENTRO del mismo filesystem: un rename, instantáneo, cero bytes por la red. Eso es lo que la hace viable con un uplink de 440 kB/s — el laptop manda órdenes, los datos van worker→box por dentro de Hetzner (122 MB/s, 280×). Medido de la shell del box, que NO es un bash: · no hay `for` ⇒ los lotes se arman en el hub · `a; b` no encadena de fiar ⇒ una orden por conexión · `mv a b c dest/` sí acepta varios orígenes ⇒ cientos por conexión · `find` no existe y devuelve 0 EN SILENCIO ⇒ parece «no hay respaldo» Y la trampa que motivó partir el trabajo en dos conjuntos: `mv A dest/` con dest/A ya existente NO falla, mete A DENTRO y deja dest/A/A. Como el store es CAS, un artefacto ya respaldado es idéntico ⇒ no se mueve, se borra del buzón. Los dos caminos probados de punta a punta con un artefacto falso, recuento verificado y sin anidar. Defensa en profundidad aparte: plan de snapshots diario (03:17 UTC, retiene 7 de 10) y la carpeta ZFS visible para recuperar ficheros sueltos sin rollback. La subcuenta no las alcanza: viven en la cuenta, con el token que el worker no tiene. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
10 KiB
Bash
Executable File
147 lines
10 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 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`.
|
|
#
|
|
# ── DOS SUBCUENTAS, DOS VERBOS (2026-08-09) ────────────────────────────────────────────────────
|
|
# `sub1` (home `hammer`, --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 `hammer/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"
|
|
|
|
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"
|
|
;;
|
|
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 -i "$KEY" "root@$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' 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 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.
|
|
ssh -i "$KEY" "root@$IP" "SB=\"ssh -4 -p 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no $BUZ_USER@$BUZ_HOST\"
|
|
echo hola > /tmp/.btest
|
|
esc=\$(scp -4 -P 23 -i /root/.ssh/sb_sub -o StrictHostKeyChecking=no /tmp/.btest $BUZ_USER@$BUZ_HOST:store/.btest 2>&1 | head -1)
|
|
ve=\$(\$SB 'ls store' 2>&1 | head -3 | tr '\n' ' ')
|
|
fuera=\$(\$SB 'ls ../hammer/store' 2>&1 | head -1)
|
|
\$SB 'rm -f store/.btest' >/dev/null 2>&1
|
|
echo \"escribir en buzón : \${esc:-(sin error = OK)}\"
|
|
echo \"ve su buzón : \$ve\"
|
|
echo \"alcanza respaldo : \$fuera\"
|
|
case \"\$fuera\" in *[Nn]o\ such*|*denied*|*[Ff]ailure*|'')
|
|
echo 'VEREDICTO: buzón aislado ✓' ;;
|
|
*) echo 'VEREDICTO: ⚠ ALCANZA EL RESPALDO — no usar'; exit 1 ;; esac"
|
|
;;
|
|
*) sed -n '/^# Uso:/,/verificar-buzon/p' "$0"; exit 2 ;;
|
|
esac
|