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.45Es 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.45Para 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
\$5con barra invertida. Dentro de comillas dobles, tu Bash local sustituiría$5antes 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 clave privada, que se queda en tu ordenador y no sale de ahí jamás.
- La clave pública, que copias al servidor.
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.pubMira 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@portatilY se instala en el servidor con un comando que lo hace todo:
ssh-copy-id nica@203.0.113.45Te 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 delauthorized_keysde 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 60Con eso, esto:
ssh -p 2222 nica@203.0.113.45se convierte en esto:
ssh vpsY 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_ed25519Nota
El archivo
configdebe tener permisos600, 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 enteraIgual 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 escribesscp informe.txt vpssin los dos puntos,scpno entiende «al servidor vps»: entiende «copia el archivo aquí mismo con el nombrevps». 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 destinoCon 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
--deleteborra 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-runno 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.txtAhí 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.netLas 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 -4Y 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 GMTSeguir 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/cursosY 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/cursosY 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.netY sale:
200Los 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
fiEnviar 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:
-X POSTes el método: «te traigo datos», en vez de «dame algo».-H "Content-Type: application/json"avisa de que lo que mandas es JSON. Sin esa cabecera, muchos servicios no saben interpretarlo.-des el contenido. Va entre comillas simples para que Bash no toque las comillas dobles de dentro del JSON.
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.jsonEsto 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 }
]
}
FINLo básico
Un punto solo significa «todo», y sirve para formatear JSON apelmazado:
jq . servidores.jsonUn 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.jsonY sale:
web
n8n
backupY | dentro de jq encadena operaciones,
igual que el pipe de Bash pero dentro del propio jq:
jq -r '.servidores[] | .nombre + " " + .ip' servidores.jsonY sale:
web 203.0.113.10
n8n 203.0.113.11
backup 203.0.113.12Filtrar
select() se queda solo con lo que cumpla la
condición:
jq -r '.servidores[] | select(.activo == true) | .nombre' servidores.jsonY sale:
web
n8nY con \(...) puedes construir el texto de salida como
quieras:
jq -r '.servidores[] | select(.carga > 0.5) | "\(.nombre): carga \(.carga)"' servidores.jsonY 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: webCon 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 1Ejecutá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: 1Repasa lo que hay ahí dentro, porque es casi el curso entero:
set -euo pipefaily${WEBHOOK_URL:?...}del capítulo 13.${1:-...}para que el argumento sea opcional.- El
|| truees para el caso de quecurlni siquiera pueda conectar. Cuando eso pasa,curlimprime000y además devuelve un código de error, que conset -ehabría matado el script antes de poder avisar. El|| truese come ese error y deja que el000llegue a la variable. >&2manda el aviso al canal de errores, del capítulo 4.exit 1para que cron sepa que hubo un problema.- Y las comillas de
"$URL"y"$codigo", del capítulo 9.
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.shNota
El
\"dentro del-des necesario porque el JSON va entre comillas dobles para que se sustituyan$URLy$codigo. Cuando el JSON crece, esto se hace ilegible y es mejor construirlo conjq -n:cuerpo=$(jq -n --arg url "$URL" --arg cod "$codigo" \ '{evento: "web-caida", url: $url, codigo: $cod}')Además, así
jqse encarga de escapar los caracteres raros.
Ejercicios
Genera un par de claves SSH nuevo con
ssh-keygenguardándolo en~/pruebas/clave_test, y mira los permisos de los dos archivos que crea.Escribe una entrada en
~/.ssh/configpara un servidor inventado y comprueba conssh -Gque la configuración sale como esperas. (Sin conectarte a ninguna parte.)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-runpara ver qué haría.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íaEn 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.