Todo lo que llevas construido pasa en un solo servidor. Pero en producción hay tres —srv-veloz-01, srv-veloz-02 y srv-veloz-03—, y comprobar el estado de la flota entrando a mano en cada uno no es automatización: es lo mismo de antes con más ventanas de terminal. ssh es la herramienta que convierte un script local en una orden que se ejecuta en cualquier máquina, y con ella el toolkit da su último salto del módulo. Verás SSH desde el punto de vista de un script —bastante distinto del de una persona escribiendo en una terminal—, cómo autenticarse sin que nadie teclee una contraseña y sin abrir un agujero, y cómo recorrer una flota en paralelo sin que un servidor caído tumbe el recorrido.

Contenido

  1. ssh desde un script: qué shell obtienes y qué código devuelve
  2. Autenticación por clave
  3. Limitar lo que una clave automatizada puede hacer
  4. ~/.ssh/config y la reutilización de conexión
  5. Las opciones que un script necesita
  6. known_hosts bien gestionado
  7. Bloques remotos con here-document
  8. Enviar scripts y transferir ficheros
  9. Recorrer la flota, en serie y en paralelo
  10. sudo remoto y cuándo Bash deja de ser la respuesta
  11. Aplicación: nace flota.sh

  1. ssh desde un script: qué shell obtienes y qué código devuelve

Sin comando, ssh abre una sesión interactiva. Con comando (ssh veloz@srv-veloz-02 'uptime') ocurre algo distinto y muy importante: se ejecuta un shell no interactivo y no de inicio de sesión, exactamente la misma situación que cron (07-01).

Sesión interactiva ssh host ssh host comando
¿Lee ~/.bash_profile o ~/.bashrc? No (salvo BASH_ENV, raro)
¿Alias y funciones de tu perfil? / PATH Sí / el de tu perfil No / el mínimo del sistema
¿Hay TTY? No, salvo -t

Compruébalo con ssh veloz@srv-veloz-02 'echo "$PATH"': responde /usr/local/bin:/usr/bin:/bin, y no lo que ves al entrar a mano. La consecuencia es la misma que con cron, y la solución también: rutas absolutas siempre, y si necesitas configuración, cárgala explícitamente. Un ssh srv-veloz-02 'estado-servicio.sh' fallará con «command not found» aunque el script funcione perfectamente cuando entras a mano. Y como no hay TTY, un comando remoto que pregunte algo se queda colgado o falla: se aplica la propiedad 1 de 07-02, nada interactivo, ahora también al otro lado del cable. ssh devuelve el código de salida del comando remoto, lo cual es magnífico: toda tu lógica de errores de 05-03 viaja por la red. Pero hay una excepción crítica: 255 es el código que ssh usa para sus propios errores —host inalcanzable, autenticación rechazada, clave desconocida—. Distinguirlo es lo que separa «el servidor está caído» de «el servicio del servidor está caído», dos incidencias muy distintas:

salida=$(ssh -n -o BatchMode=yes veloz@srv-veloz-02 'estado-servicio.sh' 2>&1) && codigo=0 || codigo=$?
case "$codigo" in
    0)   veloz_log_info  "srv-veloz-02: OK" ;;
    255) veloz_log_error "srv-veloz-02: INACCESIBLE (${salida%%$'\n'*})" ;;
    *)   veloz_log_warn  "srv-veloz-02: fallo con codigo $codigo" ;;
esac

La ambigüedad es teórica pero real: si tu comando remoto devolviera 255 por su cuenta, no podrías distinguirlo; por eso los códigos convencionales de 05-03 se quedan por debajo de 128.

  1. Autenticación por clave

Un script no puede teclear una contraseña; la autenticación por clave pública lo resuelve y además es más segura.

ssh-keygen -t ed25519 -C "veloz-ops en srv-veloz-01" -f ~/.ssh/id_veloz_ops
ssh-copy-id -i ~/.ssh/id_veloz_ops.pub veloz@srv-veloz-02

