detomas.net Curso de Bash desde cero
Descargar PDF

Curso de Bash desde cero / Capítulo 14

Bash en el mundo real

Hasta ahora todo ha pasado dentro de tu máquina. Este capítulo es el que saca tus scripts a la red: entrar en un servidor, copiar archivos a él y hablar con servicios web.

Son las cuatro herramientas que aparecen en cualquier trabajo real con servidores: ssh para entrar, rsync para copiar, curl para pedir cosas por HTTP y jq para entender lo que te contestan.

Entrar en otra máquina: ssh

SSH (Secure Shell) abre una terminal en otro ordenador. Escribes en el tuyo, pero los comandos se ejecutan allí. Todo lo que va y viene está cifrado.

ssh nica@203.0.113.45

Es usuario@máquina. La máquina puede ser una IP o un nombre de dominio. Si el servidor no usa el puerto estándar (el 22), se indica con -p:

ssh -p 2222 nica@203.0.113.45

Para salir, exit o Ctrl+D, como en cualquier terminal.

La primera vez que te conectas a un servidor, SSH te avisa de que no lo conoce y te enseña su huella digital. Si dices que sí, la guarda en ~/.ssh/known_hosts y no vuelve a preguntar. Si algún día te avisa de que la huella ha cambiado en un servidor que ya conocías, para y averigua por qué: suele ser que reinstalaron el servidor, pero es exactamente la misma señal que daría alguien suplantándolo.

Ejecutar un comando sin quedarte dentro

Esto es lo que más se usa en scripts. Si pones un comando entre comillas después del destino, SSH lo ejecuta allí, te devuelve la salida y cierra la conexión:

ssh nica@203.0.113.45 "df -h /"
ssh nica@203.0.113.45 "systemctl status nginx"

Y como la salida vuelve a tu terminal, se puede meter en un pipe o guardar en una variable, igual que cualquier otro comando:

libre=$(ssh nica@203.0.113.45 "df -h / | awk 'NR==2 {print \$5}'")
echo "El disco del servidor está al $libre"

Consejo

Fíjate en el \$5 con barra invertida. Dentro de comillas dobles, tu Bash local sustituiría $5 antes de enviar nada (capítulo 9). La barra invertida le dice «este dólar es para el otro lado». Si el comando remoto es complicado, suele ser más limpio usar comillas simples por fuera.

Claves en vez de contraseña

Escribir la contraseña cada vez es incómodo, y en un script directamente no sirve: un script no puede teclearla. La solución son las claves SSH.

Una clave SSH son dos archivos que van juntos:

La analogía habitual: la pública es un candado abierto y la privada es la única llave que lo abre. Puedes repartir candados por todos los servidores que quieras; mientras solo tú tengas la llave, solo tú entras.

Se generan con ssh-keygen:

ssh-keygen -t ed25519 -C "nica@portatil"

-t ed25519 elige el tipo de clave (el recomendado hoy) y -C añade un comentario para que luego sepas de qué máquina era. Te preguntará dónde guardarla (acepta lo que propone) y si quieres una contraseña para la clave.

El resultado son dos archivos:

-rw------- 1 nica nica 399 Jul 31 15:10 id_ed25519
-rw-r--r-- 1 nica nica  95 Jul 31 15:10 id_ed25519.pub

Mira los permisos, que son los del capítulo 6: la privada es 600 (solo tú puedes leerla) y la pública 644 (la puede leer cualquiera). SSH se niega a usar una clave privada si tiene permisos más abiertos, así que si algún día te da un error raro de permisos, ya sabes.

La pública es una sola línea de texto:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDqvdWwQ8GwSgqt/jXlhpLWy7k24Q55goNr0JX1ak80y nica@portatil

Y se instala en el servidor con un comando que lo hace todo:

ssh-copy-id nica@203.0.113.45

Te pedirá la contraseña esa última vez, y a partir de ahí entras sin ella. Lo que hace por dentro es añadir tu clave pública al archivo ~/.ssh/authorized_keys del servidor.

Precaución

