diff --git a/.gitignore b/.gitignore index 1269488..18eb24c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -data +data/ +TASKS.org +BUGS.org + diff --git a/Screenshots/test b/Screenshots/test new file mode 100644 index 0000000..945c9b4 --- /dev/null +++ b/Screenshots/test @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/Screenshots/zzz@librebits.info.png b/Screenshots/zzz@librebits.info.png new file mode 100644 index 0000000..034828d Binary files /dev/null and b/Screenshots/zzz@librebits.info.png differ diff --git a/nginx/README.md b/nginx/README.md new file mode 100644 index 0000000..e0882ed --- /dev/null +++ b/nginx/README.md @@ -0,0 +1,51 @@ +# 🌐 Configuraciones Nginx — napi + +Copia de referencia de los server blocks desplegados en **zzz** (`qu3v3d0.tech`). + +--- + +## 📋 Sites activos + +| Fichero | server_name | Root | Grupo | +|:--------|:------------|:-----|:------| +| **`napi-ddaw2.conf`** | `notas.qu3v3d0.tech` | `/var/www/napi` | DDAW2 (19 alumnos) | +| **`napi2-asir1.conf`** | `asir1.qu3v3d0.tech` | `/var/www/napi2` | ASIR1 Programación (21 alumnos) | + +--- + +## 🔑 Funcionamiento común + +Ambos sites comparten la misma arquitectura: + +1. **Auth PAM** (`libnginx-mod-http-auth-pam`) — credenciales SFTP del alumno +2. **`$remote_user`** — Nginx usa el usuario autenticado para servir su `notas.md` +3. **`viewer.html`** — App estática que hace `fetch('/notas.md')` y renderiza con `marked.js` + `twemoji` +4. **SSL wildcard** — `*.qu3v3d0.tech` (certificado en `/etc/ssl/certs/qu3v3d0.tech.crt`) + +``` +Alumno → https://asir1.qu3v3d0.tech → Auth PAM → viewer.html + → fetch('/notas.md') → Nginx alias → /var/www/napi2/data/$remote_user/notas.md + → marked.js renderiza → alumno ve su feedback +``` + +--- + +## 🛠️ Gestión en zzz + +```bash +# Ver configs activos +ls -la /etc/nginx/sites-enabled/ + +# Editar +sudo nano /etc/nginx/sites-available/napi2 + +# Test + reload +sudo nginx -t && sudo systemctl reload nginx +``` + +--- + +## 📅 DNS + +DNS wildcard `*.qu3v3d0.tech` → `161.22.44.104` (zzz). +No hay que tocar DNS para añadir nuevos subdominios. diff --git a/nginx/napi-ddaw2.conf b/nginx/napi-ddaw2.conf new file mode 100644 index 0000000..4a942de --- /dev/null +++ b/nginx/napi-ddaw2.conf @@ -0,0 +1,41 @@ +# napi — Notas API (notas.qu3v3d0.tech) +# Auth: PAM (mismas credenciales que SFTP) +# Datos: /var/www/api/data/$remote_user/notas.md + +server { + listen 80; + server_name notas.qu3v3d0.tech; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name notas.qu3v3d0.tech; + + ssl_certificate /etc/ssl/certs/qu3v3d0.tech.crt; + ssl_certificate_key /etc/ssl/private/qu3v3d0.tech.key; + + root /var/www/api; + + auth_pam "Notas DDAW2"; + auth_pam_service_name "common-auth"; + + location = / { + try_files /viewer.html =404; + } + + location = /notas.md { + alias /var/www/api/data/$remote_user/notas.md; + default_type text/plain; + charset utf-8; + add_header Cache-Control "no-cache"; + } + + location ~* \.(js|css)$ { + expires 7d; + } + + location / { + return 404; + } +} diff --git a/nginx/napi2-asir1.conf b/nginx/napi2-asir1.conf new file mode 100644 index 0000000..4fbfb7f --- /dev/null +++ b/nginx/napi2-asir1.conf @@ -0,0 +1,41 @@ +# napi2 — Notas Programación ASIR1 (asir1.qu3v3d0.tech) +# Auth: PAM (mismas credenciales que SFTP) +# Datos: /var/www/napi2/data/$remote_user/notas.md + +server { + listen 80; + server_name asir1.qu3v3d0.tech; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name asir1.qu3v3d0.tech; + + ssl_certificate /etc/ssl/certs/qu3v3d0.tech.crt; + ssl_certificate_key /etc/ssl/private/qu3v3d0.tech.key; + + root /var/www/napi2; + + auth_pam "Notas Programacion ASIR1"; + auth_pam_service_name "common-auth"; + + location = / { + try_files /viewer.html =404; + } + + location = /notas.md { + alias /var/www/napi2/data/$remote_user/notas.md; + default_type text/plain; + charset utf-8; + add_header Cache-Control "no-cache"; + } + + location ~* \.(js|css)$ { + expires 7d; + } + + location / { + return 404; + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d1a1602 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,165 @@ +# 📜 Scripts y Servicios — napi + +Copia de referencia de todos los scripts y unidades systemd desplegados en **zzz** (`qu3v3d0.tech`) y **aldebaran/anka4** para el sistema de retroalimentación a estudiantes. + +--- + +## 🗂️ Inventario + +| Fichero | Dónde vive | Descripción | +|:--------|:-----------|:------------| +| **`python-upload-watcher.sh`** | `zzz:/usr/local/bin/` | Watcher ASIR1 — detecta entregas SFTP en `/home/USER/python/`, agrupa por carpeta, notifica XMPP | +| **`nginx-user-config-watcher.sh`** | `zzz:/usr/local/bin/` | Watcher DDAW2 — valida y despliega `*.conf` de alumnos, analiza CSP, notifica XMPP | +| **`xmpp-notify.py`** | `zzz:/usr/local/bin/` | Bot XMPP one-shot (slixmpp) — envía mensaje a `jla@librebits.info` | +| **`python-upload-watcher.service`** | `zzz:/etc/systemd/system/` | Unit systemd para el watcher ASIR1 | +| **`nginx-user-config-watcher.service`** | `zzz:/etc/systemd/system/` | Unit systemd para el watcher DDAW2 | +| **`home-fenix-napi-data.mount`** | `aldebaran:~/.config/systemd/user/` | Mount sshfs `~/napi-data/` → `zzz:/var/www/napi/data/` (DDAW2) | +| **`home-fenix-napi-data2.mount`** | `aldebaran:~/.config/systemd/user/` | Mount sshfs `~/napi-data2/` → `zzz:/var/www/napi2/data/` (ASIR1) | + +--- + +## 🐍 python-upload-watcher.sh (v5) + +**Propósito:** Monitorizar las entregas de prácticas de Programación (ASIR1) subidas por SFTP. + +### Características + +- ✅ **Batching por carpeta** — Cuando un alumno sube `PRACTICA3.1/` con N ficheros, envía **un solo mensaje XMPP** con el listado completo (espera 10s de silencio) +- ♻️ **Re-entregas** — Si el alumno borra y re-sube la misma carpeta (<120s), etiqueta como "Re-entrega" +- 🪟 **Windows-safe** — Ignora `Thumbs.db`, `desktop.ini`, `.DS_Store`, `*.tmp`, `~$*` +- 📁 **Iconos por tipo** — 🐍 `.py` · 📦 `.zip/.rar` · 📝 `.md` · 📄 `.txt` · 📕 `.pdf` · 📘 `.docx` + +### Ejemplo de notificación XMPP + +``` +📁 [barrios] Entrega ASIR1: PRACTICA3.1/ (4 ficheros) + 🐍 main.py (2048 bytes) + 🐍 utils.py (1024 bytes) + 📝 README.md (512 bytes) + 📄 requisitos.txt (128 bytes) +``` + +``` +📁 [barrios] ♻️ Re-entrega ASIR1: PRACTICA3.1/ (3 ficheros) + 🐍 main.py (2100 bytes) + 📝 README.md (600 bytes) + 📄 requisitos.txt (128 bytes) +``` + +### Eventos inotifywait monitorizados + +| Evento | Acción | +|:-------|:-------| +| `CREATE` (directorio) | Abre batch, detecta re-entrega si fue borrado recientemente | +| `CREATE` (fichero) | Ignorado (esperamos `close_write`) | +| `CLOSE_WRITE` | Fichero completado → añade al batch o notifica individual | +| `MOVED_TO` | Fichero movido al directorio → igual que `close_write` | +| `DELETE` / `MOVED_FROM` | Registra borrado de carpeta (para detectar re-entregas) | + +### Log + +```bash +sudo tail -f /var/log/python-upload-watcher.log # en zzz +``` + +--- + +## 🌐 nginx-user-config-watcher.sh (v3) + +**Propósito:** Despliegue automático de configuraciones Nginx subidas por alumnos de DDAW2 via SFTP. + +### Características + +- ✅ **Validación de seguridad** — Verifica `server_name`, `root`, bloquea directivas peligrosas (`proxy_pass`, `include /`, etc.) +- 🔒 **Análisis CSP** — Revisa cabeceras `Content-Security-Policy`, detecta `unsafe-inline`, multiline, HTTPS +- 📛 **Nomenclatura** — Verifica patrón `mi-nginx[-SUFIJO].conf` +- 🌐 **Multi-sitio v3** — `mi-nginx-hextris.conf` → `USER-hextris.qu3v3d0.tech` +- 🗑️ **Undeploy** — Borrar el `.conf` via SFTP elimina el site de Nginx +- 💡 **Pistas humanizadas** — Traduce errores `nginx -t` a español comprensible + +### Límites + +- Máximo **4 sitios** por alumno +- Solo `.conf` dentro de `/home/USER/html/` + +--- + +## 📨 xmpp-notify.py + +Bot XMPP one-shot usando `slixmpp`. Envía un mensaje y desconecta. + +```bash +/usr/local/bin/xmpp-notify.py "Mensaje de prueba" +``` + +- **JID:** `zzz@librebits.info` +- **Destinatario:** `jla@librebits.info` +- **Config:** `/etc/xmpp-notify.conf` (credenciales) + +--- + +## ⚙️ Units systemd + +### En zzz (system-level) + +```bash +# Estado de los watchers +sudo systemctl status python-upload-watcher.service # ASIR1 +sudo systemctl status nginx-user-config-watcher.service # DDAW2 + +# Reiniciar +sudo systemctl restart python-upload-watcher.service +sudo systemctl restart nginx-user-config-watcher.service +``` + +### En aldebaran/anka4 (user-level) + +```bash +# Estado de los mounts sshfs +systemctl --user status home-fenix-napi\\x2ddata.mount # DDAW2 +systemctl --user status home-fenix-napi\\x2ddata2.mount # ASIR1 + +# Montar/desmontar +systemctl --user start home-fenix-napi\\x2ddata2.mount +systemctl --user stop home-fenix-napi\\x2ddata2.mount +``` + +--- + +## 🏗️ Estructura de directorios en zzz + +``` +/var/www/napi/ ← DDAW2 +├── viewer.html +├── marked.min.js +├── twemoji.min.js +└── data/ + ├── anas/notas.md + ├── pablo/notas.md + └── ... (19 alumnos) + +/var/www/napi2/ ← ASIR1 (Programación) +├── viewer.html +├── marked.min.js +├── twemoji.min.js +└── data/ + ├── barja/notas.md + ├── barrios/notas.md + └── ... (21 alumnos) + +/home/USER/python/ ← Entregas SFTP (ASIR1) + ├── PRACTICA3.1/ + │ ├── main.py + │ └── README.md + └── fichero_suelto.py +``` + +--- + +## 📅 Historial de versiones + +| Script | Versión | Fecha | Cambios | +|:-------|:--------|:------|:--------| +| `python-upload-watcher.sh` | **v5** | 2026-02-25 | Batching + re-entregas + Windows-safe + fix `local` en subshell | +| `nginx-user-config-watcher.sh` | **v3** | 2026-02-19 | Multi-sitio + undeploy + CSP analysis | +| `xmpp-notify.py` | **v1** | 2026-01-27 | Bot one-shot slixmpp | diff --git a/scripts/home-fenix-napi-data.mount b/scripts/home-fenix-napi-data.mount new file mode 100644 index 0000000..213e981 --- /dev/null +++ b/scripts/home-fenix-napi-data.mount @@ -0,0 +1,13 @@ +[Unit] +Description=SSHFS mount napi-data from zzz (qu3v3d0.tech) +After=network-online.target +Wants=network-online.target + +[Mount] +What=fenix@qu3v3d0.tech:/var/www/napi/data +Where=/home/fenix/napi-data +Type=fuse.sshfs +Options=reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,IdentityFile=/home/fenix/.ssh/id_rsa,_netdev,allow_other + +[Install] +WantedBy=default.target diff --git a/scripts/home-fenix-napi-data2.mount b/scripts/home-fenix-napi-data2.mount new file mode 100644 index 0000000..f0c1c0c --- /dev/null +++ b/scripts/home-fenix-napi-data2.mount @@ -0,0 +1,13 @@ +[Unit] +Description=SSHFS mount napi-data2 from zzz (qu3v3d0.tech) — Programación ASIR1 +After=network-online.target +Wants=network-online.target + +[Mount] +What=fenix@qu3v3d0.tech:/var/www/napi2/data +Where=/home/fenix/napi-data2 +Type=fuse.sshfs +Options=reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,IdentityFile=/home/fenix/.ssh/id_rsa,_netdev,allow_other + +[Install] +WantedBy=default.target diff --git a/scripts/nginx-user-config-watcher.service b/scripts/nginx-user-config-watcher.service new file mode 100644 index 0000000..9c4a220 --- /dev/null +++ b/scripts/nginx-user-config-watcher.service @@ -0,0 +1,12 @@ +[Unit] +Description=Watch and deploy user Nginx configs +After=network.target nginx.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/nginx-user-config-watcher.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/nginx-user-config-watcher.sh b/scripts/nginx-user-config-watcher.sh new file mode 100644 index 0000000..0f8d4be --- /dev/null +++ b/scripts/nginx-user-config-watcher.sh @@ -0,0 +1,508 @@ +#!/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 diff --git a/scripts/python-upload-watcher.service b/scripts/python-upload-watcher.service new file mode 100644 index 0000000..f64f2a1 --- /dev/null +++ b/scripts/python-upload-watcher.service @@ -0,0 +1,12 @@ +[Unit] +Description=Watch ASIR1 Python uploads via SFTP and notify XMPP +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/python-upload-watcher.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/python-upload-watcher.sh b/scripts/python-upload-watcher.sh new file mode 100644 index 0000000..f23cb3c --- /dev/null +++ b/scripts/python-upload-watcher.sh @@ -0,0 +1,213 @@ +#!/bin/bash +# /usr/local/bin/python-upload-watcher.sh +# Watcher para entregas de Programación ASIR1 via SFTP +# v7 — Nombre completo en notificaciones ([María Jara] en vez de [jara]) +# +# - Batching por carpeta: UN mensaje XMPP con listado completo +# - Re-entregas: detecta delete+recreate (<120s) +# - Windows-safe: ignora Thumbs.db, desktop.ini, .DS_Store, *.tmp, __pycache__, *.pyc + +WATCH_DIR="/home" + +# --- Mapa usuario → nombre completo --- +fullname() { + case "$1" in + barja) echo "Alex Barja" ;; + barrios) echo "Andrés Barrios" ;; + cayo) echo "Jared Cayo" ;; + contrera) echo "Luciano Contrera" ;; + duque) echo "Jorge Duque" ;; + florea) echo "Alejandro Florea" ;; + gomes) echo "Gabriel Gomes" ;; + izquierdo) echo "Daniel Izquierdo" ;; + jara) echo "María Jara" ;; + lillo) echo "Fernando Lillo" ;; + linares) echo "Christopher Linares" ;; + macedo) echo "Eduardo Macedo" ;; + martinez) echo "Daniel Martínez" ;; + munoz) echo "Jordy Muñoz" ;; + olcina) echo "Jorge Olcina" ;; + ponce) echo "Francisco Ponce" ;; + posada) echo "Santiago Posada" ;; + quiroz) echo "Alexander Quiroz" ;; + reynoso) echo "Jorge Reynoso" ;; + sierra) echo "Leonel Sierra" ;; + torrero) echo "Mario Torrero" ;; + *) echo "$1" ;; # fallback: username tal cual + esac +} +LOG="/var/log/python-upload-watcher.log" +BATCH_DIR="/tmp/python-watcher-batches" +DELETE_DIR="/tmp/python-watcher-deletes" +QUIET_SECONDS=10 + +mkdir -p "$BATCH_DIR" "$DELETE_DIR" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG" +} + +notify_xmpp() { + /usr/local/bin/xmpp-notify.py "$1" >/dev/null 2>&1 & +} + +is_junk() { + case "${1,,}" in + thumbs.db|desktop.ini|.ds_store|*.tmp|~\$*|~*.tmp) return 0 ;; + __pycache__|*.pyc|*.pyo) return 0 ;; + .git|.venv|.env|node_modules|.idea|.vscode) return 0 ;; + *) return 1 ;; + esac +} + +# Filtrar paths que contengan directorios basura +is_junk_path() { + case "$1" in + */__pycache__/*|*/.git/*|*/.venv/*|*/node_modules/*|*/.idea/*|*/.vscode/*) return 0 ;; + *) return 1 ;; + esac +} + +file_icon() { + case "${1,,}" in + *.py) echo "🐍" ;; + *.zip|*.rar|*.tar*|*.gz|*.bz2|*.7z) echo "📦" ;; + *.md) echo "📝" ;; + *.txt) echo "📄" ;; + *.pdf) echo "📕" ;; + *.docx|*.doc) echo "📘" ;; + *.png|*.jpg|*.jpeg|*.gif) echo "🖼️" ;; + *) echo "📎" ;; + esac +} + +batch_key() { + echo "${1}_${2}" | tr '/ ' '__' +} + +flush_batch() { + local batchfile="$1" + [[ ! -f "$batchfile" ]] && return + + local user dir tag file_count file_list label msg + user=$(sed -n '1p' "$batchfile") + dir=$(sed -n '2p' "$batchfile") + tag=$(sed -n '3p' "$batchfile") + file_count=$(tail -n +4 "$batchfile" | wc -l) + + [[ "$file_count" -eq 0 ]] && { rm -f "$batchfile"; return; } + + file_list=$(tail -n +4 "$batchfile") + label="Entrega" + [[ "$tag" == "re-entrega" ]] && label="♻️ Re-entrega" + + local display_name + display_name=$(fullname "$user") + msg="📁 [$display_name] $label ASIR1: $dir/ ($file_count ficheros) +$file_list" + + log "[$user] BATCH FLUSH ($tag): $dir/ → $file_count ficheros" + notify_xmpp "$msg" + rm -f "$batchfile" +} + +flush_daemon() { + while true; do + sleep 3 + for batchfile in "$BATCH_DIR"/*.batch; do + [[ ! -f "$batchfile" ]] && continue + local last_mod now age + last_mod=$(stat -c%Y "$batchfile" 2>/dev/null) || continue + now=$(date +%s) + age=$(( now - last_mod )) + (( age >= QUIET_SECONDS )) && flush_batch "$batchfile" + done + done +} + +flush_daemon & +FLUSH_PID=$! +trap "kill $FLUSH_PID 2>/dev/null" EXIT + +log "=== Python Upload Watcher v7 iniciado ===" +notify_xmpp "🚀 [zzz] Python Upload Watcher v7 iniciado (ASIR1)" + +# --------------------------------------------------------- +# Bucle principal — sin 'local', todo variables globales +# --------------------------------------------------------- +inotifywait -m -r \ + -e close_write -e moved_to -e create -e delete -e moved_from \ + --format "%e %w%f" "$WATCH_DIR" 2>/dev/null | \ +while IFS= read -r line; do + + _event="${line%% /*}" + _filepath="/${line#* /}" + + [[ ! "$_filepath" =~ ^/home/([^/]+)/python/(.+)$ ]] && continue + + _user="${BASH_REMATCH[1]}" + _relpath="${BASH_REMATCH[2]}" + _filename=$(basename "$_relpath") + + is_junk "$_filename" && continue + is_junk_path "$_relpath" && continue + + # --- DELETE / MOVED_FROM --- + if [[ "$_event" == *DELETE* || "$_event" == *MOVED_FROM* ]]; then + if [[ "$_relpath" != */* ]]; then + _bk=$(batch_key "$_user" "$_relpath") + echo "$(date +%s)" > "$DELETE_DIR/$_bk" + rm -f "$BATCH_DIR/${_bk}.batch" + log "[$_user] BORRADA: $_relpath/" + fi + continue + fi + + # --- CREATE directorio --- + if [[ "$_event" == *CREATE* && -d "$_filepath" ]]; then + # Solo carpetas de primer nivel bajo python/ + if [[ "$_relpath" != */* ]]; then + _bk=$(batch_key "$_user" "$_relpath") + _tag="new" + + if [[ -f "$DELETE_DIR/$_bk" ]]; then + _del_ts=$(cat "$DELETE_DIR/$_bk") + _now=$(date +%s) + (( _now - _del_ts < 120 )) && _tag="re-entrega" + rm -f "$DELETE_DIR/$_bk" + fi + + printf '%s\n%s\n%s\n' "$(fullname "$_user")" "$_relpath" "$_tag" > "$BATCH_DIR/${_bk}.batch" + log "[$_user] CARPETA ($_tag): $_relpath/ — batch abierto en $BATCH_DIR/${_bk}.batch" + fi + continue + fi + + # --- CREATE fichero → skip (close_write viene después) --- + [[ "$_event" == *CREATE* ]] && continue + + # --- close_write / moved_to → fichero completado --- + _size=$(stat -c%s "$_filepath" 2>/dev/null || echo "?") + _icon=$(file_icon "$_filename") + + if [[ "$_relpath" == */* ]]; then + _topdir="${_relpath%%/*}" + _bk=$(batch_key "$_user" "$_topdir") + _batchfile="$BATCH_DIR/${_bk}.batch" + + if [[ -f "$_batchfile" ]]; then + _subpath="${_relpath#*/}" + echo " $_icon $_subpath ($_size bytes)" >> "$_batchfile" + log "[$_user] +BATCH: $_relpath ($_size bytes)" + continue + fi + + # Sin batch → individual + log "[$_user] Subido: $_relpath ($_size bytes)" + notify_xmpp "$_icon [$(fullname "$_user")] Entrega ASIR1: $_relpath ($_size bytes)" + else + log "[$_user] Subido: $_relpath ($_size bytes)" + notify_xmpp "$_icon [$(fullname "$_user")] Entrega ASIR1: $_relpath ($_size bytes)" + fi + +done diff --git a/scripts/xmpp-notify.py b/scripts/xmpp-notify.py new file mode 100644 index 0000000..9cf9290 --- /dev/null +++ b/scripts/xmpp-notify.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" +Notificador XMPP one-shot para zzz.qu3v3d0.tech +Uso: xmpp-notify.py "mensaje" +""" +import sys +import slixmpp +import asyncio +import logging + +logging.basicConfig(level=logging.WARNING) + +class NotifyBot(slixmpp.ClientXMPP): + def __init__(self, msg): + super().__init__("zzz@librebits.info", "zzz2025") + self.msg = msg + self.add_event_handler("session_start", self.start) + + async def start(self, event): + self.send_message(mto="jla@librebits.info", mbody=self.msg, mtype="chat") + await asyncio.sleep(0.5) + self.disconnect() + +async def main(msg): + bot = NotifyBot(msg) + bot.connect() + await asyncio.sleep(8) + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Uso: xmpp-notify.py \"mensaje\"") + sys.exit(1) + asyncio.run(main(sys.argv[1]))