-t ed25519 elige el algoritmo recomendado hoy: claves cortas, rápidas y sólidas (RSA de 4096 bits vale si el destino es antiguo). -f da un nombre propio, para no mezclar la clave de automatización con la tuya personal; se generan id_veloz_ops (privada, nunca sale de la máquina) e id_veloz_ops.pub (pública, la que se copia). ssh-copy-id la añade al ~/.ssh/authorized_keys del destino y arregla los permisos, cosa importante porque SSH es estricto con ellos y falla en silencio si no le gustan: ~/.ssh a 700, authorized_keys y la clave privada a 600, y ~ sin escritura para grupo ni otros —si no, cualquiera podría reemplazar .ssh—. La frase de paso es la decisión incómoda: una clave protegida es más segura, pero alguien tiene que escribirla. ssh-agent guarda la clave descifrada en memoria durante tu sesión (eval "$(ssh-agent)" y ssh-add ~/.ssh/id_veloz_ops), lo cual es perfecto para trabajo interactivo e inútil para una tarea de cron a las 03:00, cuando no hay sesión ni agente. Las tareas automáticas usan claves sin frase de paso, y hay que ser honesto sobre el riesgo: quien consiga ese fichero tiene acceso a todos los destinos donde está autorizada. Como el riesgo no se puede evitar, se acota: permisos 600 y propietario correcto, una clave distinta por finalidad para revocarla sin afectar a lo demás, y restricciones en el destino.

  1. Limitar lo que una clave automatizada puede hacer

Una entrada de authorized_keys admite opciones delante de la clave que limitan lo que se puede hacer con ella. Es la diferencia entre una clave que da acceso total al servidor y una que solo sirve para lo que dijiste:

from="10.0.0.11",command="/home/veloz/veloz-ops/bin/despachador-remoto.sh",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3Nz... veloz-ops

from= impide usar la clave desde otra dirección; no-port-forwarding impide usar el servidor como túnel hacia la red interna; no-agent-forwarding impide aprovechar tu agente para saltar a otras máquinas; no-pty niega la terminal interactiva; y command= fuerza a ejecutar siempre esa orden, sea cual sea la que pida el cliente. Esta última es la más potente y la que más se olvida: aunque alguien robe la clave, lo único que puede hacer es lanzar ese script. La orden que el cliente pidió queda en $SSH_ORIGINAL_COMMAND, lo que permite un pequeño despachador que solo acepte lo previsto:

# despachador-remoto.sh — unico comando permitido por la clave de operaciones
case "${SSH_ORIGINAL_COMMAND:-}" in
    estado)   exec /home/veloz/veloz-ops/bin/estado-servicio.sh ;;
    respaldo) exec /home/veloz/veloz-ops/bin/respaldo.sh ;;
    *)        printf 'comando no permitido\n' >&2; exit 64 ;;
esac

El case con lista cerrada (04-05) es lo correcto: enumera lo permitido en lugar de filtrar lo peligroso. Y nunca pases $SSH_ORIGINAL_COMMAND a eval ni a un shell; sería regalar la ejecución arbitraria que acabas de evitar (08-03).

  1. ~/.ssh/config y la reutilización de conexión

Repetir usuario, puerto y clave en cada llamada es ruido; ~/.ssh/config (permisos 600) lo centraliza.

Host srv-veloz-*
    User veloz
    IdentityFile ~/.ssh/id_veloz_ops
    IdentitiesOnly yes
    ConnectTimeout 5
    ServerAliveInterval 15
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 60
Host srv-veloz-03
    HostName 10.0.2.33
    ProxyJump bastion.veloz.example

Host srv-veloz-* aplica a todos los que casen con el patrón, y los bloques más específicos se suman. IdentitiesOnly yes evita que SSH ofrezca todas tus claves antes de la correcta, cosa que puede provocar «demasiados intentos de autenticación»; ServerAliveInterval detecta una conexión muerta en vez de dejarla colgada; y ProxyJump salta a través de una máquina intermedia sin túneles a mano. ControlMaster es la joya para bucles. Con auto, la primera conexión a un host abre un socket de control en ControlPath y las siguientes reutilizan esa conexión en lugar de negociar de nuevo; ControlPersist 60 mantiene el socket un minuto tras la última. Establecer una sesión SSH cuesta entre 200 y 500 ms, así que un bucle con diez órdenes sobre tres servidores pasa de varios segundos a casi nada:

ejecutar time ssh srv-veloz-02 true dos veces seguidas da 0m0,412s la primera y 0m0,038s la segunda, diez veces más rápida porque no ha vuelto a autenticarse. Un aviso: si el socket queda huérfano, ssh -O exit srv-veloz-02 lo cierra.

  1. Las opciones que un script necesita