La clave privada (el archivo sin .pub) no se comparte nunca: ni por correo, ni en un repositorio de Git, ni copiándola a otro servidor. Quien la tenga entra donde tú entras. Si alguna vez se te escapa, borra la pública correspondiente del authorized_keys de todos los servidores y genera otra.

Lo que se copia siempre es el archivo .pub.

El archivo ~/.ssh/config

Recordar puertos, IPs y usuarios es insufrible. El archivo ~/.ssh/config te deja ponerle un mote a cada servidor:

Host vps
    HostName 203.0.113.45
    User nica
    Port 2222
    IdentityFile ~/.ssh/id_ed25519

Host *
    ServerAliveInterval 60

Con eso, esto:

ssh -p 2222 nica@203.0.113.45

se convierte en esto:

ssh vps

Y el mote funciona en todo lo demás: scp archivo vps:/ruta, rsync ... vps:, ssh vps "comando". El bloque Host * aplica a todos los servidores; ese ServerAliveInterval 60 manda una señal cada minuto para que la conexión no se caiga sola cuando te distraes.

Puedes comprobar qué configuración le sale a un destino sin conectarte, con ssh -G:

ssh -G vps | grep -E '^(hostname|user|port|identityfile) '

Y sale:

user nica
hostname 203.0.113.45
port 2222
identityfile ~/.ssh/id_ed25519

Nota

El archivo config debe tener permisos 600, igual que la clave privada: chmod 600 ~/.ssh/config. Si no, SSH lo ignora.

Copiar archivos: scp

scp (secure copy) copia archivos por SSH. La sintaxis es la de cp, pero con usuario@máquina:ruta en el lado remoto.

scp informe.txt vps:/home/nica/documentos/     # subir
scp vps:/var/log/nginx/error.log .             # bajar
scp -r web/ vps:/var/www/                      # una carpeta entera

Igual que cp, necesita -r para carpetas. Y el puerto se indica con -P mayúscula (en ssh era -p minúscula, que es una de esas incoherencias que hay que sufrir; con el ~/.ssh/config te olvidas del tema).

Precaución

Los dos puntos son el error número uno de scp. Si escribes scp informe.txt vps sin los dos puntos, scp no entiende «al servidor vps»: entiende «copia el archivo aquí mismo con el nombre vps». No da error, y te quedas esperando un archivo que nunca llegó.

Regla: si no hay : en algún lado, no estás copiando a ninguna parte.

Si los dos puntos van sin ruta detrás (vps:), se copia a la carpeta personal del usuario, que suele ser lo que quieres.

Copiar bien: rsync

scp copia todo, siempre. rsync compara origen y destino y copia solo lo que ha cambiado. Para subir una web de cien archivos de los que has tocado dos, la diferencia es abismal.

rsync -avz web/ vps:/var/www/detomas/

Las opciones que se usan siempre:

Opción Qué hace
-a Modo archivo: recursivo y conservando permisos y fechas
-v Cuenta lo que va haciendo
-z Comprime durante el envío (útil por red, inútil en local)
-n o --dry-run Ensayo: dice lo que haría, sin hacer nada
--delete Borra en el destino lo que ya no está en el origen
--exclude Se salta lo que le digas (--exclude '.git')
-P Muestra progreso y permite reanudar archivos grandes

La barra final, que confunde a todo el mundo

Esta es la particularidad de rsync. Una barra al final del origen cambia el resultado:

rsync -av web/ destino/    # copia el CONTENIDO de web dentro de destino
rsync -av web  destino/    # copia la CARPETA web dentro de destino

Con barra queda destino/index.html. Sin barra queda destino/web/index.html.

La forma de acordarse: la barra significa «lo de dentro de». Y la forma de no equivocarse nunca es la de la sección siguiente.

Ensayar antes: –dry-run

--dry-run (o -n) hace que rsync te cuente exactamente lo que haría, sin tocar nada:

rsync -av --dry-run web/ vps:/var/www/detomas/

Y sale:

sending incremental file list
./
nuevo.txt

