#!/bin/bash # /usr/local/bin/nginx-user-config-watcher.sh # Watcher para configs nginx de usuarios via SFTP # Detecta cualquier archivo *.conf en /home/USER/html/ # Con notificaciones XMPP a jla@librebits.info # # v2 — Análisis CSP post-deploy + humanización de errores nginx (P2.5) # v3 — Multi-sitio por alumno: mi-nginx-SUFIJO.conf → USER-SUFIJO.qu3v3d0.tech # Retrocompatible: mi-nginx.conf (sin sufijo) funciona igual que v2 WATCH_DIR="/home" LOG="/var/log/nginx-user-configs.log" MAX_SITES=4 log() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG" } # Notificación XMPP (fire and forget) notify_xmpp() { local msg="$1" /usr/local/bin/xmpp-notify.py "$msg" >/dev/null 2>&1 & } # ------------------------------------------------------- # analyze_csp() — Analiza un .conf desplegado buscando # cabeceras CSP y de seguridad. Devuelve texto con pistas. # ------------------------------------------------------- analyze_csp() { local conf="$1" local hints="" local conf_content conf_content=$(cat "$conf" 2>/dev/null) || return # --- Check 1: ¿Tiene cabecera CSP? --- if ! echo "$conf_content" | grep -q "Content-Security-Policy"; then hints="${hints}💡 Tu .conf no tiene cabecera Content-Security-Policy (opcional según la práctica) " else # --- Check 2: ¿CSP multiline? (add_header ... " sin cierre en misma línea) --- # Buscamos líneas con add_header Content-Security-Policy que abren comillas # pero no las cierran en la misma línea if echo "$conf_content" | grep -E 'add_header\s+Content-Security-Policy' | grep -qE '"[^"]*$'; then hints="${hints}⚠️ CSP con saltos de línea: ponlo todo en UNA sola línea entre comillas " fi # --- Check 3: ¿CSP en bloque HTTPS (443) o solo en HTTP (80)? --- # Estrategia: buscar si CSP aparece ANTES del primer listen 443 # o si no hay listen 443 en absoluto local has_443 has_443=$(echo "$conf_content" | grep -c "listen.*443") if [[ "$has_443" -eq 0 ]]; then hints="${hints}⚠️ No hay bloque HTTPS (listen 443) — la CSP debería ir en el bloque HTTPS " else # Comprobar si CSP está solo en bloque HTTP (antes de listen 443) # Obtenemos la línea de CSP y la línea de listen 443 local csp_line local ssl_line csp_line=$(echo "$conf_content" | grep -n "Content-Security-Policy" | head -1 | cut -d: -f1) ssl_line=$(echo "$conf_content" | grep -n "listen.*443" | head -1 | cut -d: -f1) if [[ -n "$csp_line" && -n "$ssl_line" && "$csp_line" -lt "$ssl_line" ]]; then # CSP aparece antes del bloque 443 — verificar si también está después local csp_after_ssl csp_after_ssl=$(echo "$conf_content" | tail -n +"$ssl_line" | grep -c "Content-Security-Policy") if [[ "$csp_after_ssl" -eq 0 ]]; then hints="${hints}⚠️ CSP solo en bloque HTTP (80), no en HTTPS (443) — ponla en el bloque donde se sirve tu web " fi fi fi # --- Check 4: ¿unsafe-inline en script-src? --- if echo "$conf_content" | grep -i "script-src" | grep -qi "unsafe-inline"; then hints="${hints}⚠️ unsafe-inline en script-src debilita la protección CSP contra XSS " fi # --- Check 5: ¿Falta 'always' en add_header CSP? --- if echo "$conf_content" | grep "Content-Security-Policy" | grep -qv "always"; then hints="${hints}💡 Consejo: añade 'always' para que CSP se envíe también en respuestas 4xx/5xx " fi fi # --- Check 6: ¿Tiene las otras cabeceras de seguridad? --- local missing_headers="" if ! echo "$conf_content" | grep -q "X-Content-Type-Options"; then missing_headers="${missing_headers} X-Content-Type-Options" fi if ! echo "$conf_content" | grep -q "X-Frame-Options"; then missing_headers="${missing_headers} X-Frame-Options" fi if ! echo "$conf_content" | grep -q "X-XSS-Protection"; then missing_headers="${missing_headers} X-XSS-Protection" fi if ! echo "$conf_content" | grep -q "Referrer-Policy"; then missing_headers="${missing_headers} Referrer-Policy" fi if [[ -n "$missing_headers" ]]; then hints="${hints}💡 Faltan cabeceras de seguridad:${missing_headers} " fi # Devolver resultado if [[ -z "$hints" ]]; then echo "🔒 CSP y cabeceras de seguridad: todo OK" else echo "$hints" fi } # ------------------------------------------------------- # humanize_nginx_error() — Traduce errores nginx -t # a pistas comprensibles para el alumno. # ------------------------------------------------------- humanize_nginx_error() { local error_output="$1" local pistas="" if echo "$error_output" | grep -q 'unexpected "}"'; then pistas="${pistas}💡 ¿Te falta un ';' al final de alguna directiva? " fi if echo "$error_output" | grep -q "unexpected end of file"; then pistas="${pistas}💡 ¿Cerraste todos los bloques server { } con '}'? " fi if echo "$error_output" | grep -q "directive is not allowed"; then pistas="${pistas}💡 Revisa que cada directiva esté dentro del bloque server { } correcto " fi if echo "$error_output" | grep -q "unknown directive"; then pistas="${pistas}💡 ¿Hay algún typo en el nombre de una directiva? " fi if echo "$error_output" | grep -q "invalid number of arguments"; then pistas="${pistas}💡 Alguna directiva tiene argumentos de más o de menos — revisa comillas y punto y coma " fi if echo "$error_output" | grep -qE '(missing|unexpected) ";"'; then pistas="${pistas}💡 Revisa los punto y coma (;) — puede que sobre o falte alguno " fi echo "$pistas" } # ------------------------------------------------------- # check_https_redirect() — Verifica si la config tiene # redirección HTTP → HTTPS (return 301 https://) # ------------------------------------------------------- check_https_redirect() { local conf="$1" local expected_name="$2" local conf_content conf_content=$(cat "$conf" 2>/dev/null) || return local hints="" # ¿Tiene listen 443 ssl? (es decir, ¿sirve HTTPS?) if echo "$conf_content" | grep -q "listen.*443.*ssl"; then # Tiene HTTPS → ¿redirige HTTP a HTTPS? if ! echo "$conf_content" | grep -qE "return\s+301\s+https://"; then hints="${hints}⚠️ HTTPS activo pero NO hay redirección HTTP → HTTPS. 💡 Añade un bloque server { listen 80; return 301 https://\$server_name\$request_uri; } " else hints="${hints}✅ Redirección HTTP → HTTPS configurada correctamente " fi else # No tiene HTTPS hints="${hints}⚠️ Tu config no tiene bloque HTTPS (listen 443 ssl) — el tráfico no va cifrado 💡 Recuerda: la práctica requiere forzar tráfico HTTP → HTTPS " fi echo "$hints" } # ------------------------------------------------------- # check_naming_convention() — Avisa si el fichero .conf # no sigue la nomenclatura mi-nginx[-SUFIJO].conf # ------------------------------------------------------- check_naming_convention() { local conffile="$1" if [[ ! "$conffile" =~ ^mi-nginx(-[a-z0-9]+)?\.conf$ ]]; then echo "📛 Tu fichero se llama '$conffile' — la nomenclatura recomendada es: • mi-nginx.conf → sitio principal • mi-nginx-hextris.conf → segundo sitio (Hextris) • mi-nginx-app.conf → tercer sitio (App) Usa el formato mi-nginx-NOMBRE.conf para que el sistema detecte automáticamente tu subdominio. " fi } # ------------------------------------------------------- # extract_app_suffix() — Extrae el sufijo de app del nombre del .conf # mi-nginx.conf → "" (vacío = sitio principal) # mi-nginx-portfolio.conf → "portfolio" # otro-nombre.conf → "" (no sigue el patrón multi-sitio) # ------------------------------------------------------- extract_app_suffix() { local conffile="$1" if [[ "$conffile" =~ ^mi-nginx-([a-z0-9]+)\.conf$ ]]; then echo "${BASH_REMATCH[1]}" else echo "" fi } # ------------------------------------------------------- # undeploy_site() — Elimina un site de nginx cuando el # alumno borra su mi-nginx[-SUFIJO].conf via SFTP # ------------------------------------------------------- undeploy_site() { local user="$1" local conffile="$2" local app_suffix app_suffix=$(extract_app_suffix "$conffile") local site_id if [[ -n "$app_suffix" ]]; then site_id="${user}-${app_suffix}" else site_id="${user}" fi local dest="/etc/nginx/sites-available/$site_id" local enabled="/etc/nginx/sites-enabled/$site_id" # Solo actuar si el site existe desplegado if [[ -f "$dest" || -L "$enabled" ]]; then rm -f "$enabled" "$dest" # Validar que nginx sigue OK sin ese site if nginx -t 2>&1 | grep -q "successful"; then systemctl reload nginx log "[$user] UNDEPLOY: Site $site_id eliminado (borrado $conffile) y nginx recargado" notify_xmpp "🗑️ [$user] Site $site_id eliminado (borrado $conffile)" else log "[$user] UNDEPLOY: Site $site_id eliminado pero nginx -t falló (recargando de todas formas)" systemctl reload nginx fi fi } validate_and_deploy() { local user="$1" local conffile="$2" local src="/home/$user/html/$conffile" # --- v3: Determinar sufijo de app y nombres esperados --- local app_suffix app_suffix=$(extract_app_suffix "$conffile") local expected_name local expected_root local site_id if [[ -n "$app_suffix" ]]; then expected_name="${user}-${app_suffix}" expected_root="/home/$user/html/$app_suffix" site_id="${user}-${app_suffix}" else expected_name="${user}" expected_root="/home/$user/html" site_id="${user}" fi local dest="/etc/nginx/sites-available/$site_id" local enabled="/etc/nginx/sites-enabled/$site_id" local tmp="/tmp/nginx-test-${site_id}.conf" local backup="/tmp/nginx-backup-${site_id}.conf" local error_file="/home/$user/html/nginx-error.log" local status_file="/home/$user/html/nginx-status.log" local practica_file="/home/$user/html/practica-status.log" # Verificar que el archivo existe [[ ! -f "$src" ]] && return 1 # --- v3: Límite de sitios por alumno (solo para sitios NUEVOS) --- if [[ -n "$app_suffix" && ! -f "$dest" ]]; then local current_sites current_sites=$(ls /etc/nginx/sites-enabled/${user} /etc/nginx/sites-enabled/${user}-* 2>/dev/null | wc -l) if (( current_sites >= MAX_SITES )); then log "[$user] RECHAZADO: límite de $MAX_SITES sitios alcanzado" echo "ERROR: Ya tienes $MAX_SITES sitios desplegados (límite máximo $MAX_SITES)." > "$error_file" echo "Elimina algún mi-nginx-*.conf si quieres crear uno nuevo." >> "$error_file" chown $user:www-data "$error_file" 2>/dev/null notify_xmpp "❌ [$user] Config RECHAZADA: límite de $MAX_SITES sitios alcanzado" return 1 fi fi # Copiar a temporal para validar cp "$src" "$tmp" # Comprobar nomenclatura del fichero (aviso temprano) local naming_check naming_check=$(check_naming_convention "$conffile") # SEGURIDAD: Verificar que server_name coincide con el esperado if ! grep -qE "^\s*server_name\s+${expected_name}\.(local|qu3v3d0\.tech)" "$tmp"; then log "[$user] RECHAZADO: server_name no coincide (esperado: ${expected_name}.qu3v3d0.tech)" { echo "ERROR: server_name debe ser ${expected_name}.qu3v3d0.tech" if [[ -n "$naming_check" ]]; then echo "" echo "$naming_check" fi } > "$error_file" if [[ -n "$app_suffix" ]]; then echo "(Para el fichero $conffile el subdominio esperado es ${expected_name}.qu3v3d0.tech)" >> "$error_file" fi chown $user:www-data "$error_file" 2>/dev/null notify_xmpp "❌ [$user] Config RECHAZADA: server_name incorrecto ($conffile, esperado: ${expected_name})" rm -f "$tmp" return 1 fi # SEGURIDAD: Verificar que root apunta al directorio correcto # - Sin sufijo: prefijo /home/USER/html (v2-compatible, permite subdirectorios) # - Con sufijo: exacto /home/USER/html/SUFIJO (multi-sitio v3) if [[ -n "$app_suffix" ]]; then if ! grep -qE "^\s*root\s+${expected_root}\s*;" "$tmp"; then log "[$user] RECHAZADO: root no apunta a $expected_root" echo "ERROR: root debe ser ${expected_root}" > "$error_file" echo "(Para el fichero $conffile la carpeta debe ser html/$app_suffix/)" >> "$error_file" chown $user:www-data "$error_file" 2>/dev/null notify_xmpp "❌ [$user] Config RECHAZADA: root incorrecto ($conffile, esperado: ${expected_root})" rm -f "$tmp" return 1 fi else # Retrocompatible con v2: acepta /home/USER/html y cualquier subdirectorio if ! grep -qE "^\s*root\s+/home/$user/html" "$tmp"; then log "[$user] RECHAZADO: root no apunta a /home/$user/html" echo "ERROR: root debe ser /home/$user/html" > "$error_file" chown $user:www-data "$error_file" 2>/dev/null notify_xmpp "❌ [$user] Config RECHAZADA: root incorrecto ($conffile)" rm -f "$tmp" return 1 fi fi # --- v3: Verificar que el subdirectorio existe (solo multi-sitio) --- if [[ -n "$app_suffix" && ! -d "$expected_root" ]]; then log "[$user] RECHAZADO: directorio $expected_root no existe" echo "ERROR: La carpeta '$app_suffix/' no existe dentro de html/." > "$error_file" echo "Crea primero la carpeta via SFTP (FileZilla) y luego vuelve a subir $conffile." >> "$error_file" chown $user:www-data "$error_file" 2>/dev/null notify_xmpp "❌ [$user] Config RECHAZADA: carpeta html/$app_suffix/ no existe" rm -f "$tmp" return 1 fi # SEGURIDAD: Bloquear directivas peligrosas (ignorando comentarios) if grep -v "^\s*#" "$tmp" | grep -qE "(proxy_pass|fastcgi_pass|uwsgi_pass|include\s+/|lua_|perl_|upstream)" ; then log "[$user] RECHAZADO: directivas prohibidas detectadas ($conffile)" echo "ERROR: Directivas proxy/include/lua no permitidas" > "$error_file" chown $user:www-data "$error_file" 2>/dev/null notify_xmpp "❌ [$user] Config RECHAZADA: directivas prohibidas ($conffile)" rm -f "$tmp" return 1 fi # Hacer backup del config actual si existe [[ -f "$dest" ]] && cp "$dest" "$backup" # Copiar nuevo config a sites-available cp "$tmp" "$dest" # Asegurar que el symlink existe ln -sf "$dest" "$enabled" # Validar sintaxis nginx con la nueva config local nginx_output nginx_output=$(nginx -t 2>&1) if echo "$nginx_output" | grep -q "successful"; then # Sintaxis OK - recargar nginx systemctl reload nginx log "[$user] OK: Config desplegada ($conffile → $site_id) y nginx recargado" # Analizar CSP y cabeceras de seguridad local csp_analysis csp_analysis=$(analyze_csp "$dest") # Analizar redirección HTTP → HTTPS local https_check https_check=$(check_https_redirect "$dest" "$expected_name") # Mensaje de deploy según sea sitio principal o sub-sitio local deploy_msg if [[ -n "$app_suffix" ]]; then deploy_msg="✅ [$user] Config desplegada OK ($conffile → ${expected_name}.qu3v3d0.tech)" else deploy_msg="✅ [$user] Config desplegada OK ($conffile)" fi # XMPP al profesor (resumen + todos los análisis) notify_xmpp "$deploy_msg $https_check$csp_analysis${naming_check:+ $naming_check}" # nginx-status.log (resumen para el alumno) { echo "OK: Config desplegada desde $conffile $(date)" if [[ -n "$app_suffix" ]]; then echo "🌐 Sitio: https://${expected_name}.qu3v3d0.tech" fi echo "" echo "$https_check" echo "$csp_analysis" if [[ -n "$naming_check" ]]; then echo "$naming_check" fi } > "$status_file" chown $user:www-data "$status_file" 2>/dev/null # practica-status.log (detalle completo) { echo "=== Análisis de $conffile — $(date) ===" if [[ -n "$app_suffix" ]]; then echo "🌐 Sitio: https://${expected_name}.qu3v3d0.tech" echo "📁 Root: $expected_root" fi echo "" echo "--- Redirección HTTPS ---" echo "$https_check" echo "--- Seguridad (CSP y cabeceras) ---" echo "$csp_analysis" if [[ -n "$naming_check" ]]; then echo "--- Nomenclatura ---" echo "$naming_check" fi echo "---" echo "Revisa el enunciado de tu práctica para los entregables pendientes." } > "$practica_file" chown $user:www-data "$practica_file" 2>/dev/null rm -f "$error_file" rm -f "$backup" else # Error de sintaxis - revertir log "[$user] RECHAZADO: Error de sintaxis nginx ($conffile → $site_id)" # Humanizar el error local pistas pistas=$(humanize_nginx_error "$nginx_output") # nginx-error.log con error original + pistas { echo "$nginx_output" | tail -5 echo "ERROR: Sintaxis nginx inválida." if [[ -n "$pistas" ]]; then echo "" echo "--- Pistas ---" echo "$pistas" fi } > "$error_file" chown $user:www-data "$error_file" 2>/dev/null # XMPP al profesor con pistas if [[ -n "$pistas" ]]; then notify_xmpp "❌ [$user] Config RECHAZADA: sintaxis inválida ($conffile) $pistas" else notify_xmpp "❌ [$user] Config RECHAZADA: sintaxis inválida ($conffile)" fi # Restaurar backup si existe if [[ -f "$backup" ]]; then cp "$backup" "$dest" log "[$user] Config anterior de $site_id restaurada" else rm -f "$dest" "$enabled" log "[$user] Config $site_id eliminada (no había backup)" fi rm -f "$backup" fi rm -f "$tmp" } log "=== Nginx User Config Watcher v3 iniciado (*.conf, multi-sitio) ===" notify_xmpp "🚀 [zzz] Nginx Config Watcher v3 iniciado (*.conf, multi-sitio)" # Bucle principal con inotifywait - detecta cualquier *.conf # v3.1: también detecta delete/moved_from para limpiar sites huérfanos inotifywait -m -r -e close_write,moved_to,delete,moved_from --format "%e %w%f" "$WATCH_DIR" 2>/dev/null | while read event filepath; do # Captura: /home/USER/html/ARCHIVO.conf if [[ "$filepath" =~ ^/home/([^/]+)/html/([^/]+.conf)$ ]]; then user="${BASH_REMATCH[1]}" conffile="${BASH_REMATCH[2]}" if [[ "$event" == *DELETE* || "$event" == *MOVED_FROM* ]]; then log "[$user] Detectado borrado de $conffile" undeploy_site "$user" "$conffile" else log "[$user] Detectado cambio en $conffile" sleep 1 # Esperar a que el archivo esté completo validate_and_deploy "$user" "$conffile" fi fi done