Opción Qué hace Por qué es imprescindible
-n Entrada estándar desde /dev/null Ver abajo: es el error clásico
-o BatchMode=yes Nunca pregunta; falla en su lugar Sin ella se cuelga pidiendo contraseña
-o ConnectTimeout=5 Máximo para conectar Un host caído no puede colgar la tarea
-o StrictHostKeyChecking=accept-new Acepta hosts nuevos, rechaza cambios Apartado 6

El problema de -n merece su propio párrafo, porque desconcierta a todo el mundo la primera vez. En while read -r host; do ssh "$host" 'uptime'; done < servidores.txt solo se procesa el primer servidor, y la razón es que ssh lee la entrada estándar para enviársela al comando remoto. Dentro del bucle, la entrada estándar es servidores.txt, así que el primer ssh se traga el resto del fichero y el read de la segunda vuelta no encuentra nada: el bucle termina tras un solo servidor, sin error y sin explicación. La solución es ssh -n, un idioma que hay que memorizar igual que while IFS= read -r de 04-01. La alternativa es usar el descriptor 3 (done < servidores.txt 3<&0 y read -u 3), pero -n es más simple. Y ojo: solo se pone -n cuando no quieres alimentar al comando remoto; si le estás pasando datos por una tubería, no debe estar.

  1. known_hosts bien gestionado

La primera vez que te conectas a una máquina, SSH pregunta si aceptas su huella. Esa pregunta cuelga un script, y la tentación es -o StrictHostKeyChecking=no, que es una mala idea: desactiva la comprobación que impide que un atacante se haga pasar por tu servidor. Que sea común no la hace correcta. Las dos formas correctas:

# a) Aceptar hosts nuevos, pero seguir rechazando cambios de clave
ssh -o StrictHostKeyChecking=accept-new -n veloz@srv-veloz-02 'uptime'
# b) Registrar las claves por adelantado, en el aprovisionamiento
ssh-keyscan -t ed25519 srv-veloz-01 srv-veloz-02 srv-veloz-03 >> ~/.ssh/known_hosts

accept-new es el punto medio razonable: no pregunta la primera vez, pero si la clave de un host cambia —señal de un ataque o de una reinstalación— falla ruidosamente, que es justo lo que quieres. La (b) es más rigurosa y la adecuada para un entorno serio: las claves se recogen una vez, se verifican y se distribuyen con el resto de la configuración. Cuando reinstales un servidor y la clave cambie legítimamente, ssh-keygen -R srv-veloz-02 borra la entrada antigua.

  1. Bloques remotos con here-document

Para varias órdenes remotas, meterlas en una cadena con ; se vuelve ilegible; la forma limpia usa un here-document (05-05) alimentando un shell remoto.

ssh -o BatchMode=yes veloz@srv-veloz-02 bash -s <<'EOF'
set -euo pipefail
cd /srv/veloz/datos
printf 'srv-veloz-02: %s envios, %s libres\n' "$(wc -l < envios.csv)" "$(df -h --output=avail /srv|tail -1)"
EOF

bash -s le dice al Bash remoto que lea el script de su entrada estándar, y el here-document se la proporciona. Fíjate en que aquí no se pone -n: la entrada estándar es precisamente el canal por el que viaja el script. Y las comillas simples del delimitador son la parte crítica de este idioma, porque deciden dónde se expanden las variables. Con <<'EOF' el texto viaja literal y $var se expande en el destino; con <<EOF sin comillas, el Bash local expande antes de enviar. Compáralo:

fecha=2026-08-03
ssh srv-veloz-02 bash -s <<'EOF'
echo "remoto: $(hostname), fecha: $fecha"      # -> remoto: srv-veloz-02, fecha:
EOF
ssh srv-veloz-02 bash -s <<EOF
echo "remoto: \$(hostname), fecha: $fecha"     # -> remoto: srv-veloz-02, fecha: 2026-08-03
EOF

En el primer caso, $(hostname) se ejecuta en el destino (correcto) y $fecha sale vacía porque esa variable no existe allí. En el segundo, $fecha la expande el shell local antes de enviar y \$(hostname) va escapada para ejecutarse en el destino. Mezclar los dos mundos produce errores desconcertantes, así que la recomendación es usar siempre <<'EOF' y pasar los valores locales como argumentos, más seguro y más legible:

ssh srv-veloz-02 bash -s -- "$fecha" "$umbral" <<'EOF'
set -euo pipefail
fecha="$1"; umbral="$2"
awk -F, -v f="$fecha" -v u="$umbral" '$2 ~ f && $5 == "incidencia" { c++ }
    END { print (c + 0 >= u ? "ALERTA" : "OK"), c + 0 }' /srv/veloz/datos/envios.csv
EOF

Los argumentos tras -- llegan al script remoto como $1, $2… Así no hay expansión local que se cuele, y un valor con espacios o comillas no puede alterar el significado del código remoto: la misma defensa contra la inyección que aplicabas con awk -v (06-01) y jq --arg (06-05).

  1. Enviar scripts y transferir ficheros

Cuando el trabajo remoto pasa de veinte líneas deja de tener sentido incrustarlo.

# a) Enviar por la entrada estandar, sin dejar rastro en el destino
ssh -o BatchMode=yes veloz@srv-veloz-02 'bash -s' -- --dry-run < ~/veloz-ops/bin/comprueba-nodo.sh
# b) Copiar, ejecutar y limpiar conservando el codigo de salida
scp -q ~/veloz-ops/bin/comprueba-nodo.sh veloz@srv-veloz-02:/tmp/ && ssh -n veloz@srv-veloz-02 \
    'bash /tmp/comprueba-nodo.sh; rc=$?; rm -f /tmp/comprueba-nodo.sh; exit $rc'

La opción (a) es elegante y la que conviene por defecto: no toca el disco remoto, así que no hay que limpiar ni queda una versión antigua olvidada. La (b) hace falta cuando el script debe persistir; fíjate en que guarda el código de salida antes de borrar, para no perderlo en el rm. Para ficheros, scp está bien para uno suelto, pero para directorios rsync es superior en todo —transfiere solo lo cambiado, preserva permisos, permite excluir, se reanuda y acepta --link-dest—:

rsync -az --delete -e 'ssh -o BatchMode=yes -o ConnectTimeout=5' /respaldos/diarios/ srv-veloz-02:/respaldos/n01/

-e fija el transporte con todas las opciones de la conexión, y -z sí tiene sentido aquí porque va por red (recuerda de 07-03 que en local no aportaba nada). Esto completa la regla 3-2-1: la copia que sale del servidor. Y para hacerlo bien, la clave del respaldo remoto se restringe en el destino con command="rrsync -wo /respaldos", un envoltorio que solo permite a rsync escribir bajo esa ruta: el apartado 3 aplicado al caso más frecuente.

  1. Recorrer la flota, en serie y en paralelo

El requisito central: que un servidor caído no aborte el recorrido. Con set -e activo, un ssh fallido mataría el script en el primer host inaccesible y perderías la información de los otros dos. El idioma orden && codigo=0 || codigo=$? es lo que neutraliza set -e para esa orden y captura el código a la vez (05-03), y a partir de ahí un case como el del apartado 1 distingue el 255 del resto. Tres servidores en serie, a dos segundos cada uno, son seis segundos; con treinta, un minuto. En paralelo, el tiempo es el del más lento. Retomando 05-02, con una salvedad importante: si varios procesos escriben a la vez en la misma salida, las líneas se entremezclan, así que cada rama escribe en su propio fichero y se consolida al final. Cuatro decisiones hacen que el recorrido paralelo del apartado 11 funcione. Cada rama de fondo escribe su código y su salida en ficheros separados dentro de un mktemp -d, porque una subshell no puede devolver valores al padre por variables. El wait || true espera a todas y evita que el código distinto de cero de una rama aborte el script. La consolidación recorre la lista de servidores en su orden original, así que el informe sale determinista aunque los nodos respondan en cualquier orden —un informe cuyo orden cambia cada día es incomparable—. Y un trap … EXIT limpia el temporal. Para flotas grandes conviene limitar la concurrencia: xargs -P 10 (05-01) es la forma más simple de no pasar de diez conexiones simultáneas.

  1. sudo remoto y cuándo Bash deja de ser la respuesta