sent 178 bytes  received 23 bytes  402.00 bytes/sec
total size is 18  speedup is 0.09 (DRY RUN)

Ese (DRY RUN) del final es la confirmación de que no se ha hecho nada.

Precaución

--delete borra en el destino todo lo que no esté en el origen. Es lo que quieres para dejar una web idéntica a tu copia local, y es lo que vacía un servidor entero si te equivocas de carpeta de origen o de barra final.

Con --delete, el --dry-run no es opcional. Ejecútalo siempre primero y lee la lista de lo que va a borrar.

rsync -av --delete --dry-run web/ vps:/var/www/detomas/

Y sale:

sending incremental file list
deleting index.html
./
nuevo.txt

Ahí se ve claramente qué va a desaparecer antes de que desaparezca.

Hablar con una web: curl

curl hace peticiones HTTP desde la terminal. En su forma más simple, descarga una página y la escupe por pantalla:

curl https://detomas.net

Las opciones que de verdad se usan:

Opción Qué hace
-s Callado: sin barra de progreso ni estadísticas
-o archivo Guarda la respuesta en un archivo en vez de imprimirla
-L Sigue las redirecciones
-I Solo las cabeceras, sin el contenido
-w Imprime datos de la petición con un formato que tú eliges
--max-time Se rinde pasados N segundos
-H Añade una cabecera
-X Cambia el método (POST, PUT, DELETE…)
-d Envía datos en el cuerpo de la petición

Ver solo las cabeceras

curl -sI https://detomas.net | head -4

Y sale:

HTTP/2 200
x-powered-by: PHP/8.1.34
content-type: text/html; charset=UTF-8
date: Fri, 31 Jul 2026 13:11:12 GMT

Seguir redirecciones

Muchas URLs no responden directamente, sino que te mandan a otra. Sin -L, curl se queda en la redirección:

curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' https://detomas.net/cursos

Y sale:

301 -> https://detomas.net/cursos/

Con -L la sigue hasta el final:

curl -sL -o /dev/null -w '%{http_code} final: %{url_effective}\n' https://detomas.net/cursos

Y sale:

200 final: https://detomas.net/cursos/

Comprobar si una web está viva

Esta combinación es la que vas a usar en scripts. -o /dev/null tira el contenido (no nos interesa) y -w '%{http_code}' imprime solo el código de estado:

curl -s -o /dev/null -w '%{http_code}\n' https://detomas.net

Y sale:

200

Los códigos que hay que reconocer: 200 todo bien, 301 y 302 redirección, 404 no existe, 500 el servidor ha reventado, 000 ni siquiera se pudo conectar (esto último es de curl, no de HTTP).

Metido en una variable, ya es una comprobación:

codigo=$(curl -sL -o /dev/null -w '%{http_code}' --max-time 10 https://detomas.net)
if [ "$codigo" != "200" ]; then
    echo "La web responde $codigo" >&2
fi

Enviar datos: POST y webhooks

Hasta aquí solo hemos pedido cosas. Para enviarlas se usa POST:

curl -X POST https://ejemplo.com/webhook \
     -H "Content-Type: application/json" \
     -d '{"evento":"web-caida","url":"https://detomas.net","codigo":500}'

Las tres piezas:

Si el JSON es largo, se puede leer de un archivo con @:

curl -X POST https://ejemplo.com/webhook \
     -H "Content-Type: application/json" \
     -d @aviso.json

Esto es exactamente lo que necesita un webhook: una URL que se queda esperando a que alguien le mande datos para arrancar un proceso. Es la forma de que un script de Bash dispare un flujo de n8n.

Precaución

Los webhooks suelen llevar un token o una clave en la URL. No lo escribas dentro del script, sobre todo si el script va a un repositorio de Git. Guárdalo en una variable de entorno y exígela con lo del capítulo 13:

WEBHOOK="${WEBHOOK_URL:?define WEBHOOK_URL antes de ejecutar}"

Y ejecuta el script así: WEBHOOK_URL="https://..." ./mi-script.sh

Leer JSON sin volverse loco: jq

Casi todo lo que devuelve una API es JSON. Y el JSON no se procesa con grep: no tiene una estructura de líneas, los espacios dan igual, un valor puede ocupar varias líneas o estar todo en una. Lo que grep te devuelve es texto que luego tienes que volver a trocear a mano.

jq entiende la estructura. Se instala con sudo apt install jq.

Vamos a usar este archivo, servidores.json:

cat > servidores.json << 'FIN'
{
  "empresa": "detomas.net",
  "servidores": [
    { "nombre": "web",    "ip": "203.0.113.10", "activo": true,  "carga": 0.42 },
    { "nombre": "n8n",    "ip": "203.0.113.11", "activo": true,  "carga": 0.87 },
    { "nombre": "backup", "ip": "203.0.113.12", "activo": false, "carga": 0.05 }
  ]
}
FIN

Lo básico

Un punto solo significa «todo», y sirve para formatear JSON apelmazado:

jq . servidores.json

Un nombre detrás del punto saca ese campo:

jq '.empresa' servidores.json      # "detomas.net"  (con comillas)
jq -r '.empresa' servidores.json   # detomas.net    (crudo, sin comillas)

-r (raw) es imprescindible cuando el valor va a acabar en una variable de Bash: sin él te llevas las comillas dentro.

Recorrer listas

[] recorre todos los elementos de una lista:

jq -r '.servidores[].nombre' servidores.json

Y sale:

web
n8n
backup

Y | dentro de jq encadena operaciones, igual que el pipe de Bash pero dentro del propio jq:

jq -r '.servidores[] | .nombre + " " + .ip' servidores.json

Y sale:

web 203.0.113.10
n8n 203.0.113.11
backup 203.0.113.12

Filtrar

select() se queda solo con lo que cumpla la condición:

jq -r '.servidores[] | select(.activo == true) | .nombre' servidores.json

Y sale:

web
n8n

Y con \(...) puedes construir el texto de salida como quieras:

jq -r '.servidores[] | select(.carga > 0.5) | "\(.nombre): carga \(.carga)"' servidores.json

Y sale:

n8n: carga 0.87
Filtro Qué hace
. Todo, formateado
.campo Un campo
.[] Recorre una lista
.[0] El primer elemento
length Cuántos hay
select(cond) Filtra
keys Los nombres de los campos
-r Sin comillas

El combo: curl y jq

Aquí es donde todo encaja. Una petición, y del resultado sacas solo el dato que te interesa:

primero=$(jq -r '.servidores[0].nombre' servidores.json)
echo "El primero se llama: $primero"

Y sale:

El primero se llama: web

Con curl es lo mismo, cambiando el archivo por un pipe:

curl -s https://una-api.com/estado | jq -r '.estado'

El propio curl sabe emitir JSON con sus estadísticas, así que puedes probar el combo ahora mismo contra cualquier web:

curl -s -o /dev/null -w '%{json}' https://detomas.net \
  | jq '{codigo: .http_code, segundos: .time_total, tam: .size_download}'

Y sale:

{
  "codigo": 200,
  "segundos": 0.089304,
  "tam": 40180
}

Un script de verdad

Juntamos todo el capítulo y el anterior: un script que comprueba si tu web responde y, si no, avisa por webhook.

#!/bin/bash
set -euo pipefail

URL="${1:-https://detomas.net}"
WEBHOOK="${WEBHOOK_URL:?define WEBHOOK_URL antes de ejecutar}"

codigo=$(curl -sL -o /dev/null -w '%{http_code}' --max-time 10 "$URL" || true)

if [ "$codigo" = "200" ]; then
    echo "OK: $URL responde $codigo"
    exit 0
fi

echo "AVISO: $URL responde $codigo" >&2

respuesta=$(curl -s -X POST "$WEBHOOK" \
    -H "Content-Type: application/json" \
    -d "{\"evento\":\"web-caida\",\"url\":\"$URL\",\"codigo\":\"$codigo\"}")

echo "Aviso enviado. Respuesta: $(echo "$respuesta" | jq -r '.recibido')"
exit 1

Ejecutándolo:

--- la web responde bien ---
OK: https://detomas.net responde 200
código de salida: 0

--- la web no responde ---
AVISO: https://detomas.net/no-existe-999 responde 404
Aviso enviado. Respuesta: true
código de salida: 1

--- el dominio ni siquiera existe ---
AVISO: https://noexisteestedominio-xyz123.net responde 000
Aviso enviado. Respuesta: true
código de salida: 1

--- falta el webhook ---
vigilar.sh: line 5: WEBHOOK_URL: define WEBHOOK_URL antes de ejecutar
código de salida: 1

Repasa lo que hay ahí dentro, porque es casi el curso entero:

Añádele una línea de crontab (capítulo 11) y tienes una vigilancia de tu web funcionando cada cinco minutos:

*/5 * * * * WEBHOOK_URL="https://n8n.detomas.net/webhook/xxx" /home/nica/bin/vigilar.sh

Nota

El \" dentro del -d es necesario porque el JSON va entre comillas dobles para que se sustituyan $URL y $codigo. Cuando el JSON crece, esto se hace ilegible y es mejor construirlo con jq -n:

cuerpo=$(jq -n --arg url "$URL" --arg cod "$codigo" \
         '{evento: "web-caida", url: $url, codigo: $cod}')

Además, así jq se encarga de escapar los caracteres raros.

Ejercicios

  1. Genera un par de claves SSH nuevo con ssh-keygen guardándolo en ~/pruebas/clave_test, y mira los permisos de los dos archivos que crea.

  2. Escribe una entrada en ~/.ssh/config para un servidor inventado y comprueba con ssh -G que la configuración sale como esperas. (Sin conectarte a ninguna parte.)

  3. Copia una carpeta a otro sitio con rsync, primero con --dry-run. Después añade un archivo al origen, bórrale otro, y vuelve a ejecutarlo con --delete --dry-run para ver qué haría.

  4. Escribe un script que reciba una lista de URLs como argumentos y muestre el código de estado de cada una, marcando con un aviso las que no devuelvan

Soluciones

1. El -N '' pone la clave sin contraseña, para no tener que teclear nada:

mkdir -p ~/pruebas
ssh-keygen -t ed25519 -C "prueba" -f ~/pruebas/clave_test -N ''
ls -l ~/pruebas/

La privada sale como -rw------- (600) y la pública como -rw-r--r-- (644).

2. Escribe el bloque en ~/.ssh/config y consulta:

ssh -G miservidor | grep -E '^(hostname|user|port) '

ssh -G resuelve la configuración y la imprime sin abrir ninguna conexión, así que es seguro probar con servidores que no existen.

3. El ciclo completo:

rsync -av --dry-run origen/ destino/    # ver qué haría
rsync -av origen/ destino/              # hacerlo
touch origen/nuevo.txt && rm origen/viejo.txt
rsync -av --delete --dry-run origen/ destino/   # ver qué borraría

En la última salida aparece una línea deleting viejo.txt. Esa es la línea que hay que leer siempre antes de quitar el --dry-run.

4. Un bucle sobre los argumentos, con la comprobación de curl:

#!/bin/bash
set -euo pipefail

[ "$#" -ge 1 ] || { echo "uso: $(basename "$0") URL..." >&2; exit 1; }

fallos=0

for url in "$@"; do
    codigo=$(curl -sL -o /dev/null -w '%{http_code}' --max-time 10 "$url" || true)
    if [ "$codigo" = "200" ]; then
        echo "OK    $codigo  $url"
    else
        echo "AVISO $codigo  $url"
        fallos=$((fallos + 1))
    fi
done

echo "---"
echo "Revisadas $# URLs, $fallos con problemas"
[ "$fallos" -eq 0 ]

La última línea es un detalle útil: [ "$fallos" -eq 0 ] es la última orden del script, así que su resultado es el código de salida. Si hubo fallos, devuelve distinto de cero y cron se entera.