add ASIR1 (Programación) support: nginx, watchers, scripts

- New subdomain asir1.qu3v3d0.tech (nginx server block for napi2)
- python-upload-watcher.sh v7: batching, re-uploads, Windows-safe,
  __pycache__ filtering, full student names in XMPP notifications
- sshfs mount units for both napi-data and napi-data2
- nginx configs for DDAW2 and ASIR1 preserved as reference
- Screenshot of XMPP notifications for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fenix 2026-02-25 20:32:43 +01:00
parent 14949a3775
commit aa3126cdb6
14 changed files with 1107 additions and 1 deletions

5
.gitignore vendored
View File

@ -1 +1,4 @@
data data/
TASKS.org
BUGS.org

1
Screenshots/test Normal file
View File

@ -0,0 +1 @@
.

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

51
nginx/README.md Normal file
View File

@ -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.

41
nginx/napi-ddaw2.conf Normal file
View File

@ -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;
}
}

41
nginx/napi2-asir1.conf Normal file
View File

@ -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;
}
}

165
scripts/README.md Normal file
View File

@ -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 |

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

33
scripts/xmpp-notify.py Normal file
View File

@ -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]))