Si el comando remoto necesita privilegios, el problema es que sudo querrá una contraseña y no hay terminal donde escribirla. La forma correcta es autorizar una orden concreta sin contraseña en el destino, con una línea veloz ALL=(root) NOPASSWD: /usr/bin/systemctl restart veloz-api en /etc/sudoers.d/veloz-ops. Con eso, ssh -n srv-veloz-02 'sudo /usr/bin/systemctl restart veloz-api' funciona sin preguntar. Ruta absoluta, sin comodines y para un usuario concreto: un NOPASSWD: ALL equivale a dar la contraseña de root a quien robe la clave. La alternativa sudo -S, que lee la contraseña de la entrada estándar, obliga a enviarla por una tubería, que es justo lo que intentábamos evitar. Y aquí aparece requiretty: algunas configuraciones de sudoers exigen una terminal, lo que hace fallar todo sudo remoto no interactivo con «sorry, you must have a tty to run sudo»; se arregla con Defaults:veloz !requiretty en el destino. Forzar el TTY con ssh -t es la solución sucia, porque además mezcla stdout y stderr y desordena la salida. Hay que saber reconocer el límite. Con tres servidores y comprobaciones simples, flota.sh es perfecto: no hay nada que instalar, es transparente y lo entiende cualquiera. Pero si empiezas a necesitar plantillas de configuración, gestión de paquetes, control de que el estado deseado se cumple, inventarios dinámicos o ejecución sobre cincuenta máquinas, estás reescribiendo mal lo que Ansible ya hace bien —y por SSH, sin agente, con el mismo modelo mental—; pssh/parallel-ssh es el escalón intermedio para lanzar la misma orden en muchos hosts. La señal de alarma es clara: cuando tu script de Bash empieza a llevar dentro una lista de paquetes a instalar y plantillas de ficheros de configuración, ha dejado de ser un script de operaciones.

  1. Aplicación: nace flota.sh

El último script del toolkit ejecuta estado-servicio.sh en los tres servidores en paralelo y consolida el resultado.

#!/usr/bin/env bash
# flota.sh — Estado consolidado de la flota. CUANDO: cada 15 min (timer).
# Codigos: 0 toda la flota OK | 1 algun nodo con problema | 2 algun nodo inaccesible
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export LC_ALL=C
readonly BASE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
. "$BASE/lib/comun.sh"; [[ -r $BASE/etc/veloz-ops.conf ]] && . "$BASE/etc/veloz-ops.conf"
VELOZ_COMPONENTE=flota
readonly -a FLOTA=(${VELOZ_FLOTA:-srv-veloz-01 srv-veloz-02 srv-veloz-03})
readonly REMOTO=/home/veloz/veloz-ops/bin/estado-servicio.sh
readonly -a SSH_OPC=(-n -o BatchMode=yes -o ConnectTimeout=5 -o ControlMaster=auto
                     -o StrictHostKeyChecking=accept-new -o ControlPersist=30
                     -o ControlPath="$HOME/.ssh/cm-%r@%h:%p")
estado_texto() { case "$1" in 0) printf OK ;; 255) printf INACCESIBLE ;; *) printf "FALLO($1)" ;; esac; }

main() {
    veloz_requiere ssh; local tmp host cod peor=0
    tmp=$(mktemp -d "${TMPDIR:-/tmp}/flota.XXXXXX"); trap 'rm -rf "$tmp"' EXIT
    for host in "${FLOTA[@]}"; do
        { local sal c; sal=$(timeout 60s ssh "${SSH_OPC[@]}" "$host" "$REMOTO" 2>&1) && c=0 || c=$?
          printf '%s\n' "$c" > "$tmp/$host.cod"; printf '%s\n' "$sal" > "$tmp/$host.sal"; } &
    done
    wait || true
    printf '%-14s %-12s %s\n' NODO ESTADO DETALLE
    for host in "${FLOTA[@]}"; do
        cod=$(< "$tmp/$host.cod"); printf '%-14s %-12s %s\n' "$host" \
            "$(estado_texto "$cod")" "$(head -1 "$tmp/$host.sal")"
        case "$cod" in
            0)   veloz_log_info  "$host: OK" ;;
            255) veloz_log_error "$host: inaccesible"; peor=2 ;;
            *)   veloz_log_warn  "$host: codigo $cod"; (( peor < 1 )) && peor=1 ;;
        esac
    done
    veloz_log_info "FIN, peor estado: $peor"; return "$peor"
}
main "$@"
NODO           ESTADO       DETALLE
srv-veloz-01   OK           veloz-api sana, 14 envios en cola
srv-veloz-03   INACCESIBLE  ssh: connect to host srv-veloz-03 port 22: timed out

