Analizador de la configuración de Nginx
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
- 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 esserver {, otra apertura de bloque (termina en{), un cierre}o una directiva terminada en;. Las líneas se numeran desde 1 contando todas. - 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 Ny, al final,Error: llaves sin cerrar. Si no hay ningúnserver,No hay ningún bloque server. - De cada
serverse guardan (también desde dentro de suslocation): los puertos delisten(el número tras los dos puntos, si los hay, ysslsi la línea lo lleva; sin repetir; 80 si no hay ningúnlisten), los nombres deserver_name, el primerroot, el primerproxy_pass, la URL del últimoreturn(lo que va tras el código), si tienessl_certificate, si tienessl_certificate_keyy si tieneautoindex on. - Por cada servidor:
Servidor N (líneas A-B): puerto P · nombres · acción, con los puertos separados pory(443 ssl y 80), los nombres tal cual o(sin nombre), y como acciónredirige a URL,proxy a URL,sirve RUTAono sirve nada, por ese orden de preferencia. - 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) ydominio: el puerto 80 no redirige a HTTPS(para cada dominio que se sirve en el 443 sin un servidor en el 80 cuyoreturnvaya ahttps://). - Escribe
Avisos:y cada aviso con-delante, oSin 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):
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.
Ejemplo
# 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;
}
}
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
Solución explicada
Ver la solución completa
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
99fiEl 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
includeleyendo los ficheros incluidos. - Genera el bloque
serverdel puerto 80 que falta para cada dominio sin redirección.