Módulo 5: Máquinas remotas, redes y scripting

8. Proyecto final: toolkit de diagnóstico remoto

Descripción

En esta lección vas a construir ops-toolkit: tres scripts de shell que se combinan para diagnosticar si un servidor remoto está sano, y un README.md que documenta cómo usarlo y cómo defenderlo frente a otra persona. check-env.sh audita que un equipo tiene lo necesario para operar (dependencias, PATH, variables de entorno, permisos de una llave privada). probe.sh diagnostica un dominio capa por capa (DNS, HTTP, puerto local). remote-report.sh conecta ambos por SSH a una máquina remota, trae los resultados con rsync y arma un resumen con grep, sort y uniq -c. Cada script sale con un código distinto según qué falló, documentado en una tabla — no "salió mal", sino "salió mal porque falta una dependencia" versus "salió mal porque el DNS no resuelve".

Esto es exactamente lo que hace un ingeniero cuando le llega el mensaje "el sitio no responde" a las 11 de la noche: no abre un IDE, abre una terminal, se conecta al servidor y corre un puñado de comandos en un orden que ya sabe de memoria porque los automatizó hace tiempo. La diferencia entre "tardé cuarenta minutos tecleando lo mismo de siempre" y "corrí un script y en diez segundos supe que era el DNS" es exactamente el contenido de este módulo.

Conexión con el módulo: este proyecto no enseña nada nuevo — cablea todo lo que ya viste. Las variables de entorno y los permisos de archivo (lección 1), IP/puertos/DNS (lección 2), dig/curl/ss (lección 3), SSH con llaves y ~/.ssh/config (lección 4), rsync (lección 5), y argumentos, case, bucles, códigos de salida, set -euo pipefail y trap (lecciones 6 y 7) se convierten aquí en una sola herramienta que corre de punta a punta.

Arquitectura del toolkit: tres capas, una responsabilidad cada una

Piensa en un electricista al que llaman a una casa que no conoce. No empieza destapando paredes. Primero revisa el panel eléctrico: ¿hay corriente entrando, están los interruptores en su lugar, el panel está etiquetado o es un caos? Eso es check-env.sh — no diagnostica nada del problema en sí, solo confirma que las herramientas y el entorno están en condiciones de hacer un diagnóstico serio. Después prueba cada tomacorriente por separado, en orden: ¿llega electricidad a la caja (DNS), ¿el aparato responde cuando lo conectas (HTTP), ¿el interruptor específico está encendido (puerto local)? Eso es probe.sh. Y si la casa está en otra ciudad, el electricista no maneja hasta allá con las manos vacías: manda a alguien con las mismas herramientas, esa persona revisa en el lugar, y el resultado vuelve por mensajería. Eso es remote-report.sh.

La razón por la que son tres scripts y no uno solo con quinientas líneas es la misma que viste en la lección 7 sobre cuándo un script "ya creció demasiado": cada uno hace una cosa, cada uno se puede probar solo, y remote-report.sh no repite la lógica de diagnóstico — la reutiliza copiándola al remoto y ejecutándola ahí. Es la filosofía Unix aplicada a tus propias herramientas, no solo a las de otros.

Una advertencia antes de empezar: ss es parte de iproute2, exclusivo de Linux. Si escribes y pruebas estos scripts desde macOS, la capa 3 de probe.sh va a fallar localmente con "comando no encontrado" — y por eso check-env.sh verifica su presencia en vez de asumirla. El diseño real es que corras probe.sh en el servidor (casi siempre Linux) a través de remote-report.sh, y lo uses en tu laptop solo para la vista "de afuera" (DNS y HTTP).

Crea una carpeta ops-toolkit/ y ve armando esto adentro:

ops-toolkit/
├── check-env.sh
├── probe.sh
├── remote-report.sh
├── domains.txt
└── README.md

Ejemplo trabajado: check-env.sh, el gate de entrada

check-env.sh no prueba red ni servidores — prueba que tu propio equipo está listo para operar el toolkit. Verifica cuatro cosas, en orden: que el PATH incluya lo básico, que existan los comandos que el resto del toolkit necesita, que esté definida la variable TOOLKIT_SSH_KEY (la ruta a tu llave privada), y que esa llave tenga permisos que solo tú puedas leer.

#!/usr/bin/env bash
# check-env.sh — audita que este equipo está listo para operar ops-toolkit:
# PATH, comandos requeridos, variables de entorno y permisos de la llave SSH.
set -euo pipefail

# Códigos de salida del toolkit completo (documentados también en README.md).
readonly EX_OK=0
readonly EX_USAGE=64       # argumentos inválidos
readonly EX_UNAVAILABLE=69 # falta un comando requerido
readonly EX_NOPERM=77      # permisos inseguros en un archivo sensible
readonly EX_CONFIG=78      # falta una variable de entorno

SCRIPT_NAME="$(basename "$0")"
DRY_RUN=0

usage() {
  cat <<EOF
Uso: ${SCRIPT_NAME} [--dry-run] [--help]

Verifica que este equipo tiene lo necesario para correr ops-toolkit:
comandos requeridos en el PATH, la variable TOOLKIT_SSH_KEY, y permisos
seguros en la llave privada que apunta.

  --dry-run   Muestra qué se verificaría, sin abortar por ningún fallo.
  --help      Muestra esta ayuda y sale con código 0.

Códigos de salida: ver tabla en README.md.
EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run) DRY_RUN=1; shift ;;
    --help) usage; exit "${EX_OK}" ;;
    *)
      echo "Error: argumento desconocido '$1'" >&2
      usage >&2
      exit "${EX_USAGE}"
      ;;
  esac
done

fail() {
  local code="$1" msg="$2"
  echo "FALLO: ${msg}" >&2
  if [[ "${DRY_RUN}" -eq 1 ]]; then
    echo "  (dry-run: no se aborta, pero en modo normal saldría con código ${code})"
  else
    exit "${code}"
  fi
}

echo "== PATH =="
echo "  PATH actual: ${PATH}"
for dir in /usr/bin /bin; do
  case ":${PATH}:" in
    *":${dir}:"*) echo "  ok: ${dir} está en PATH" ;;
    *) echo "  advertencia: ${dir} no está en PATH (típico si esto corre bajo cron)" ;;
  esac
done

echo "== dependencias =="
REQUIRED_COMMANDS=(dig curl ss ssh rsync)
for cmd in "${REQUIRED_COMMANDS[@]}"; do
  if command -v "${cmd}" >/dev/null 2>&1; then
    echo "  ok: ${cmd} -> $(command -v "${cmd}")"
  else
    fail "${EX_UNAVAILABLE}" "falta el comando '${cmd}' en el PATH"
  fi
done

echo "== variable de entorno TOOLKIT_SSH_KEY =="
if [[ -z "${TOOLKIT_SSH_KEY:-}" ]]; then
  fail "${EX_CONFIG}" "TOOLKIT_SSH_KEY no está definida (ejemplo: export TOOLKIT_SSH_KEY=\$HOME/.ssh/id_ed25519)"
else
  echo "  ok: TOOLKIT_SSH_KEY=${TOOLKIT_SSH_KEY}"
fi

echo "== permisos de la llave privada =="
if [[ -n "${TOOLKIT_SSH_KEY:-}" && -f "${TOOLKIT_SSH_KEY}" ]]; then
  # %Lp (BSD/macOS) o %a (GNU/Linux) devuelven el modo en octal, ej. "600".
  # Asume que la llave no tiene setuid/sticky bit, lo cual es el caso normal.
  PERMS="$(stat -f "%Lp" "${TOOLKIT_SSH_KEY}" 2>/dev/null || stat -c "%a" "${TOOLKIT_SSH_KEY}")"
  if [[ "${PERMS}" =~ ^[0-7]00$ ]]; then
    echo "  ok: permisos ${PERMS} (solo el dueño puede leer/escribir)"
  else
    fail "${EX_NOPERM}" "${TOOLKIT_SSH_KEY} tiene permisos ${PERMS}; corrige con: chmod 600 ${TOOLKIT_SSH_KEY}"
  fi
elif [[ -n "${TOOLKIT_SSH_KEY:-}" ]]; then
  fail "${EX_CONFIG}" "TOOLKIT_SSH_KEY apunta a '${TOOLKIT_SSH_KEY}', que no existe"
fi

echo "check-env.sh: todo en orden."
exit "${EX_OK}"

Cómo leerlo: la función fail() centraliza el mensaje ("FALLO: ...") y el código de salida en un solo lugar, y respeta --dry-run — en vez de decidir en cada punto de fallo si abortar o no, cada llamada a fail() ya sabe qué hacer según el modo. El chequeo de permisos usa una expresión regular (^[0-7]00$) en vez de comparar números, porque comparar "640" contra "600" con -gt compara enteros decimales, no bits de permisos — funciona la mayoría de las veces por casualidad, pero la regex dice exactamente lo que quieres: "los últimos dos dígitos deben ser cero".

Qué esperar cuando lo corres con la variable bien puesta y la llave en 600:

$ chmod +x check-env.sh
$ export TOOLKIT_SSH_KEY=$HOME/.ssh/id_ed25519
$ ./check-env.sh
== PATH ==
  PATH actual: /usr/local/bin:/usr/bin:/bin
  ok: /usr/bin está en PATH
  ok: /bin está en PATH
== dependencias ==
  ok: dig -> /usr/bin/dig
  ok: curl -> /usr/bin/curl
  ok: ss -> /usr/sbin/ss
  ok: ssh -> /usr/bin/ssh
  ok: rsync -> /usr/bin/rsync
== variable de entorno TOOLKIT_SSH_KEY ==
  ok: TOOLKIT_SSH_KEY=/home/ana/.ssh/id_ed25519
== permisos de la llave privada ==
  ok: permisos 600 (solo el dueño puede leer/escribir)
check-env.sh: todo en orden.
$ echo $?
0

Y si la llave quedara con permisos 644 (legible por cualquiera del grupo):

$ chmod 644 ~/.ssh/id_ed25519
$ ./check-env.sh; echo "salida: $?"
...
== permisos de la llave privada ==
FALLO: /home/ana/.ssh/id_ed25519 tiene permisos 644; corrige con: chmod 600 /home/ana/.ssh/id_ed25519
salida: 77

El 77 no es arbitrario — es el mismo código que vas a documentar en la tabla del README.md, y es el que remote-report.sh va a interpretar más adelante.

probe.sh: diagnóstico por capas de un dominio

La idea de "diagnosticar por capas" viene directo de la lección 3: cuando algo no responde, el orden de sospecha va de lo más básico a lo más específico. Si el nombre no resuelve, no tiene sentido probar HTTP. Si HTTP responde, no tiene sentido sospechar del DNS. probe.sh automatiza exactamente ese orden de descarte, y lo separa en tres capas: dig para DNS, curl -w para HTTP con tiempos, y ss para confirmar si hay un proceso escuchando localmente en el puerto — esta última pensada para cuando corres el script en el propio servidor (vía remote-report.sh), donde te dice si el problema es que el servicio nunca arrancó, y no un tema de red.

#!/usr/bin/env bash
# probe.sh — diagnostica un dominio en tres capas: DNS, HTTP y el estado del
# puerto local. Resultados por stdout, errores por stderr.
set -euo pipefail

readonly EX_OK=0
readonly EX_USAGE=64
readonly EX_UNAVAILABLE=69 # DNS no resuelve
readonly EX_SOFTWARE=70    # la petición HTTP falló (timeout, conexión rechazada)

SCRIPT_NAME="$(basename "$0")"
PORT=""
SCHEME="https"
DRY_RUN=0
DOMAIN=""

usage() {
  cat <<EOF
Uso: ${SCRIPT_NAME} <dominio> [--port PUERTO] [--scheme http|https] [--dry-run] [--help]

Diagnostica un dominio en tres capas: DNS (dig), HTTP (curl -w) y el estado
del puerto local (ss).

  --port PUERTO       Puerto para ss (por defecto: 443 si scheme=https, 80 si http)
  --scheme http|https Esquema para la petición curl (por defecto: https)
  --dry-run           Muestra los comandos, sin ejecutarlos
  --help              Muestra esta ayuda y sale con código 0

Códigos de salida: ver tabla en README.md.
EOF
}

fail() {
  local code="$1" msg="$2"
  echo "FALLO: ${msg}" >&2
  exit "${code}"
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --port) PORT="$2"; shift 2 ;;
    --scheme) SCHEME="$2"; shift 2 ;;
    --dry-run) DRY_RUN=1; shift ;;
    --help) usage; exit "${EX_OK}" ;;
    -*) fail "${EX_USAGE}" "opción desconocida '$1'" ;;
    *)
      [[ -n "${DOMAIN}" ]] && fail "${EX_USAGE}" "solo se acepta un dominio, ya recibí '${DOMAIN}'"
      DOMAIN="$1"
      shift
      ;;
  esac
done

[[ -z "${DOMAIN}" ]] && { usage >&2; fail "${EX_USAGE}" "falta el dominio a diagnosticar"; }
[[ -z "${PORT}" ]] && { [[ "${SCHEME}" == "https" ]] && PORT=443 || PORT=80; }

echo "== Capa 1/3: DNS (${DOMAIN}) =="
if [[ "${DRY_RUN}" -eq 1 ]]; then
  echo "[dry-run] dig +short ${DOMAIN}"
else
  # dig puede fallar (red caída) o simplemente no tener registros; en ambos
  # casos IP queda vacía y lo tratamos como una sola condición de fallo.
  IP="$(dig +short "${DOMAIN}" | tail -n1 || true)"
  [[ -z "${IP}" ]] && fail "${EX_UNAVAILABLE}" "no resuelve ningún registro para '${DOMAIN}'"
  echo "resuelve a: ${IP}"
fi

echo "== Capa 2/3: HTTP (${SCHEME}://${DOMAIN}) =="
if [[ "${DRY_RUN}" -eq 1 ]]; then
  echo "[dry-run] curl -o /dev/null -s -w '...' --max-time 5 ${SCHEME}://${DOMAIN}"
else
  if ! curl -o /dev/null -s --max-time 5 \
      -w 'http_code=%{http_code} time_namelookup=%{time_namelookup}s time_connect=%{time_connect}s time_total=%{time_total}s\n' \
      "${SCHEME}://${DOMAIN}"; then
    fail "${EX_SOFTWARE}" "curl no pudo completar la petición a ${SCHEME}://${DOMAIN} (timeout o conexión rechazada)"
  fi
fi

echo "== Capa 3/3: puerto local ${PORT} (ss) =="
if [[ "${DRY_RUN}" -eq 1 ]]; then
  echo "[dry-run] ss -tuln | grep -E ':${PORT}\b'"
else
  if ss -tuln | grep -qE ":${PORT}\b"; then
    echo "hay un proceso escuchando localmente en el puerto ${PORT}"
  else
    echo "nadie escucha localmente en el puerto ${PORT} (normal si esto corre fuera del servidor)"
  fi
fi

echo "OK"
exit "${EX_OK}"

Un detalle que vale la pena mirar dos veces: uso tail -n1 y no head -n1 sobre la salida de dig +short. Cuando un dominio tiene un CNAME, dig +short imprime primero el nombre al que apunta y después la IP final — tomar la primera línea te da un nombre de host, no una dirección. tail -n1 asume que la cadena termina en un registro A, que es el caso normal, pero no es infalible (lo retomamos en errores comunes).

Qué esperar contra un dominio real:

$ ./probe.sh example.com
== Capa 1/3: DNS (example.com) ==
resuelve a: 93.184.216.34
== Capa 2/3: HTTP (https://example.com) ==
http_code=200 time_namelookup=0.012s time_connect=0.045s time_total=0.187s
== Capa 3/3: puerto local 443 (ss) ==
nadie escucha localmente en el puerto 443 (normal si esto corre fuera del servidor)
OK
$ echo $?
0

Y contra un dominio que no existe:

$ ./probe.sh dominio-que-no-existe-xyz.test
== Capa 1/3: DNS (dominio-que-no-existe-xyz.test) ==
FALLO: no resuelve ningún registro para 'dominio-que-no-existe-xyz.test'
$ echo $?
69

remote-report.sh: orquestando el diagnóstico remoto

Este script no diagnostica nada por sí mismo — orquesta. Toma un alias de ~/.ssh/config y un archivo con una lista de dominios (uno por línea), y para cada uno: corre check-env.sh una vez en el remoto como gate de entrada, corre probe.sh por cada dominio guardando la salida en archivos en el propio servidor, trae todo de vuelta con un solo rsync, y arma un resumen local con grep | sort | uniq -c. La razón de escribir los resultados a disco en el remoto y traerlos después con rsync — en vez de dejar que ssh transmita la salida directo a tu terminal — es deliberada: así el ejercicio de "recuperar resultados" es real, no cosmético, y si la conexión se cae a mitad de un diagnóstico largo, los resultados de lo que ya corrió siguen ahí para recuperarlos después.

#!/usr/bin/env bash
# remote-report.sh — corre check-env.sh y probe.sh en un servidor remoto para
# una lista de dominios, trae los resultados con rsync y resume con grep/sort/uniq.
set -euo pipefail

readonly EX_OK=0
readonly EX_USAGE=64
readonly EX_UNAVAILABLE=69 # check-env.sh falló en el remoto
readonly EX_CONFIG=78      # alias SSH o archivo de dominios inválido

SCRIPT_NAME="$(basename "$0")"
# Resuelve la carpeta donde vive este script, sin importar desde dónde lo invoques.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=0

usage() {
  cat <<EOF
Uso: ${SCRIPT_NAME} <alias-ssh> <archivo-de-dominios> [--dry-run] [--help]

Se conecta a <alias-ssh> (debe existir como 'Host' en ~/.ssh/config, lección 4),
corre check-env.sh y probe.sh para cada dominio de <archivo-de-dominios> (uno
por línea), trae los resultados con rsync y muestra un resumen final.

  --dry-run   Muestra qué se ejecutaría, sin conectarse a nada.
  --help      Muestra esta ayuda y sale con código 0.

Códigos de salida: ver tabla en README.md.
EOF
}

fail() {
  local code="$1" msg="$2"
  echo "FALLO: ${msg}" >&2
  exit "${code}"
}

POSITIONAL=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run) DRY_RUN=1; shift ;;
    --help) usage; exit "${EX_OK}" ;;
    -*) fail "${EX_USAGE}" "opción desconocida '$1'" ;;
    *) POSITIONAL+=("$1"); shift ;;
  esac
done
set -- "${POSITIONAL[@]}"
[[ $# -ne 2 ]] && { usage >&2; fail "${EX_USAGE}" "se esperan exactamente 2 argumentos: alias y archivo de dominios"; }
ALIAS="$1"
DOMAINS_FILE="$2"

[[ -f "${DOMAINS_FILE}" ]] || fail "${EX_CONFIG}" "no existe el archivo de dominios '${DOMAINS_FILE}'"
# Se valida incluso en --dry-run: es una lectura local, no cuesta red, y
# detecta el error de configuración más común antes de tocar nada.
grep -qE "^Host[[:space:]]+${ALIAS}([[:space:]]|\$)" "${HOME}/.ssh/config" 2>/dev/null \
  || fail "${EX_CONFIG}" "'${ALIAS}' no está definido como Host en ~/.ssh/config (lección 4)"

if [[ "${DRY_RUN}" -eq 1 ]]; then
  echo "[dry-run] ssh ${ALIAS} mktemp -d"
  echo "[dry-run] rsync check-env.sh probe.sh -> ${ALIAS}:<remoto>/"
  echo "[dry-run] ssh ${ALIAS} bash check-env.sh"
  while IFS= read -r domain || [[ -n "${domain}" ]]; do
    [[ -z "${domain}" || "${domain}" == \#* ]] && continue
    echo "[dry-run] ssh ${ALIAS} bash probe.sh ${domain}"
  done < "${DOMAINS_FILE}"
  echo "[dry-run] rsync ${ALIAS}:<remoto>/results/ -> ./reports/<timestamp>/"
  exit "${EX_OK}"
fi

TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
LOCAL_REPORTS_DIR="./reports/${TIMESTAMP}"
REMOTE_DIR=""

# A diferencia de check-env.sh y probe.sh, este trap no borra el reporte
# final — solo limpia lo que este script dejó en el servidor remoto.
cleanup() {
  [[ -n "${REMOTE_DIR}" ]] && ssh "${ALIAS}" "rm -rf '${REMOTE_DIR}'" 2>/dev/null || true
}
trap cleanup EXIT

mkdir -p "${LOCAL_REPORTS_DIR}"

echo "== Preparando directorio remoto en ${ALIAS} =="
REMOTE_DIR="$(ssh "${ALIAS}" mktemp -d)"
ssh "${ALIAS}" "mkdir -p '${REMOTE_DIR}/results'"

echo "== Copiando scripts con rsync =="
rsync -az "${SCRIPT_DIR}/check-env.sh" "${SCRIPT_DIR}/probe.sh" "${ALIAS}:${REMOTE_DIR}/"

echo "== Corriendo check-env.sh en el remoto =="
if ! ssh "${ALIAS}" "bash '${REMOTE_DIR}/check-env.sh'" \
    > "${LOCAL_REPORTS_DIR}/check-env.out" 2> "${LOCAL_REPORTS_DIR}/check-env.err"; then
  cat "${LOCAL_REPORTS_DIR}/check-env.err" >&2
  fail "${EX_UNAVAILABLE}" "check-env.sh falló en '${ALIAS}'; revisa ${LOCAL_REPORTS_DIR}/check-env.err"
fi

echo "== Diagnosticando dominios =="
while IFS= read -r domain || [[ -n "${domain}" ]]; do
  [[ -z "${domain}" || "${domain}" == \#* ]] && continue
  echo "  -- ${domain} --"
  # El '||' es intencional: si un dominio falla, el resto de la lista debe
  # seguir corriendo. Sin él, set -e tumbaría todo el lote en el primer fallo.
  ssh "${ALIAS}" \
    "bash '${REMOTE_DIR}/probe.sh' '${domain}' > '${REMOTE_DIR}/results/${domain}.out' 2> '${REMOTE_DIR}/results/${domain}.err'" \
    || echo "     (probe.sh reportó una falla para ${domain}; se sigue con el resto)"
done < "${DOMAINS_FILE}"

echo "== Trayendo resultados con rsync =="
rsync -az "${ALIAS}:${REMOTE_DIR}/results/" "${LOCAL_REPORTS_DIR}/"

echo "== Resumen =="
echo "-- Fallas registradas --"
grep -h "^FALLO" "${LOCAL_REPORTS_DIR}"/*.err 2>/dev/null | sort | uniq -c | sort -rn || echo "  (ninguna)"
echo "-- Códigos HTTP vistos --"
grep -hoE 'http_code=[0-9]+' "${LOCAL_REPORTS_DIR}"/*.out 2>/dev/null | sort | uniq -c || echo "  (ninguno)"

echo "Resultados completos en: ${LOCAL_REPORTS_DIR}"
exit "${EX_OK}"

El punto que más vale la pena defender en voz alta si alguien te pregunta por este script: el || al final de la línea de ssh dentro del bucle no es un adorno. Bajo set -e, un comando que falla dentro de un bucle sin ese || termina el script entero en cuanto el primer dominio de la lista falla — el resto de la lista nunca se llega a probar, y el reporte parece "el script se rompió" cuando en realidad solo un dominio estaba caído. El || convierte ese fallo puntual en un dato del reporte, no en un accidente.

Qué esperar con un domains.txt de tres líneas donde una está caída:

$ cat domains.txt
example.com
example.org
dominio-que-no-existe-xyz.test

$ ./remote-report.sh webserver domains.txt
== Preparando directorio remoto en webserver ==
== Copiando scripts con rsync ==
== Corriendo check-env.sh en el remoto ==
== Diagnosticando dominios ==
  -- example.com --
  -- example.org --
  -- dominio-que-no-existe-xyz.test --
     (probe.sh reportó una falla para dominio-que-no-existe-xyz.test; se sigue con el resto)
== Trayendo resultados con rsync ==
== Resumen ==
-- Fallas registradas --
      1 FALLO: no resuelve ningún registro para 'dominio-que-no-existe-xyz.test'
-- Códigos HTTP vistos --
      2 http_code=200
Resultados completos en: ./reports/20260721-221045
$ echo $?
0

El script sale con 0 aunque un dominio haya fallado, porque el reporte en sí se generó correctamente — es el contenido del resumen, no el código de salida de remote-report.sh, el que te dice que algo está caído. Esa distinción entre "el diagnóstico falló" y "el diagnóstico encontró un problema" es intencional y vale la pena poder explicarla.

README.md y cómo defenderlo en vivo

El README.md es lo primero que lee alguien que no escribió el toolkit — incluyendo tú mismo, seis meses después. Como mínimo, necesita la tabla de códigos de salida (la misma que ya usaste arriba, ahora en un solo lugar), los requisitos, y ejemplos de uso:

# ops-toolkit

Diagnóstico de servidores remotos: entorno local, capas de red de un dominio, y
orquestación vía SSH con reporte agregado.

## Requisitos

- bash 4 o superior
- `dig` (paquete `bind-utils` / `dnsutils`), `curl`, `ssh`, `rsync`
- `ss` (paquete `iproute2`) — solo necesario en el servidor remoto, no en tu laptop

## Variables de entorno

| Variable          | Usado por      | Descripción                              |
|-------------------|-----------------|-------------------------------------------|
| `TOOLKIT_SSH_KEY`  | check-env.sh    | Ruta a la llave privada SSH a auditar     |

## Códigos de salida

| Código | Constante      | Cuándo aparece                                              | Script(s)                        |
|--------|----------------|--------------------------------------------------------------|-----------------------------------|
| 0      | EX_OK          | Todo correcto                                                 | los tres                          |
| 64     | EX_USAGE       | Argumento inválido o faltante                                  | los tres                          |
| 69     | EX_UNAVAILABLE | Falta un comando / DNS no resuelve / check-env falló en remoto | check-env.sh, probe.sh, remote-report.sh |
| 70     | EX_SOFTWARE    | La petición HTTP falló (timeout, conexión rechazada)          | probe.sh                          |
| 77     | EX_NOPERM      | Permisos inseguros en la llave privada                        | check-env.sh                      |
| 78     | EX_CONFIG      | Falta env var / alias SSH / archivo de dominios                | check-env.sh, remote-report.sh    |

## Uso

    ./check-env.sh --help
    ./probe.sh example.com --scheme https
    ./remote-report.sh webserver domains.txt

## Cómo demostrarlo en vivo

1. `./check-env.sh --help` y `./probe.sh --help` — muestra que el toolkit se documenta solo.
2. `./probe.sh example.com --dry-run` — muestra el plan sin tocar la red.
3. Provoca un fallo real (renombra `dig` temporalmente, o usa un dominio inexistente) y
   muestra `echo $?` — el código coincide con la tabla de arriba.
4. `./remote-report.sh <alias> domains.txt` contra una lista con un dominio caído a propósito —
   el resumen final debe mostrarlo sin que el script completo truene.
5. Abre uno de los tres scripts y señala una decisión concreta: por qué ese código de salida y
   no otro, qué limpia el `trap`, qué rompería si le quitas el `set -o pipefail`.

Defender este proyecto en vivo no es leer el código en voz alta — es poder responder tres preguntas sin mirar la pantalla: "¿por qué este código de salida y no otro?" (porque lo decidiste tú y lo documentaste, no porque exista una ley que lo imponga — más sobre esto en errores comunes), "¿qué pasa si corro esto sin set -euo pipefail?" (un cd que falla silenciosamente, una variable vacía tratada como si tuviera valor, un fallo en medio de una tubería que nadie nota) y "¿qué limpia el trap?" (en remote-report.sh, la carpeta temporal del servidor, nunca tu reporte local). Si puedes responder esas tres sin abrir el archivo, ya defendiste el proyecto.

Errores comunes

Creer que los códigos de salida son un estándar que el sistema hace cumplir. Definiste 69 como "falta una dependencia" y 78 como "falta configuración" — pero eso es una convención tuya, documentada en tu propio README.md, no una ley que otros programas respetan. curl, por ejemplo, tiene su propia tabla completamente distinta: el código 6 significa "no pudo resolver el host" y el 28 significa "se agotó el tiempo de espera" — nada que ver con la numeración de sysexits que usa este toolkit. Cómo se detecta: si encadenas la salida de tu script con la de una herramienta externa y asumes que un mismo número significa lo mismo en ambas, vas a diagnosticar mal el fallo. Cómo se corrige: nunca reutilices el código de salida de un programa externo como si fuera el tuyo — revísalo en su propia documentación (man curl tiene la lista completa) y decide tú qué código tuyo le corresponde a ese caso.

Confiar en la primera línea de dig +short cuando el dominio tiene una cadena de CNAME. Un dominio puede resolver a través de varios alias antes de llegar a una IP (www.example.comcdn. example.net93.184.216.34), y dig +short imprime toda la cadena, una línea por salto. Tomar la primera línea con head -n1 te da el primer alias, no la IP final — y si algo más adelante en el script espera una dirección IP para, por ejemplo, filtrar con ss, va a fallar de forma confusa. Cómo se detecta: corre dig +short <dominio> a mano y cuenta cuántas líneas salen; si hay más de una, la primera casi nunca es la IP. Cómo se corrige: probe.sh usa tail -n1, que asume que la cadena termina en un registro A (el caso normal) — para el caso general y robusto, filtra explícitamente con una expresión regular de IPv4 en vez de confiar en la posición de la línea.

Escribir el bucle de remote-report.sh sin el || echo de resguardo. Bajo set -euo pipefail, el primer dominio de la lista que falle aborta el script completo en ese punto — los dominios siguientes de domains.txt nunca se llegan a probar, y ni siquiera te enteras de que existían, porque el resumen final tampoco llega a generarse. Es la contraparte exacta del caso de la lección 7 donde -e no te salva: acá -e funciona exactamente como se diseñó (aborta ante un fallo), pero ese comportamiento es lo contrario de lo que este script necesita para un lote de dominios. Cómo se detecta: corre el toolkit contra una lista con un dominio caído en el medio y observa si los dominios de después de ese aparecen en el resumen. Cómo se corrige: cualquier comando dentro de un bucle cuyo fallo individual no debe frenar el resto necesita su propio || explícito — la política de "aborta ante el primer error" es global salvo que tú, comando por comando, decidas lo contrario.

Ejercicios

Ejercicio 1. check-env.sh no verifica que ~/.ssh/config exista antes de que remote-report.sh intente leerlo más adelante. Agrega una verificación al final de check-env.sh: si ~/.ssh/config no existe o no es legible, sal con EX_CONFIG (78) y un mensaje claro.

Ver solución
echo "== ~/.ssh/config =="
if [[ -r "${HOME}/.ssh/config" ]]; then
  echo "  ok: ${HOME}/.ssh/config existe y es legible"
else
  fail "${EX_CONFIG}" "no existe o no se puede leer ${HOME}/.ssh/config (necesario para remote-report.sh)"
fi

Por qué funciona: -r prueba en un solo paso tanto existencia como permiso de lectura — evita el error más común de solo chequear -f y luego fallar igual al intentar leer el archivo sin permisos.

Ejercicio 2. El error común #2 señala que tail -n1 sobre dig +short es una suposición, no una garantía. Reescribe esa línea de probe.sh para que extraiga específicamente la última dirección IPv4 de la salida, sin importar cuántas líneas de CNAME la precedan.

Ver solución
IP="$(dig +short "${DOMAIN}" | grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' | tail -n1 || true)"

Por qué funciona: el grep -E con esa expresión regular descarta cualquier línea que no tenga la forma de una IPv4 (los nombres de host de un CNAME no calzan con el patrón), así que sin importar cuántos saltos haya, solo quedan direcciones IP reales, y el tail -n1 ahora sí toma la última de esas.

Ejercicio 3. Agrega un tercer contador al resumen de remote-report.sh: cuántos de los dominios de domains.txt terminaron sin ninguna línea FALLO en su .err (éxito total) frente al total de dominios procesados.

Ver solución
TOTAL=0
OK_COUNT=0
for f in "${LOCAL_REPORTS_DIR}"/*.err; do
  [[ -e "${f}" ]] || continue
  [[ "$(basename "${f}")" == "check-env.err" ]] && continue
  TOTAL=$((TOTAL + 1))
  [[ -s "${f}" ]] || OK_COUNT=$((OK_COUNT + 1))
done
echo "-- Dominios sin fallas: ${OK_COUNT}/${TOTAL} --"

Por qué funciona: -s es verdadero cuando el archivo existe y tiene tamaño mayor a cero — un .err vacío significa que probe.sh nunca escribió nada a stderr para ese dominio, es decir, que no hubo ningún fail(). Contar archivos vacíos versus el total te da la proporción sin tener que volver a leer el contenido de cada uno.

Resumen y siguiente paso

Lo que armaste en ops-toolkit no es un ejercicio aislado: es la forma final de todo lo que viste en este módulo — y, en cierto sentido, de toda esta guía. Un script ya no es "unas líneas para no teclear lo mismo dos veces", es una pieza con contrato explícito (argumentos documentados, --help, --dry-run, códigos de salida en una tabla) que otra persona — o tú, con menos contexto, meses después — puede correr sin tener que leer el código primero. Esa es la frontera real entre un script personal y una herramienta.

También tocaste el límite de la lección 7: remote-report.sh ya coordina tres scripts, dos protocolos (SSH y rsync sobre SSH) y un formato de reporte propio. Si mañana necesitaras además reintentos con backoff, ejecución en paralelo de varios servidores, o un formato de salida en JSON para alimentar un dashboard, ese es exactamente el punto donde conviene reescribirlo en Python — no porque bash esté "mal", sino porque ya usaste todo lo que bash ofrece bien y lo que sigue es estructura de datos, no más texto.

Antes de dar por cerrada esta guía, deberías poder: escribir un script con parseo de argumentos propio, código de salida documentado por tipo de fallo, trap para limpieza y --dry-run real (no cosmético); diagnosticar un problema de red explicando por qué capa empiezas y por qué descartas las demás; y conectarte a un servidor remoto para correr algo ahí y traer el resultado, sin necesitar copiar y pegar comandos a mano. Si te falta alguna de las tres, vuelve a la lección de este módulo que la cubre — tienes el índice arriba.

Recursos

  • curl(1) — write-out variables — la sección -w, --write-out documenta todas las variables como http_code, time_total y time_connect que usa probe.sh.
  • sysexits(3), OpenBSD manual — el origen de la convención de códigos de salida (64, 69, 70, 78...) que este toolkit adapta.
  • ss(8), Linux manual — sintaxis completa de filtros de ss, incluida la variante por estado y puerto usada en probe.sh.
  • ssh_config(5), OpenBSD manual — referencia completa del Host y sus patrones, la base de la validación que hace remote-report.sh.
  • Google Shell Style Guide — convenciones de una organización real para cuándo usar shell, cuándo no, y cómo estructurar scripts que otros van a mantener.