Repasa las piezas: las opciones de SSH agrupadas en un array para no repetirlas ni perder el entrecomillado; ControlMaster para reutilizar conexión; timeout 60s además de ConnectTimeout, porque el primero cubre el comando remoto colgado y el segundo solo la conexión; ramas en paralelo escribiendo a ficheros separados; consolidación en orden fijo; y códigos que distinguen los tres desenlaces. Como devuelve 0/1/2, flota.sh encaja como una comprobación más de vigilante.sh (07-04) o como un .service con su timer (07-05).

Errores Comunes y Consejos

  • Olvidar -n en un bucle. El ssh se come el fichero de entrada y solo se procesa el primer host, sin ningún error visible.
  • Suponer que el destino lee .bashrc, o usar StrictHostKeyChecking=no. Lo primero da «command not found» pese a funcionar al entrar a mano (rutas absolutas); lo segundo desactiva la única defensa contra la suplantación (usa accept-new o ssh-keyscan).
  • Sin BatchMode=yes o sin ConnectTimeout. Lo primero cuelga el script esperando una contraseña que nadie escribirá; lo segundo deja que un host caído bloquee el recorrido durante minutos.
  • Confundir 255 con un fallo de la aplicación. «El servidor no responde» y «el servicio del servidor falla» son incidencias distintas.
  • Delimitador de here-document sin comillas, o varias ramas paralelas escribiendo a la misma salida. Lo primero expande las variables en el sitio equivocado; lo segundo entremezcla las líneas (un fichero por rama y consolidar). Y NOPASSWD: ALL convierte una clave robada en acceso total: autoriza órdenes concretas con ruta absoluta.
  • Consejo: prueba siempre ssh -n host 'comando' a mano antes de meterlo en un bucle, y añade -v si algo no cuadra: la traza de ssh -v dice exactamente qué clave se ofreció, qué rechazó el servidor y qué fichero de configuración se aplicó.

Ejercicios

Ejercicio 1. Este bucle solo procesa el primer servidor y, cuando uno está caído, aborta. Encuentra los cuatro problemas y reescríbelo.

set -e
while read -r host; do ssh "$host" "uptime; df -h /"; done < ~/veloz-ops/etc/flota.txt

Ejercicio 2. Escribe veloz_remoto para lib/comun.sh: ejecuta un comando en un host remoto con todas las opciones de automatización, distingue inaccesible de fallo de aplicación con códigos distintos, y limita la duración total.

Soluciones

Solución 1. Los cuatro problemas: (a) falta -n, así que el primer ssh consume el fichero y el bucle termina; (b) set -e aborta en cuanto un host falla, perdiendo los demás; (c) sin BatchMode ni ConnectTimeout, un host caído cuelga el recorrido o pide contraseña; (d) sin IFS= read -r y sin filtrar líneas vacías o comentadas, un fichero de inventario normal rompe el bucle.

while IFS= read -r host; do
    [[ -z $host || $host == \#* ]] && continue
    if salida=$(ssh -n -o BatchMode=yes -o ConnectTimeout=5 "$host" 'uptime; df -h /' 2>&1); then
        printf '=== %s ===\n%s\n' "$host" "$salida"
    else
        printf '=== %s === ERROR (%s): %s\n' "$host" "$?" "${salida%%$'\n'*}"
    fi
done < ~/veloz-ops/etc/flota.txt

El if alrededor del ssh es lo que permite convivir con set -e (05-03): un fallo dentro de la condición de un if no aborta el script. Y la salida se captura en una variable para etiquetarla con el nombre del host, en lugar de dejar que tres servidores escriban entremezclados.

Solución 2.

# veloz_remoto <host> <comando...>
# Codigos: 0 ok | 69 host inaccesible | 124 se agoto el tiempo | otro = del comando remoto
veloz_remoto() {
    local host="${1:?falta el host}" limite="${VELOZ_SSH_LIMITE:-60}" salida codigo; shift
    salida=$(timeout "${limite}s" ssh -n -o BatchMode=yes -o ConnectTimeout=5 \
        -o StrictHostKeyChecking=accept-new -o ControlMaster=auto \
        -o ControlPath="$HOME/.ssh/cm-%r@%h:%p" "$host" "$@" 2>&1) && codigo=0 || codigo=$?
    case "$codigo" in
        0)   printf '%s\n' "$salida"; return 0 ;;
        124) veloz_log_error "$host: agotados los ${limite}s"; return 124 ;;
        255) veloz_log_error "$host: inaccesible (${salida%%$'\n'*})"; return 69 ;;
        *)   veloz_log_warn "$host: el comando fallo con codigo $codigo"
             printf '%s\n' "$salida" >&2; return "$codigo" ;;
    esac
}

