Apuntes DAM
Volver al inicio

Analizador de la configuración de Nginx

Ejercicio de BashMuy difícilUnos 90 minutos

Un script que lee un fichero de sitios de Nginx, resume cada bloque server (puertos, dominios y qué hace) y avisa de los fallos típicos: HTTPS sin certificado, autoindex activado, dominios repetidos en un puerto y sitios cuyo puerto 80 no redirige a HTTPS. Análisis de texto con estados en Bash.

  • while IFS= read -r
  • Expansión ${var%%patrón} y ${var#patrón}
  • Contar llaves (profundidad)
  • Arrays indexados y asociativos
  • case
  • mapfile y sort

Enunciado

En un servidor con varias webs, la configuración de Nginx crece con el tiempo: un server para el dominio, otro para la API, otro que nadie recuerda… y los errores aparecen: un sitio que escucha en el 443 sin certificado (Nginx no arranca), un autoindex on olvidado que enseña los ficheros a cualquiera, o un dominio con HTTPS cuyo puerto 80 sigue sirviendo la web sin cifrar.

nginx -t comprueba la sintaxis, pero no esas buenas prácticas. Vas a escribir un script que lea un fichero de sitios (el que estaría en /etc/nginx/sites-available/), resuma cada bloque server y avise de los problemas.

El formato de Nginx es sencillo de leer línea a línea: directivas terminadas en ; y bloques entre llaves (server { … }, y dentro bloques location … { … }). Para saber dónde termina cada server hay que llevar la cuenta de las llaves abiertas.

Qué tiene que hacer el programa

  1. Se ignoran los comentarios (desde # hasta el final de la línea), los espacios de los extremos y las líneas vacías. Cada línea es server {, otra apertura de bloque (termina en {), un cierre } o una directiva terminada en ;. Las líneas se numeran desde 1 contando todas.
  2. Errores, que detienen el script: Error: llave de cierre sin abrir en la línea N, Error: bloque fuera de un server en la línea N, Error: directiva fuera de un server en la línea N y, al final, Error: llaves sin cerrar. Si no hay ningún server, No hay ningún bloque server.
  3. De cada server se guardan (también desde dentro de sus location): los puertos de listen (el número tras los dos puntos, si los hay, y ssl si la línea lo lleva; sin repetir; 80 si no hay ningún listen), los nombres de server_name, el primer root, el primer proxy_pass, la URL del último return (lo que va tras el código), si tiene ssl_certificate, si tiene ssl_certificate_key y si tiene autoindex on.
  4. Por cada servidor: Servidor N (líneas A-B): puerto P · nombres · acción, con los puertos separados por y (443 ssl y 80), los nombres tal cual o (sin nombre), y como acción redirige a URL, proxy a URL, sirve RUTA o no sirve nada, por ese orden de preferencia.
  5. Avisos, primero los de cada servidor en orden (Servidor N: usa ssl pero le falta ssl_certificate y ssl_certificate_key —o solo el que falte—, Servidor N: autoindex activado (muestra la lista de ficheros), Servidor N: no tiene root, return ni proxy_pass) y después, por orden alfabético, dominio: aparece en K servidores del puerto P (para cada dominio y puerto con más de un servidor) y dominio: el puerto 80 no redirige a HTTPS (para cada dominio que se sirve en el 443 sin un servidor en el 80 cuyo return vaya a https://).
  6. Escribe Avisos: y cada aviso con - delante, o Sin avisos.

Entrada

Un fichero de configuración de sitios de Nginx.

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Un dominio con API

Entrada

# Sitio principal
server {
    listen 80;
    listen [::]:80;
    server_name ejemplo.com www.ejemplo.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name ejemplo.com www.ejemplo.com;
    ssl_certificate /etc/letsencrypt/live/ejemplo.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ejemplo.com/privkey.pem;
    root /var/www/ejemplo;

    location /descargas/ {
        autoindex on;   # se dejó activado tras una prueba
    }
}

server {
    listen 443 ssl;
    server_name api.ejemplo.com;
    ssl_certificate /etc/ssl/api.crt;
    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Salida por consola

Servidor 1 (líneas 2-7): puerto 80 · ejemplo.com www.ejemplo.com · redirige a https://$host$request_uri
Servidor 2 (líneas 9-20): puerto 443 ssl · ejemplo.com www.ejemplo.com · sirve /var/www/ejemplo
Servidor 3 (líneas 22-29): puerto 443 ssl · api.ejemplo.com · proxy a http://127.0.0.1:3000
Avisos:
  - Servidor 2: autoindex activado (muestra la lista de ficheros)
  - Servidor 3: usa ssl pero le falta ssl_certificate_key
  - api.ejemplo.com: el puerto 80 no redirige a HTTPS

Servidores repetidos y uno vacío

Entrada

server {
    server_name intranet.local;
}
server {
    listen 8080;
    server_name intranet.local;
    root /srv/intranet;
}
server {
    listen 8080;
    server_name intranet.local;
    return 302 /mantenimiento.html;
}

Salida por consola

Servidor 1 (líneas 1-3): puerto 80 · intranet.local · no sirve nada
Servidor 2 (líneas 4-8): puerto 8080 · intranet.local · sirve /srv/intranet
Servidor 3 (líneas 9-13): puerto 8080 · intranet.local · redirige a /mantenimiento.html
Avisos:
  - Servidor 1: no tiene root, return ni proxy_pass
  - intranet.local: aparece en 2 servidores del puerto 8080

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Limpiar cada línea

${linea%%#*} quita desde el primer # hasta el final. Para quitar los espacios de los extremos sin herramientas externas se usan dos expansiones (es un truco conocido de Bash):

bash
linea="${linea#"${linea%%[![:space:]]*}"}"
linea="${linea%"${linea##*[![:space:]]}"}"
2. Seguir las llaves

Lleva una variable profundidad: server { en el nivel 0 abre un servidor nuevo (profundidad 1); cualquier otra línea que acabe en { suma 1 y cada } resta 1. Cuando vuelve a 0, el servidor actual termina en esa línea.

3. Guardar por servidor

Usa un array por dato (puertos, nombres, raiz…) con el número de servidor como índice. read -r directiva valor <<< "$linea" separa la directiva del resto, y un case decide dónde guardarlo.

4. Puertos sin repetir

De [::]:443 ssl hay que quedarse con 443 ssl: ${valor%% *} da el primer trozo y ${puerto##*:} lo que va tras los últimos dos puntos. Para no repetir, comprueba antes si ya está en la lista con [[ " $lista " == *" $puerto "* ]].

5. Avisos que cruzan servidores

Los avisos de dominios necesitan mirar todos los servidores. Recorre cada servidor, y para cada nombre y puerto suma 1 en un array asociativo con la clave "nombre puerto"; apunta también qué dominios están en el 443 y cuáles tienen un 80 que redirige a https://. Al final, recorre las claves ordenadas.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 4 casos ocultos que buscan los errores típicos.

🖥️BashAnalizador de la configuración de NginxMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
# Sitio principal
server {
    listen 80;
    listen [::]:80;
    server_name ejemplo.com www.ejemplo.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name ejemplo.com www.ejemplo.com;
    ssl_certificate /etc/letsencrypt/live/ejemplo.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ejemplo.com/privkey.pem;
    root /var/www/ejemplo;

    location /descargas/ {
        autoindex on;   # se dejó activado tras una prueba
    }
}

server {
    listen 443 ssl;
    server_name api.ejemplo.com;
    ssl_certificate /etc/ssl/api.crt;
    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}
Salida esperada
Servidor 1 (líneas 2-7): puerto 80 · ejemplo.com www.ejemplo.com · redirige a https://$host$request_uri
Servidor 2 (líneas 9-20): puerto 443 ssl · ejemplo.com www.ejemplo.com · sirve /var/www/ejemplo
Servidor 3 (líneas 22-29): puerto 443 ssl · api.ejemplo.com · proxy a http://127.0.0.1:3000
Avisos:
  - Servidor 2: autoindex activado (muestra la lista de ficheros)
  - Servidor 3: usa ssl pero le falta ssl_certificate_key
  - api.ejemplo.com: el puerto 80 no redirige a HTTPS
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
0/6 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
bash
1#!/bin/bash
2# Revisa un fichero de sitios de Nginx (como /etc/nginx/sites-available/web) y avisa de los fallos típicos
3export LC_ALL=C
4
5n=0 profundidad=0 numero=0
6declare -a inicio fin puertos nombres raiz retorno proxy cert clave autoindex
7
8while IFS= read -r linea || [ -n "$linea" ]; do
9  numero=$((numero + 1))
10  linea=${linea%%#*}                                   # quita los comentarios
11  linea="${linea#"${linea%%[![:space:]]*}"}"           # y los espacios de los extremos
12  linea="${linea%"${linea##*[![:space:]]}"}"
13  [ -z "$linea" ] && continue
14
15  if [[ $linea =~ ^server[[:space:]]*\{$ ]] && [ "$profundidad" -eq 0 ]; then
16    n=$((n + 1)); inicio[$n]=$numero; profundidad=1
17    continue
18  fi
19  if [[ $linea == *"{" ]]; then                        # un bloque location o similar
20    if [ "$profundidad" -eq 0 ]; then echo "Error: bloque fuera de un server en la línea $numero"; exit 0; fi
21    profundidad=$((profundidad + 1))
22    continue
23  fi
24  if [ "$linea" = "}" ]; then
25    if [ "$profundidad" -eq 0 ]; then echo "Error: llave de cierre sin abrir en la línea $numero"; exit 0; fi
26    profundidad=$((profundidad - 1))
27    [ "$profundidad" -eq 0 ] && fin[$n]=$numero
28    continue
29  fi
30  if [ "$profundidad" -eq 0 ]; then echo "Error: directiva fuera de un server en la línea $numero"; exit 0; fi
31
32  linea=${linea%;}
33  read -r directiva valor <<< "$linea"
34  case $directiva in
35    listen)
36      puerto=${valor%% *}; puerto=${puerto##*:}        # «[::]:443 ssl» → 443
37      [[ $valor == *" ssl"* ]] && puerto+=" ssl"
38      [[ " ${puertos[$n]} " == *" $puerto "* ]] || puertos[$n]="${puertos[$n]:+${puertos[$n]} }$puerto" ;;
39    server_name) nombres[$n]=$valor ;;
40    root) [ -z "${raiz[$n]}" ] && raiz[$n]=$valor ;;
41    return) retorno[$n]=${valor#* } ;;
42    proxy_pass) [ -z "${proxy[$n]}" ] && proxy[$n]=$valor ;;
43    ssl_certificate) cert[$n]=1 ;;
44    ssl_certificate_key) clave[$n]=1 ;;
45    autoindex) [ "$valor" = "on" ] && autoindex[$n]=1 ;;
46  esac
47done
48
49if [ "$profundidad" -ne 0 ]; then echo "Error: llaves sin cerrar"; exit 0; fi
50if [ "$n" -eq 0 ]; then echo "No hay ningún bloque server"; exit 0; fi
51
52avisos=()
53declare -A servidores_de     # «nombre puerto» → número de servidores
54declare -A redirige          # nombre → 1 si su servidor del 80 redirige a https
55declare -A en443
56for i in $(seq 1 "$n"); do
57  [ -z "${puertos[$i]}" ] && puertos[$i]="80"
58  if [ -n "${retorno[$i]}" ]; then hace="redirige a ${retorno[$i]}"
59  elif [ -n "${proxy[$i]}" ]; then hace="proxy a ${proxy[$i]}"
60  elif [ -n "${raiz[$i]}" ]; then hace="sirve ${raiz[$i]}"
61  else hace="no sirve nada"; fi
62  echo "Servidor $i (líneas ${inicio[$i]}-${fin[$i]}): puerto ${puertos[$i]// / y } · ${nombres[$i]:-(sin nombre)} · $hace" | sed 's/ y ssl/ ssl/g'
63
64  if [[ " ${puertos[$i]}" == *" ssl"* ]]; then
65    faltan=""
66    [ -z "${cert[$i]}" ] && faltan="ssl_certificate"
67    [ -z "${clave[$i]}" ] && faltan="${faltan:+$faltan y }ssl_certificate_key"
68    [ -n "$faltan" ] && avisos+=("Servidor $i: usa ssl pero le falta $faltan")
69  fi
70  [ -n "${autoindex[$i]}" ] && avisos+=("Servidor $i: autoindex activado (muestra la lista de ficheros)")
71  [ "$hace" = "no sirve nada" ] && avisos+=("Servidor $i: no tiene root, return ni proxy_pass")
72
73  for nombre in ${nombres[$i]}; do
74    for p in ${puertos[$i]// ssl/}; do
75      servidores_de["$nombre $p"]=$(( ${servidores_de["$nombre $p"]:-0} + 1 ))
76      if [ "$p" = "80" ] && [[ ${retorno[$i]} == https://* ]]; then redirige[$nombre]=1; fi
77      [ "$p" = "443" ] && en443[$nombre]=1
78    done
79  done
80done
81
82# Avisos por nombre de dominio, en orden alfabético
83mapfile -t claves < <(printf "%s\n" "${!servidores_de[@]}" | sort)
84for k in "${claves[@]}"; do
85  [ -z "$k" ] && continue
86  [ "${servidores_de[$k]}" -gt 1 ] && avisos+=("${k% *}: aparece en ${servidores_de[$k]} servidores del puerto ${k#* }")
87done
88mapfile -t seguros < <(printf "%s\n" "${!en443[@]}" | sort)
89for nombre in "${seguros[@]}"; do
90  [ -z "$nombre" ] && continue
91  [ -z "${redirige[$nombre]}" ] && avisos+=("$nombre: el puerto 80 no redirige a HTTPS")
92done
93
94if [ ${#avisos[@]} -eq 0 ]; then
95  echo "Sin avisos"
96else
97  echo "Avisos:"
98  for a in "${avisos[@]}"; do echo "  - $a"; done
99fi

El script es un analizador con estado: la profundidad de llaves dice si una línea pertenece a un servidor, a uno de sus location o a nada. Es la misma técnica que el conversor CSV con comillas, aplicada a un formato con bloques anidados.

Guardar los datos en arrays paralelos indexados por el número de servidor convierte el fichero en una pequeña tabla. Con ella, el resumen de cada servidor es un recorrido y los avisos son preguntas sobre la tabla.

Los avisos de dominios necesitan una segunda pasada que cruce información de varios servidores (el 80 de un dominio y su 443 suelen estar en bloques distintos). Por eso se recogen primero todos los datos y se analizan después.

En un servidor real, el script se ejecutaría antes de nginx -t && systemctl reload nginx, por ejemplo en un pipeline de despliegue: los fallos de sintaxis los encuentra Nginx; los de criterio, este script.

Para ir más allá

  • Avisa también de los sitios sin las cabeceras de seguridad recomendadas (Strict-Transport-Security, X-Content-Type-Options).
  • Admite la directiva include leyendo los ficheros incluidos.
  • Genera el bloque server del puerto 80 que falta para cada dominio sin redirección.

Dónde se explica