El 255 se traduce a 69 (EX_UNAVAILABLE), el mismo código que usaba veloz_api_get en 06-05 para «el servicio no está disponible»: así el llamante trata igual una API caída y un servidor caído, que operativamente es lo mismo. Los demás códigos se propagan tal cual, de modo que la lógica de errores del script remoto llega intacta al local. Y la salida correcta va a stdout mientras los diagnósticos van a stderr (02-04), así que datos=$(veloz_remoto srv-veloz-02 cat /etc/os-release) captura solo lo útil.

Conclusión

ssh host comando te da un shell no interactivo y sin perfil: el mismo problema de entorno que cron, con la misma solución —rutas absolutas y configuración cargada explícitamente— y sin nada interactivo al otro lado. Devuelve el código del comando remoto, salvo el 255, que son los errores del propio ssh y hay que distinguir siempre. La autenticación es por clave (ssh-keygen -t ed25519, ssh-copy-id, permisos 700/600 estrictos); en automatización va sin frase de paso, y como el riesgo no se puede eliminar se acota: una clave por finalidad y restricciones en authorized_keys con command=, from=, no-port-forwarding y no-pty. Centraliza lo repetitivo en ~/.ssh/config con User, IdentityFile, IdentitiesOnly, ProxyJump y sobre todo ControlMaster/ControlPersist, que reutiliza la conexión y hace un bucle diez veces más rápido. Las cuatro opciones imprescindibles: -n —porque ssh lee la entrada estándar y dentro de un while read se traga el fichero, procesando un solo host sin dar ningún error—, -o BatchMode=yes para que falle en vez de preguntar, -o ConnectTimeout para que un host caído no bloquee el recorrido, y StrictHostKeyChecking=accept-new (o ssh-keyscan por adelantado) en lugar del peligroso no. Para bloques remotos, ssh host bash -s <<'EOF' con comillas en el delimitador y los valores locales pasados como argumentos tras --: es lo que decide dónde se expanden las variables y lo que impide que un dato altere el código. Envía scripts por la entrada estándar antes que copiarlos, usa rsync -az -e ssh en vez de scp para directorios —que además completa la regla 3-2-1 de 07-03—, recorre la flota capturando el código de cada host para que uno caído no aborte los demás, paraleliza con & y wait escribiendo a ficheros separados y consolidando en orden fijo, autoriza sudo remoto por órdenes concretas —nunca NOPASSWD: ALL— y reconoce el límite: cuando aparezcan plantillas e inventarios, Ansible hace mejor ese trabajo.

Con flota.sh se cierra el Módulo 7 y el toolkit de Veloz Envíos. Ya no espera tus órdenes: informe-diario.sh publica su informe cada mañana aunque el servidor pasara la noche apagado, respaldo.sh copia, verifica, retiene y replica fuera del servidor, vigilante.sh comprueba ocho cosas cada cinco minutos y avisa una sola vez por incidencia, y flota.sh consulta los tres servidores en paralelo. Y aquí aparece el problema siguiente, que es de otra naturaleza: esto son ya varios cientos de líneas repartidas en cinco scripts y una librería, escritas a lo largo de siete módulos, que ejecutan tareas privilegiadas, manejan claves SSH y tocan datos de clientes. Funciona, pero ¿es mantenible? ¿Es seguro? ¿Cómo sabes que un cambio no rompe nada? El Módulo 8 responde: código legible (08-01), optimización de lo que de verdad importa (08-02), seguridad (08-03), control de versiones con Git (08-04), análisis estático con ShellCheck y shfmt (08-05), pruebas automatizadas con Bats (08-06) y portabilidad frente a POSIX (08-07). El toolkit ya funciona solo; toca convertirlo en software del que fiarse.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados