From 742e534af20f8d78c50c55ea9c5eccc6d707a746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leopoldo=20Rodr=C3=ADguez?= Date: Tue, 27 Oct 2026 14:24:06 -0600 Subject: [PATCH] =?UTF-8?q?27-oct:=20p=C3=A1gina=20de=20entrada,=20inciden?= =?UTF-8?q?cias,=20standup,=20vig=C3=ADa=20y=20agente=20diario;=20integrac?= =?UTF-8?q?i=C3=B3n=20del=20cotizador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE --- CLAUDE.md | 6 +- DESPLIEGUE.md | 62 ++- agentes/agente-diario.md | 110 +++++ backend/acceso.py | 403 +++++++++++++++++ backend/actas-hbox.service | 15 +- backend/app.py | 31 +- backend/cuentas/2026-10-27_correos.json | 54 +++ backend/incidencias.py | 404 ++++++++++++++++++ ...026-10-27_entrada-incidencias-agentes.json | 58 +++ backend/lotes/2026-10-27_t45-lista.json | 72 ++++ backend/nginx/cambiar-entrada.sh | 114 +++++ backend/nginx/drogheda.helgen.online.conf | 128 ++++++ backend/semilla_1027.py | 64 +++ backend/standup.py | 198 +++++++++ backend/tests-1027.sh | 101 +++++ backend/vigia.py | 265 ++++++++++++ docs/admin.html | 1 + docs/agentes.md | 79 ++++ docs/bitacora.md | 4 + docs/cuenta.html | 52 ++- docs/entrar.html | 83 ++++ docs/formularios/ardusimple.html | 1 + docs/formularios/banco.html | 1 + docs/formularios/dano.html | 1 + docs/formularios/diagnostico.html | 1 + docs/formularios/ensamble.html | 1 + docs/formularios/envio.html | 1 + docs/formularios/ethernet-serie.html | 1 + docs/formularios/instalacion.html | 1 + docs/formularios/integracion.html | 1 + docs/formularios/modem4g.html | 1 + docs/formularios/prueba-bateria.html | 1 + docs/formularios/prueba-sitio.html | 1 + docs/formularios/recepcion.html | 1 + docs/formularios/reflasheo.html | 1 + docs/formularios/v4.html | 1 + docs/formularios/v5.html | 1 + docs/formularios/ver.html | 1 + docs/hardware/diseno-v6.md | 7 +- ...26-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png | Bin 0 -> 236326 bytes ...-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png | Bin 0 -> 457422 bytes docs/hardware/index.md | 9 +- docs/hardware/instrucciones-leo-27oct.md | 19 +- docs/hardware/instrucciones-michelle-27oct.md | 49 ++- docs/hardware/v6-revision-26oct.md | 7 +- ...0-27_Drogheda_Flujo-OC-Inventario_v1.0.png | Bin 0 -> 334090 bytes docs/inventario/index.md | 2 + .../integracion-cotizador-instrucciones.md | 150 +++++++ docs/inventario/integracion-cotizador.md | 154 +++++++ docs/js/incidencias.js | 193 +++++++++ docs/js/standup.js | 22 + docs/publico/caido.html | 23 + docs/publico/entrar.css | 87 ++++ docs/publico/entrar.js | 132 ++++++ docs/salir.html | 24 +- docs/stylesheets/extra.css | 6 + docs/tableros/equipo.md | 3 +- docs/tableros/incidencias-tablero.js | 182 ++++++++ docs/tableros/incidencias.css | 43 ++ docs/tableros/incidencias.md | 55 +++ docs/tableros/ingenieria.md | 2 + docs/tableros/standup-tablero.js | 140 ++++++ docs/tableros/standup.css | 32 ++ docs/tableros/standup.md | 33 ++ hooks/iconos.py | 3 + mkdocs.yml | 6 + overrides/main.html | 2 + scripts/agente-api.sh | 20 + scripts/deploy.sh | 5 +- scripts/despliegue-1027.sh | 46 ++ scripts/sync-docs.sh | 7 + 71 files changed, 3719 insertions(+), 65 deletions(-) create mode 100644 agentes/agente-diario.md create mode 100644 backend/acceso.py create mode 100644 backend/cuentas/2026-10-27_correos.json create mode 100644 backend/incidencias.py create mode 100644 backend/lotes/2026-10-27_entrada-incidencias-agentes.json create mode 100644 backend/lotes/2026-10-27_t45-lista.json create mode 100755 backend/nginx/cambiar-entrada.sh create mode 100644 backend/nginx/drogheda.helgen.online.conf create mode 100644 backend/semilla_1027.py create mode 100644 backend/standup.py create mode 100755 backend/tests-1027.sh create mode 100644 backend/vigia.py create mode 100644 docs/agentes.md create mode 100644 docs/entrar.html create mode 100644 docs/hardware/img/2026-10-27_Drogheda_v6-T45-EP-U4-U7_v1.0.png create mode 100644 docs/hardware/img/2026-10-27_Drogheda_v6-T45-R80-Kelvin_v1.0.png create mode 100644 docs/inventario/img/2026-10-27_Drogheda_Flujo-OC-Inventario_v1.0.png create mode 100644 docs/inventario/integracion-cotizador-instrucciones.md create mode 100644 docs/inventario/integracion-cotizador.md create mode 100644 docs/js/incidencias.js create mode 100644 docs/js/standup.js create mode 100644 docs/publico/caido.html create mode 100644 docs/publico/entrar.css create mode 100644 docs/publico/entrar.js create mode 100644 docs/tableros/incidencias-tablero.js create mode 100644 docs/tableros/incidencias.css create mode 100644 docs/tableros/incidencias.md create mode 100644 docs/tableros/standup-tablero.js create mode 100644 docs/tableros/standup.css create mode 100644 docs/tableros/standup.md create mode 100755 scripts/agente-api.sh create mode 100755 scripts/despliegue-1027.sh diff --git a/CLAUDE.md b/CLAUDE.md index 0aa4860..716d403 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ > This file is the mechanics: where each piece of the site lives and how to change it. This repo (submodule of Drogheda) is the internal site: MkDocs Material in Mexican Spanish + a Flask -backend (`backend/app.py`, gunicorn `actas-hbox` on `127.0.0.1:8081`, nginx basic auth in front). Leo, +backend (`backend/app.py`, gunicorn `actas-hbox` on `127.0.0.1:8081`; since 27-oct nginx asks the backend for +the session on every request — `auth_request` to `/api/_auth`, login page `/entrar.html`, `backend/acceso.py`). Leo, Adriana, Michelle, Jesús and the field technicians work from it; Claude closes every task on it too. ## Read first (start of every task) @@ -30,6 +31,9 @@ Adriana, Michelle, Jesús and the field technicians work from it; Claude closes | **Ruta a producción**, version cards, fase, pedido, próximas fechas, map pins | `docs/datos/proyecto.json` | Claude or Leo | edit by hand (keep the one-array-per-line format); step states `hecho · ahora · bloqueado · falta`, card tone `hecho · ahora · aviso · bloqueado`; bump `actualizado` | | **Instalaciones** (per-site installation board, 22-oct) | `docs/tableros/instalaciones.md` + `.js`/`.css`; data: field actas via `GET /api/instalaciones` (`backend/instalaciones.py`, hooked at the end of `app.py`) | automatic | stages envío → recepción → instalación → **prueba en sitio** (acta `prueba-sitio`; only a passed test counts for the 1–2 per-site goal) → en operación (7 days after the test, no failure) + «con falla»; colour, nota and meta per site from `proyecto.json`; forms accept `?serie=&sitio=&=` prefill (`acta.js`) | | **Inventario por sitio** (23-oct) | `docs/tableros/inventario.md` + `.js`/`.css`; data: server `/srv/projects/drogheda/inventario` via `/api/inventario` | everyone in the UI | tabs ICAVE · TIMSA · EIT · LCT · Querétaro + «Todo» (`?sitio=eit&n=2`); every movement carries `sitio` (none = qro); `envio` leaves a site (or `fuera`: purchase in transit / material shipped before counting) and stays «en camino» until the destination presses «Recibir» (`recepcion`); item `por_instalacion` = the installation kit; team rules in `docs/inventario/index.md`; tests `backend/tests-1023.sh` | +| **Entrada al sitio** (27-oct) | `docs/entrar.html` + `docs/publico/` (public); `backend/acceso.py`; nginx `backend/nginx/drogheda.helgen.online.conf` installed by `backend/nginx/cambiar-entrada.sh` (backup, tests, auto-rollback; `deshacer`) | — | session = signed cookie `hbox_sesion` (30 days; version = hash of the user's htpasswd line, so a password change ends old sessions); login by user or Equipo email, case-insensitive; **no HTTP Basic** (only from `ACCESO_BASIC_IPS`, empty by default — server scripts use 127.0.0.1:8081); unsafe methods only same-origin (Origin/Sec-Fetch-Site); lockout per user+IP (8) and per IP (20) in 15 min; attempts in `/srv/projects/drogheda/acceso/intentos-AAAA-MM.jsonl` (no passwords); public paths: entrada, `/publico/`, `/fonts/`, `helgen.css`, `tema.js`, icon, `/api/{entrar,salir,sesion,salud,incidencias/publica(s)}` | +| **Incidencias del sitio** (27-oct) | server `/srv/projects/drogheda/incidencias` (INC-0001.json…, `_errores/`, `_vigia/`); `backend/incidencias.py`; page `docs/tableros/incidencias.md` + `incidencias-tablero.js/.css`; footer «Problemas del sitio» = `docs/js/incidencias.js` (in `overrides/main.html` head and in every standalone page) | everyone reports; `INCIDENCIAS_EDITORES` (leo, gibranlp, adriana, claude) triage; the daily agent through `scripts/agente-api.sh` | rules: `docs/agentes.md`; the agent's runbook: `agentes/agente-diario.md`; vigía `backend/vigia.py` (leo's crontab, minute 7); never put passwords or project data in `publica` notices | +| **Standup** (27-oct) | server `/srv/projects/drogheda/standup/AAAA-MM-DD/.json`; `backend/standup.py`; `docs/tableros/standup.md` + `standup-tablero.js/.css`; banner `docs/js/standup.js` | the bot (`claude`) prepares; each person answers; Leo/Adriana see «Equipo» | answers can move the person's own cards (as them); everything else from the answers goes in a lote | | **Equipo y accesos** (14/23-oct) | server `/srv/projects/drogheda/equipo` (one JSON per member) + `/etc/nginx/hbox-docs.htpasswd` | Leo (admin) in Equipo and Mi cuenta; in bulk `backend/cuentas.py` run by Leo on the server | member fields `sitio`, `correo`, `correo_alt` (two email domains), `contrasena_inicial` (set when someone else sets the password, cleared when the person changes it; banner from `docs/js/sitio.js`, admin-only pill in Equipo); new people go in `backend/cuentas/AAAA-MM-DD_*.json` (never passwords) → `cuentas.py todo|alta`; task `responsable_ids` are recomputed from the text with today's team | | **Últimas decisiones** (Ingeniería) | `docs/tableros/ingenieria.md` | Claude or Leo | new `
  • DD-mmm (hora) — …
  • ` at the top, with the decision number (D-xx-n) and links | | **Bitácora → Documentos por fecha** | `docs/bitacora.md` | Claude or Leo | newest first under the month: `**DD oct**` + `: [Title](path.md#anchor): what it decides or changes` | diff --git a/DESPLIEGUE.md b/DESPLIEGUE.md index ff87c24..179cc5b 100644 --- a/DESPLIEGUE.md +++ b/DESPLIEGUE.md @@ -502,12 +502,70 @@ Pruebas en la Mac (opcional, necesita Flask): `bash backend/tests-cuentas.sh .` | Hay que deshacer el restablecimiento | — | el archivo anterior quedó en `/srv/projects/drogheda/equipo/_respaldos/htpasswd-AAAAMMDD-hhmmss`: cópialo encima de `/etc/nginx/hbox-docs.htpasswd` como leo | | No sale la franja de la contraseña ni la columna Sitio | servicio sin reiniciar | el `restart` de arriba | +## A11 · 27-oct: página de entrada, incidencias, standup, vigía y agente diario + +**Por qué.** Adriana reportó el 26-oct que, después de restablecer contraseñas, la gente no podía volver a entrar. Con la +ventana gris de HTTP Basic el navegador y el teléfono guardan la contraseña anterior y la vuelven a mandar sola; Mi cuenta +no tenía campo de usuario (el gestor de contraseñas nunca se actualizaba); «Adriana» con mayúscula no existe para nginx; no +había cómo cerrar sesión ni registro de quién fallaba. Además, a las 9:15 del 27-oct se instaló a mano un +`actas-hbox.service` viejo (sin `prueba-ethernet-serie`) que el repo no tenía. + +**Qué cambia.** + +- **Página de entrada propia** (`docs/entrar.html`, `backend/acceso.py`): nginx pregunta a `/api/_auth` en cada petición + (`auth_request`); sesión = cookie firmada de 30 días; se entra con el usuario o con el correo de Equipo, sin importar + mayúsculas ni espacios; cambiar la propia contraseña no saca a nadie; restablecer cierra las sesiones de esa persona; + cada intento queda en `/srv/projects/drogheda/acceso/intentos-AAAA-MM.jsonl` (sin la contraseña) y se ve en + Incidencias (administradores). Las contraseñas siguen en el mismo htpasswd: Mi cuenta, Equipo y `cuentas.py` no cambian. + **`curl -u` ya no entra** (revisión de seguridad: por ahí se podían probar contraseñas sin límite). Los scripts del + servidor (vigía, `tablero.py`, el agente) hablan con `127.0.0.1:8081` directo; para probar desde tu terminal: + `read -s P; curl -c /tmp/j -H 'Content-Type: application/json' -d "{\"usuario\":\"leo\",\"password\":\"$P\"}" https://drogheda.helgen.online/api/entrar && curl -b /tmp/j https://drogheda.helgen.online/api/whoami`. + Revisión de seguridad independiente antes de publicar (27-oct): además, POST/PATCH/DELETE solo desde páginas del + sitio, bloqueo por usuario + IP (nadie deja fuera a otro), redirección después de entrar solo dentro del sitio, + avisos públicos solo con su texto público, y lo tecleado como usuario se guarda enmascarado. +- **nginx** (`backend/nginx/drogheda.helgen.online.conf`, lo instala `backend/nginx/cambiar-entrada.sh` con respaldo, + `nginx -t`, 14 pruebas —incluida una sesión de prueba firmada con el secreto del servicio, sin contraseña— y regreso automático; revisa antes que todas las contraseñas sean `$apr1$`): sin sesión → `/entrar.html`; las API → 401 JSON; públicos solo la entrada, + `/publico/`, las fuentes, `helgen.css`, `tema.js` y el ícono; `X-Remote-User` lo pone nginx desde la sesión. +- **Servicio** (`backend/actas-hbox.service`): `ACTAS_ADMINS=leo,gibranlp` (como lo dejó Gibran), la lista completa de + actas repetibles, gunicorn con hilos (`-k gthread -w 2 --threads 8`: cada página pasa por `/api/_auth`) y `Restart=always`. +- **Incidencias** (`backend/incidencias.py`, `docs/js/incidencias.js`, `docs/tableros/incidencias.md`): «Problemas del + sitio» al pie de cada página, reporte sin sesión desde la entrada, avisos públicos, errores del navegador y del servidor + en `/srv/projects/drogheda/incidencias/_errores/`. +- **Standup** (`backend/standup.py`, `docs/tableros/standup.md`, `docs/js/standup.js`) y **vigía** (`backend/vigia.py`, + crontab de leo, minuto 7 de cada hora). Reglas: `docs/agentes.md`; instrucciones del agente: `agentes/agente-diario.md`. +- **Correos de Equipo**: `backend/cuentas/2026-10-27_correos.json` (12 correos; solo llena vacíos). + +**Un comando** (en la Mac; pide sudo una vez y dos «si»): + +```bash +cd ~/Documents/Claude/Projects/Drogheda/hbox-docs && ./scripts/despliegue-1027.sh +``` + +Lo mismo a mano: `sync-docs.sh` → commit/push de hbox-docs y del submódulo → `./scripts/deploy.sh` → +`ssh -t leo@git.helgen.online 'sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh servicio && sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh aplicar'` +→ `cuentas.py alta cuentas/2026-10-27_correos.json` → crontab del vigía + `semilla_1027.py` → `tablero.sh revisar` / `aplicar`. + +**Regresar a la ventana gris** (si algo no te gusta; el servicio nuevo puede quedarse): +`ssh -t leo@git.helgen.online 'sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh deshacer'`. + +**Confirmar la causa del 26-oct** (opcional, lee el log de nginx de antes del cambio): +`ssh -t leo@git.helgen.online "sudo grep -hE 'password mismatch|was not found' /var/log/nginx/error.log /var/log/nginx/error.log.1 | tail -40"` +— «password mismatch» = contraseña vieja o mal escrita; «was not found» = usuario mal escrito (p. ej. con mayúscula). + +| Síntoma | Causa | Arreglo | +|---|---|---| +| Sale «Un momento…» en todas las páginas | el backend no contesta (sin él no hay sesión) | `ssh -t leo@git.helgen.online 'sudo systemctl status actas-hbox'`; `journalctl -u actas-hbox -n 50` | +| La entrada dice «No encuentro ese usuario» | escribió otro usuario; el registro de intentos lo dice | Incidencias → «Intentos de entrada»; su usuario está en Equipo | +| Alguien entra pero las API dicen 401 | cookie de otro dominio o navegador sin cookies | que entre de nuevo en `/entrar.html`; si sigue, incidencia con datos técnicos | +| `cambiar-entrada.sh aplicar` dice que el backend no tiene la entrada | falta el paso `servicio` (reiniciar con los módulos nuevos) | correr `servicio` primero | +| Quiero cerrar la sesión de todos | — | `sudo rm /srv/projects/drogheda/.sesion-secreto && sudo systemctl restart actas-hbox` | + ## B · Verificación después de desplegar ```bash # 1. el backend responde y sabe quién eres -curl -s -u leo:PASS https://drogheda.helgen.online/api/whoami # {"user":"leo","admin":true} -curl -s -u alan:PASS https://drogheda.helgen.online/api/whoami # {"user":"alan","admin":false} +curl -s -H 'X-Remote-User: leo' http://127.0.0.1:8081/api/whoami # en el servidor (desde el 27-oct curl -u ya no entra por fuera) +curl -s -H 'X-Remote-User: alan' http://127.0.0.1:8081/api/whoami # {"user":"alan","admin":false} ``` Si `user` sale vacío, falta la línea `X-Remote-User` del paso 4. diff --git a/agentes/agente-diario.md b/agentes/agente-diario.md new file mode 100644 index 0000000..d698a0f --- /dev/null +++ b/agentes/agente-diario.md @@ -0,0 +1,110 @@ +# Daily agent — runbook (Claude-facing) + +Created 27-oct-2026. The team-facing rules are `docs/agentes.md` (published at /agentes/). **When the two disagree, +the stricter one wins; change both in the same commit.** Everything the team reads is Mexican Spanish. + +You run as a scheduled task on Leo's Mac (Monday–Friday, 18:37 Mexico City). You reach the Mac through the device +bridge: `device_bash` for files in the mounted `Drogheda` folder (read and write; **never git there**), and the +desktop-commander `start_process` tool for native macOS commands (ssh to the server, git in your own clone). If the +computer or the server cannot be reached, stop and say so in the summary — do not improvise another path. + +Paths: `D=~/Documents/Claude/Projects/Drogheda`, `H=$D/hbox-docs` (Leo's working copy — read it, write lotes and +notes there, never git), `A=~/Documents/Claude/Projects/_agentes/hbox-docs` (your own clone for code fixes, outside the +Drogheda repo; create it with `git clone ssh://git@git.helgen.online:222/Helgen-Services/hbox-docs.git` if missing, and +`pip install mkdocs-material` in a venv there if `mkdocs` is not on the Mac). + +API: `$H/scripts/agente-api.sh METHOD PATH [json-file]` (ssh + curl on 127.0.0.1:8081 as `claude`). Server files you +may read over ssh as `leo`: `/srv/projects/drogheda/{incidencias,acceso,standup,tareas,equipo}/…`. You never use sudo. + +## 0. Preflight (5 minutes) + +1. Read `$H/CLAUDE.md`, `$H/docs/agentes.md`, this file, and `$D/CLAUDE.md` §★ and §0. +2. Fresh board: `cd $H && ./scripts/tablero.sh foto` (read-only), then read `$H/_estado/tablero.md`. +3. Today's vigía report: `ssh leo@git.helgen.online cat /srv/projects/drogheda/incidencias/_vigia/$(date +%F).json`. +4. Note what another session is doing: `git -C $H status --short` **via start_process** (native, read-only) — if the + tree is dirty, someone is mid-task: do not touch the files they changed. + +## 1. Incidents + +Inputs: `GET /api/incidencias?estado=abiertas`, each one with `GET /api/incidencia?id=…`, +`GET /api/incidencias/errores?dias=2`, the vigía report, and the last 2 days of +`/srv/projects/drogheda/acceso/intentos-AAAA-MM.jsonl` (never print passwords — they are never stored anyway). + +For every open incident, in this order (gravedad alta first): + +1. **Triage** — `PATCH /api/incidencia?id=` with `tipo`, `gravedad`, `asignado` (`claude` for site software; + `gibranlp` for htracker/monitor/server; `leo` for decisions, accounts and anything physical), `estado: en-revision`, + and a comment in Mexican Spanish: what you understood, what you will do, and — if information is missing — the exact + question for the reporter. Duplicates: `duplicado_de` + `estado: descartada` + a comment linking the original. +2. **Reproduce** in a local copy (the cloud container is fine: build the site from `$A`, run the backend with temp data + like `backend/tests-1027.sh` does). Write the cause in a comment. If you cannot reproduce, say what you tried. +3. **Fix** following the levels below. Comment what changed and how to undo it. +4. **Close** — an incident fixed and published yesterday (`estado: esperando`) whose firma / page shows no new errors in + the vigía report and the error logs for 24 h → `estado: resuelta` with a one-line comment. Access incidents: only when + the person has an `ok` in the intentos log after the fix. +5. **Public notice** — if a failure keeps several people from logging in, set `publica: true` with a short + `resumen_publico` (no internal names, no data) and remove it when solved. + +### Level A — do it yourself (no authorization) + +Only changes inside `docs/` of hbox-docs that are pages, text, links, CSS or the JavaScript of the boards +(`docs/tableros/*.js|css|md`, `docs/*.md`, `docs/js/sitio.js`, `docs/js/standup.js`). **Not** level A: `docs/entrar.html`, +`docs/publico/`, `docs/cuenta.html`, `docs/salir.html`, `docs/js/incidencias.js`, `docs/formularios/`, `backend/`, +`overrides/`, `hooks/`, `mkdocs.yml` nav changes, anything on the server. + +1. In `$A`: `git checkout main && git pull --ff-only`. Make the change. Commit message: + `agente: INC-xxxx (Claude, agente diario)` + the attribution lines. +2. `mkdocs build --strict` must pass; run the backend tests that touch what you changed + (`bash backend/tests-1027.sh .` at least); check the page in light, dark and 390-px width with Playwright. +3. **Deploy-safety check:** `rsync -n -c -i --delete -e ssh site/ leo@git.helgen.online:/srv/projects/drogheda/hbox-docs/site/`. + Every listed file must be explained by your change (the changed pages, `search/search_index.json`, `sitemap.xml*`, and — + if you changed a CSS/JS file — the pages whose `?v=` hash moved). **Anything else means the server has work that is not + in main (Leo deployed something uncommitted): stop, do not deploy, push a branch instead (level B).** +4. Deploy the site only: `rsync -az --delete -e ssh site/ leo@git.helgen.online:/srv/projects/drogheda/hbox-docs/site/` + (never `deploy.sh`: it also copies the backend). Then `git push` main. +5. Leo's working copy: if `git -C $H status --porcelain` is empty and it is on `main`, `git -C $H pull --ff-only` + (native, via start_process). If it is dirty, do not touch it; tell Leo in the summary to `git pull --rebase` before his + next push. +6. Incident → `estado: esperando`, comment: «Publicado: … Lo cierro mañana si no se repite.» + +### Level B — prepare it, Leo approves + +Backend, nginx, systemd, accounts, passwords, permissions, data (actas, inventario, pendientes), the entry page, the field +forms, deleting anything, `sudo`, restarts. Work in `$A` on a branch `agente/AAAA-MM-DD-INC-xxxx`, with tests, push the +branch, and comment on the incident with the exact commands Leo runs (merge, deploy, restart). Board changes always go in +a **lote** (`$H/backend/lotes/AAAA-MM-DD_agente.json`, format in `backend/lotes/README.md`); Leo applies it. + +### Never + +Delete anything · read, store, ask for or reset passwords · change permissions · send messages or email in anyone's name +· put project data on the public entry page · touch the 4G/NTRIP parameter file · edit an applied lote · push to main +anything that failed a check. + +## 2. Standup bot + +1. **Read today's answers:** `GET /api/standup?fecha=$(date +%F)`. For each answer: + - Board changes the person already made with the checkbox are done — do not repeat them. + - `bloqueos` that need someone else → in the lote, a comment on that pendiente (`nota`) naming who unblocks it, or a + new pendiente for that person if none exists (`si_no_hay`). + - Things they finished that are not on the board → comment on the closest pendiente; do not invent tasks. + Write the lote to `$H/backend/lotes/AAAA-MM-DD_standup.json` (validate with `tablero.validar_cambio` before saving). +2. **Prepare the next working day** (Friday → Monday): one entry per team member with open pendientes (from the fresh + snapshot; skip `claude` and `vigia`). For each person: + - `instrucciones`: 2–5 concrete steps for that day, in order, each tied to an existing pendiente (`tarea_id`) or a + decision, with a `liga` to the document, form or board view (relative URL). Field technicians get the acta form to + fill (`/formularios/…html?serie=…&sitio=…`). Use what they answered today and the latest juntas/decisions. + - `preguntas`: one per open pendiente (max 6; overdue, en-curso and bloqueado first), each a specific question + («¿Ya llegó la cotización de gabinetes o sigue bloqueada por el pago?»), never generic. + - `nota_bot`: one line about yesterday (e.g. «Ayer cerraste 2; quedó bloqueado t-… esperando a Leo»). + Mexican Spanish, «tú», short sentences. Save as JSON and `POST /api/standup/preparar` with `fecha` = next working day. +3. People who did not answer for 2 working days in a row → mention them in the summary for Adriana (do not nag them). + +## 3. Close the run + +1. Summary to Leo (final message, Mexican Spanish, ≤ 25 lines): incidents opened/closed/fixed (with ids), what needs his + OK with the **exact commands**, standup (answered n of m, blockers and who unblocks them), the lote(s) to apply + (`./scripts/tablero.sh revisar` → `aplicar`). +2. Bitácora only if you published a fix: one line under today's date in `$H/docs/bitacora.md`… only through `$A` (level A + commit), never by editing `$H` directly. +3. If the run itself hit friction (a missing API, a permission), open an incident about it (`origen: agente`) so it is + fixed like any other. diff --git a/backend/acceso.py b/backend/acceso.py new file mode 100644 index 0000000..cf57caf --- /dev/null +++ b/backend/acceso.py @@ -0,0 +1,403 @@ +"""Entrada al sitio con página propia (27-oct-2026). Reemplaza la ventana gris de HTTP Basic de nginx. + +Por qué (INC-0001, reporte de Adriana del 26-oct: «después de restablecer la contraseña ya no puedo entrar»): + con HTTP Basic el navegador guarda la contraseña anterior y la vuelve a mandar sola; el gestor de contraseñas del + teléfono llena la vieja en la ventana gris porque Mi cuenta no tenía campo de usuario; no hay forma real de cerrar + sesión; un usuario con mayúscula inicial («Adriana») no existe para nginx; y el servidor no sabía quién falló ni + por qué. Ahora la entrada es una página nuestra, con usuario (o correo) y contraseña, que dice qué pasó y deja + cada intento registrado (sin la contraseña) para el vigía y el agente diario. + +Cómo funciona: + nginx pregunta a /api/_auth en cada petición (auth_request), también por páginas y archivos. Responde 204 con + X-Hbox-User si hay sesión válida (cookie firmada «hbox_sesion») y 401 si no. Sin sesión, nginx manda a + /entrar.html (las API reciben 401 en JSON). HTTP Basic ya no entra, salvo desde las IP de ACCESO_BASIC_IPS (vacío + por omisión): los scripts del servidor (vigía, tablero.py, el agente) hablan con 127.0.0.1:8081 directo. + Revisión de seguridad del 27-oct: sin Basic abierto (fuerza bruta sin límite), redirección solo a este sitio, + bloqueo por usuario + IP (nadie bloquea a otro), POST/PATCH/DELETE solo desde este sitio (Origin/Sec-Fetch-Site), + cualquier error al revisar la sesión = 401, secreto creado de forma atómica. + Las contraseñas siguen en el mismo htpasswd ($apr1$): Mi cuenta, Equipo y cuentas.py no cambian. + +Rutas (las de «sin sesión» las deja pasar nginx sin auth_request y SIN X-Remote-User): + GET /api/_auth solo nginx (la ruta pública /api/_auth se bloquea en nginx) + POST /api/entrar {usuario, password, recordar} sin sesión. usuario = el del sitio o su correo de Equipo + POST /api/salir sin sesión: borra la cookie + GET /api/sesion sin sesión: {sesion, user} (la página de entrada salta si ya hay sesión) + GET /api/accesos/intentos?dias=7 administradores: intentos por usuario (sin contraseñas) + +La cookie lleva usuario ~ vence ~ versión ~ firma (HMAC-SHA256). versión = huella del hash del htpasswd de esa +persona: si su contraseña cambia (restablecimiento, Mi cuenta o cuentas.py) sus sesiones viejas dejan de valer y +la próxima vez ve la página de entrada. Quien cambia la suya en Mi cuenta recibe una cookie nueva en ese momento. +Secreto: SESION_SECRETO (entorno) o DATOS_BASE/.sesion-secreto (se crea solo, modo 600). Cambiarlo cierra todas +las sesiones. + +Intentos: DATOS_BASE/acceso/intentos-AAAA-MM.jsonl — cuándo, a quién corresponde (o, si el usuario no existe, solo +sus 3 primeras letras y su largo: a veces se teclea la contraseña en el campo del usuario), resultado (ok · +usuario-no-existe · contrasena-incorrecta · sin-datos), pistas (bloq-mayus, mayuscula-inicial, espacios-alrededor: se +calculan al fallar, nunca se le dicen a la persona), IP y navegador. Los intentos bloqueados no se anotan. +Límite: 8 fallas de un mismo usuario desde una misma IP, o 20 fallas de una IP, en 15 minutos → espera (además del +limit_req de nginx). Así nadie puede dejar fuera a otra persona tecleando mal su usuario a propósito. +""" +import base64 +import datetime +import hashlib +import hmac +import json +import os +import re +import time +import unicodedata + +from flask import jsonify, request + +COOKIE = "hbox_sesion" +DIAS_SESION = int(os.environ.get("SESION_DIAS", "30")) +DIAS_RENOVAR = 10 # si a la sesión le quedan menos días, /api/whoami la renueva +VENTANA_S = 15 * 60 +MAX_FALLAS_USUARIO = int(os.environ.get("ENTRAR_MAX_FALLAS_USUARIO", "8")) # por usuario Y por IP +BASIC_IPS = {x.strip() for x in os.environ.get("ACCESO_BASIC_IPS", "").split(",") if x.strip()} +MAX_FALLAS_IP = int(os.environ.get("ENTRAR_MAX_FALLAS_IP", "20")) +COOKIE_INSEGURA = os.environ.get("SESION_COOKIE_INSEGURA", "") == "1" # solo pruebas locales por http +SEP = "~" + + +def _plano(s): + """' Adriana ' → 'adriana'; 'Jesús' → 'jesus'. Sin acentos ni espacios.""" + s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower() + return re.sub(r"\s+", "", s) + + +def registrar(app, g): + """g = globals() de app.py (HTPASSWD, apr1, _leer_htpasswd, DATOS_BASE, EQUIPO_DIR, ADMINS…).""" + cache = {"clave": None, "usuarios": {}} + carpeta = os.path.join(g["DATOS_BASE"], "acceso") + + # ------------------------------------------------------------ secreto de las cookies + def _secreto(): + s = os.environ.get("SESION_SECRETO", "").strip() + if s: + return s.encode() + ruta = os.path.join(g["DATOS_BASE"], ".sesion-secreto") + + def leer(): + try: + with open(ruta, encoding="utf-8") as f: + return f.read().strip() + except OSError: + return "" + + actual = leer() + if len(actual) < 32: # no existe, o quedó vacío/corto: se crea completo y se publica de un golpe + tmp = ruta + ".tmp-" + os.urandom(4).hex() + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(base64.urlsafe_b64encode(os.urandom(32)).decode()) + try: + if actual: # corto o vacío: se reemplaza + os.replace(tmp, ruta) + else: + os.link(tmp, ruta) # atómico; si otro proceso ganó, se usa el suyo + except FileExistsError: + pass + finally: + if os.path.exists(tmp): + os.unlink(tmp) + actual = leer() + if len(actual) < 32: + raise RuntimeError("No pude crear el secreto de sesión en " + ruta) + return actual.encode() + + SECRETO = {"v": None} + + def secreto(): + if SECRETO["v"] is None: + SECRETO["v"] = _secreto() + return SECRETO["v"] + + # ------------------------------------------------------------ usuarios del htpasswd + def usuarios(): + """{usuario en minúsculas: (usuario tal cual, hash)}; se relee solo si el archivo cambió.""" + try: + st = os.stat(g["HTPASSWD"]) + except OSError: + return {} + clave = (st.st_mtime_ns, st.st_size, st.st_ino) + if cache["clave"] != clave: + d = {} + for linea in g["_leer_htpasswd"]() or []: + if ":" in linea and not linea.lstrip().startswith("#"): + u, h = linea.split(":", 1) + d[u.strip().lower()] = (u.strip(), h.strip()) + cache.update(clave=clave, usuarios=d) + return cache["usuarios"] + + def verificar(h, password): + if not password: + return False + if h.startswith("$apr1$"): + partes = h.split("$") + return len(partes) >= 4 and hmac.compare_digest(g["apr1"](password, partes[2]), h) + if h.startswith("{SHA}"): + calc = "{SHA}" + base64.b64encode(hashlib.sha1(password.encode("utf-8")).digest()).decode() + return hmac.compare_digest(calc, h) + if h.startswith(("$2y$", "$2b$", "$2a$")): # htpasswd -B (bcrypt), si alguien lo usó a mano + try: + import bcrypt # noqa: PLC0415 + return bcrypt.checkpw(password.encode("utf-8"), h.replace("$2y$", "$2b$", 1).encode()) + except Exception: # noqa: BLE001 + return False + return False + + def version(h): + return hashlib.sha256(h.encode()).hexdigest()[:12] + + def por_correo(texto): + """Correo de Equipo (correo o correo_alt) → usuario. None si no hay o si dos personas lo comparten.""" + t = str(texto or "").strip().lower() + if "@" not in t: + return None + hallados = {m["id"] for m in g["_listar_json"](g["EQUIPO_DIR"]) + if m.get("id") and m.get("activo", True) is not False + and t in (str(m.get("correo") or "").strip().lower(), str(m.get("correo_alt") or "").strip().lower())} + return hallados.pop() if len(hallados) == 1 else None + + # ------------------------------------------------------------ cookie firmada + def firmar(u, vence, ver): + return hmac.new(secreto(), f"{u}|{vence}|{ver}".encode(), hashlib.sha256).hexdigest()[:40] + + def emitir(resp, u, dias=DIAS_SESION): + reg = usuarios().get(u.lower()) + if not reg: + return resp + vence = int(time.time()) + dias * 86400 + ver = version(reg[1]) + valor = SEP.join((reg[0], str(vence), ver, firmar(reg[0], vence, ver))) + resp.set_cookie(COOKIE, valor, max_age=dias * 86400, path="/", httponly=True, + secure=not COOKIE_INSEGURA, samesite="Lax") + return resp + + def leer_cookie(): + """(usuario, vence) si la cookie es válida hoy; (None, 0) si no.""" + v = request.cookies.get(COOKIE) or "" + partes = v.split(SEP) + if len(partes) != 4: + return None, 0 + u, vence, ver, firma = partes + try: + vence_i = int(vence) + except ValueError: + return None, 0 + b = lambda x: str(x).encode("utf-8", "replace") + if vence_i < time.time() or not hmac.compare_digest(b(firma), b(firmar(u, vence_i, ver))): + return None, 0 + reg = usuarios().get(u.lower()) + if not reg or not hmac.compare_digest(b(version(reg[1])), b(ver)): # contraseña cambiada o acceso quitado + return None, 0 + return reg[0], vence_i + + def leer_basic(): + """Authorization: Basic solo desde las IP de ACCESO_BASIC_IPS (vacío por omisión = nunca). Sin esto, cualquiera + podría probar contraseñas sin límite por /api/_auth. Los scripts del servidor usan 127.0.0.1:8081 directo.""" + if not BASIC_IPS or ip_cliente() not in BASIC_IPS: + return None + h = request.headers.get("Authorization", "") + if not h.lower().startswith("basic "): + return None + if (request.headers.get("User-Agent") or "").startswith("Mozilla/"): + return None + try: + crudo = base64.b64decode(h[6:].strip()).decode("utf-8") + except Exception: # noqa: BLE001 + try: + crudo = base64.b64decode(h[6:].strip()).decode("latin-1") + except Exception: # noqa: BLE001 + return None + u, _, pw = crudo.partition(":") + reg = usuarios().get(u.strip().lower()) + return reg[0] if reg and verificar(reg[1], pw) else None + + # ------------------------------------------------------------ registro de intentos + def ip_cliente(): + return (request.headers.get("X-Real-IP") or (request.headers.get("X-Forwarded-For") or "").split(",")[0] + or request.remote_addr or "").strip()[:60] + + def ruta_intentos(cuando=None): + cuando = cuando or datetime.datetime.now() + return os.path.join(carpeta, f"intentos-{cuando:%Y-%m}.jsonl") + + def clave_de(usuario, tecleado): + """Usuario real (minúsculas) o, si no existe, una huella de lo tecleado: cuenta los intentos sin guardar el texto.""" + return (usuario or "").lower() or ("?" + hashlib.sha1(_plano(tecleado).encode()).hexdigest()[:10]) + + def enmascarar(tecleado): + t = _plano(tecleado) + return (t[:3] + "…(" + str(len(t)) + ")") if t else "" + + def anotar(tecleado, usuario, resultado, pistas=()): + os.makedirs(carpeta, exist_ok=True) + r = {"cuando": g["ahora"](), "tecleado": "" if usuario else enmascarar(tecleado), "usuario": usuario or "", + "clave": clave_de(usuario, tecleado), "resultado": resultado, "pistas": list(pistas), "ip": ip_cliente(), + "navegador": (request.headers.get("User-Agent") or "")[:160]} + with open(ruta_intentos(), "a", encoding="utf-8") as f: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + + def intentos_desde(desde_iso, max_bytes=400_000): + """Intentos con cuando >= desde_iso, de este mes y del anterior (lee el final de cada archivo).""" + hoy = datetime.date.today() + mes_ant = (hoy.replace(day=1) - datetime.timedelta(days=1)) + out = [] + for d in (mes_ant, hoy): + ruta = ruta_intentos(datetime.datetime(d.year, d.month, 1)) + if not os.path.isfile(ruta): + continue + with open(ruta, "rb") as f: + f.seek(0, 2) + tam = f.tell() + f.seek(max(0, tam - max_bytes)) + texto = f.read().decode("utf-8", "ignore") + for linea in texto.splitlines(): + try: + r = json.loads(linea) + except Exception: # noqa: BLE001 + continue + if str(r.get("cuando", "")) >= desde_iso: + out.append(r) + return out + + def bloqueado(clave, ip): + """Por usuario Y por IP (8) y por IP (20) en 15 min. No hay bloqueo global por usuario: si lo hubiera, cualquiera + podría dejar fuera a una persona tecleando mal su usuario a propósito.""" + desde = (datetime.datetime.now() - datetime.timedelta(seconds=VENTANA_S)).replace(microsecond=0).isoformat() + fallas = [r for r in intentos_desde(desde, 2_000_000) if r.get("resultado") not in ("ok", "bloqueado")] + n_ui = sum(1 for r in fallas if r.get("ip") == ip and r.get("clave") == clave) + n_ip = sum(1 for r in fallas if ip and r.get("ip") == ip) + return n_ui >= MAX_FALLAS_USUARIO or n_ip >= MAX_FALLAS_IP + + # ------------------------------------------------------------ rutas + @app.get("/api/_auth") + def _auth(): + try: + u, _ = leer_cookie() + u = u or leer_basic() + except Exception as e: # noqa: BLE001 (cookie rara, htpasswd ilegible…): mejor la página de entrada que «caído» + app.logger.warning("acceso: /api/_auth falló, respondo 401: %s", e) + u = None + if not u: + return ("", 401) # sin WWW-Authenticate: nginx manda a /entrar.html + r = app.response_class(status=204) + r.headers["X-Hbox-User"] = u + return r + + @app.get("/api/sesion") + def sesion(): + u, vence = leer_cookie() + return jsonify(ok=True, sesion=bool(u), user=u or "", + vence=datetime.datetime.fromtimestamp(vence).replace(microsecond=0).isoformat() if u else None) + + @app.post("/api/entrar") + def entrar(): + d = request.get_json(silent=True) or request.form or {} + tecleado = str(d.get("usuario") or "") + password = str(d.get("password") or "") + recordar = g["_bool"](d.get("recordar"), True) + u = _plano(tecleado) + if "@" in u: + u = por_correo(u) or u + ip = ip_cliente() + if not u or not password: + anotar(tecleado, "", "sin-datos") + return jsonify(ok=False, error="Escribe tu usuario y tu contraseña."), 400 + reg0 = usuarios().get(u) + if bloqueado(clave_de(reg0[0] if reg0 else "", tecleado), ip): + return jsonify(ok=False, codigo="bloqueado", + error="Demasiados intentos seguidos. Espera 15 minutos y vuelve a intentar, " + "o repórtalo abajo para que te ayudemos."), 429 + reg = usuarios().get(u) + if not reg: + anotar(tecleado, "", "usuario-no-existe") + return jsonify(ok=False, codigo="usuario", + error="No encuentro ese usuario. Es tu nombre en minúsculas y sin acentos, como te lo " + "dieron (por ejemplo «adriana» o «jesush»), o tu correo de Helgen."), 401 + if not verificar(reg[1], password): + pistas = [] + if password != password.strip() and verificar(reg[1], password.strip()): + pistas.append("espacios-alrededor") + if verificar(reg[1], password.swapcase()): + pistas.append("bloq-mayus") + if password[:1].isupper() and verificar(reg[1], password[:1].lower() + password[1:]): + pistas.append("mayuscula-inicial") + anotar(tecleado, reg[0], "contrasena-incorrecta", pistas) + return jsonify(ok=False, codigo="contrasena", + error="La contraseña no coincide. Si te la restablecieron, usa la nueva tal como te la " + "pasaron (respeta mayúsculas); si tu navegador la llena solo, bórrala y escríbela."), 401 + anotar(tecleado, reg[0], "ok") + resp = jsonify(ok=True, user=reg[0]) + return emitir(resp, reg[0], DIAS_SESION if recordar else 1) + + @app.post("/api/salir") + def salir(): + resp = jsonify(ok=True) + resp.set_cookie(COOKIE, "", max_age=0, expires=0, path="/", httponly=True, + secure=not COOKIE_INSEGURA, samesite="Lax") + return resp + + @app.get("/api/accesos/intentos") + def accesos_intentos(): + if not g["is_admin"](): + return jsonify(ok=False, error="Solo un administrador puede ver los intentos de entrada."), 403 + dias = max(1, min(60, g["_entero"](request.args.get("dias"), 7))) + desde = (datetime.datetime.now() - datetime.timedelta(days=dias)).replace(microsecond=0).isoformat() + filas = intentos_desde(desde, 2_000_000) + por = {} + for r in filas: + clave = r.get("usuario") or ("? " + (r.get("tecleado") or r.get("clave") or "?")) + p = por.setdefault(clave, {"ok": 0, "fallas": 0, "ultimo_ok": None, "ultima_falla": None, "motivos": {}, + "pistas": {}}) + if r.get("resultado") == "ok": + p["ok"] += 1 + p["ultimo_ok"] = r["cuando"] + else: + p["fallas"] += 1 + p["ultima_falla"] = r["cuando"] + p["motivos"][r.get("resultado")] = p["motivos"].get(r.get("resultado"), 0) + 1 + for x in r.get("pistas") or []: + p["pistas"][x] = p["pistas"].get(x, 0) + 1 + return jsonify(ok=True, desde=desde, total=len(filas), por_usuario=por, + ultimos=list(reversed(filas[-50:]))) + + # ------------------------------------------------------------ solo desde este sitio (contra CSRF) + INSEGUROS = {"POST", "PUT", "PATCH", "DELETE"} + + @app.before_request + def _mismo_sitio(): + """Un POST/PATCH/DELETE a la API tiene que venir de una página de este sitio. Los navegadores mandan + Sec-Fetch-Site y Origin; los scripts del servidor (curl a 127.0.0.1:8081) no mandan ninguno y pasan.""" + if request.method not in INSEGUROS or not request.path.startswith("/api/"): + return None + sfs = (request.headers.get("Sec-Fetch-Site") or "").lower() + if sfs and sfs not in ("same-origin", "none"): + return jsonify(ok=False, error="Petición desde otro sitio: no se acepta."), 403 + origen = request.headers.get("Origin") + if origen: + from urllib.parse import urlsplit # noqa: PLC0415 + if (urlsplit(origen).hostname or "").lower() != (request.host or "").split(":")[0].lower(): + return jsonify(ok=False, error="Petición desde otro sitio: no se acepta."), 403 + return None + + # ------------------------------------------------------------ después de cada respuesta + @app.after_request + def _despues(resp): + try: + if request.path == "/api/password" and request.method == "POST" and resp.status_code == 200: + j = resp.get_json(silent=True) or {} + yo = g["current_user"]() + if j.get("ok") and not j.get("reset") and yo and str(j.get("usuario", "")).lower() == yo.lower(): + emitir(resp, yo) # cambió la suya: sesión nueva con la versión nueva, no lo saca + elif request.path == "/api/whoami" and resp.status_code == 200: + u, vence = leer_cookie() + if u and vence - time.time() < DIAS_RENOVAR * 86400: + emitir(resp, u) + except Exception as e: # noqa: BLE001 + app.logger.warning("acceso: no se pudo renovar la sesión: %s", e) + return resp + + g["_acceso"] = {"usuarios": usuarios, "verificar": verificar, "intentos_desde": intentos_desde, "plano": _plano} diff --git a/backend/actas-hbox.service b/backend/actas-hbox.service index b16c23d..fa1071a 100644 --- a/backend/actas-hbox.service +++ b/backend/actas-hbox.service @@ -11,7 +11,8 @@ Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos Environment=ADJUNTO_MAX_MB=8 Environment=ARCHIVO_MAX_MB=20 Environment=ACTAS_REPETIBLES=diagnostico-campo,diagnostico-banco,instalacion-campo,reflasheo,dano,envio,recepcion,prueba-bateria-modem,prueba-ethernet-serie -Environment=ACTAS_ADMINS=leo +# 27-oct: Gibran es administrador (así quedó el servicio que se instaló a mano ese día a las 9:15). +Environment=ACTAS_ADMINS=leo,gibranlp # Colecciones del 13-oct (tableros, inventario, sitios). Si no se fijan, el backend las deduce # de ACTAS_DIR: viven junto a /actas. Descomenta solo para moverlas de lugar. #Environment=TAREAS_DIR=/srv/projects/drogheda/tareas @@ -28,8 +29,16 @@ Environment=ACTAS_ADMINS=leo Environment=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd Environment=PASS_MIN=8 Environment=PORT=8081 -ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -w 2 -b 127.0.0.1:8081 app:app -Restart=on-failure +# Página de entrada (27-oct, backend/acceso.py): nginx pregunta a /api/_auth en CADA petición (páginas y archivos). +# Por eso hilos: una subida de fotos de 2 minutos no debe dejar esperando a los demás. Secreto de sesión: si no se fija +# aquí, el servicio crea /srv/projects/drogheda/.sesion-secreto (600) la primera vez. Cambiarlo cierra todas las sesiones. +#Environment=SESION_DIAS=30 +#Environment=SESION_SECRETO= +# Quién atiende incidencias (cambia estado, gravedad, «mostrar en la entrada») y quién ve el standup del equipo. +#Environment=INCIDENCIAS_EDITORES=leo,gibranlp,adriana,claude +#Environment=STANDUP_LECTORES=leo,adriana,claude,gibranlp +ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -k gthread -w 2 --threads 8 -b 127.0.0.1:8081 app:app +Restart=always RestartSec=3 [Install] diff --git a/backend/app.py b/backend/app.py index 87969f9..086fbab 100644 --- a/backend/app.py +++ b/backend/app.py @@ -570,6 +570,8 @@ def cambiar_password(): return jsonify(ok=False, error=f"La nueva contraseña debe tener al menos {PASS_MIN} caracteres."), 400 if nueva != confirma: return jsonify(ok=False, error="Las dos contraseñas nuevas no coinciden."), 400 + if nueva != nueva.strip(): # 27-oct: el teclado del teléfono a veces agrega un espacio al final + return jsonify(ok=False, error="La contraseña no puede empezar ni terminar con un espacio."), 400 if not es_reset and nueva == actual: return jsonify(ok=False, error="La nueva contraseña es igual a la actual."), 400 @@ -790,7 +792,12 @@ SITIOS_TAREA = (("icave", "ICAVE · Veracruz", "ICAVE"), ("timsa", "TIMSA · Man SITIOS_TAREA_IDS = tuple(s[0] for s in SITIOS_TAREA) SITIO_OMISION = ["qro"] # pendientes sin sitio (los de antes del 22-oct) cuentan como Querétaro ALIAS_SITIO = {"lct": "lzc", "lazarocardenas": "lzc", "lazaro-cardenas": "lzc", "veracruz": "icave", - "manzanillo": "timsa", "ensenada": "eit", "queretaro": "qro", "oficina": "qro"} + "manzanillo": "timsa", "ensenada": "eit", "queretaro": "qro", "oficina": "qro", + # (27-oct) código de 3 letras de cada ciudad, el de su aeropuerto y su puerto (UN/LOCODE MX___): + # lo usan las órdenes de compra del cotizador. VER Veracruz · ZLO Manzanillo · ESE Ensenada · + # LZC Lázaro Cárdenas · QRO Querétaro. + "ver": "icave", "zlo": "timsa", "ese": "eit"} +CODIGO_SITIO = {"icave": "VER", "timsa": "ZLO", "eit": "ESE", "lzc": "LZC", "qro": "QRO"} AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion", "software", "campo", "asistente") ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho") @@ -1162,6 +1169,8 @@ def usuarios_crear(): password = str(d.get("password_inicial") or "") if len(password) < PASS_MIN: return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400 + if password != password.strip(): + return jsonify(ok=False, error="La contraseña no puede empezar ni terminar con un espacio."), 400 m_in = {"nombre": d.get("nombre") or usuario, "rol": d.get("rol", ""), "area": d.get("area", "produccion"), "sitio": d.get("sitio", ""), "correo": d.get("correo", ""), "correo_alt": d.get("correo_alt", ""), "telefono": d.get("telefono", ""), "activo": True} @@ -2310,14 +2319,18 @@ def actividad(): # ------------------------------------------------------------------ tablero de Instalaciones (22-oct) # backend/instalaciones.py: GET /api/instalaciones y el acta repetible «prueba-sitio». Si el archivo # falta o falla, el resto del servicio arranca igual (el tablero avisa que no hay datos). -try: - import importlib.util as _ilu - _spec = _ilu.spec_from_file_location("instalaciones", os.path.join(os.path.dirname(os.path.abspath(__file__)), "instalaciones.py")) - _inst = _ilu.module_from_spec(_spec) - _spec.loader.exec_module(_inst) - _inst.registrar(app, globals()) -except Exception as _e: # noqa: BLE001 - app.logger.error("instalaciones.py no se cargó: %s", _e) +# (27-oct) Igual se enganchan acceso.py (página de entrada y sesión: /api/_auth, /api/entrar, /api/salir), +# incidencias.py (problemas del sitio, errores del navegador y del servidor) y standup.py (el bot de scrum). +# Cada uno se carga aparte: si uno falla, los demás y el resto del servicio arrancan igual. +import importlib.util as _ilu +for _mod in ("instalaciones", "acceso", "incidencias", "standup"): + try: + _spec = _ilu.spec_from_file_location(_mod, os.path.join(os.path.dirname(os.path.abspath(__file__)), _mod + ".py")) + _m = _ilu.module_from_spec(_spec) + _spec.loader.exec_module(_m) + _m.registrar(app, globals()) + except Exception as _e: # noqa: BLE001 + app.logger.error("%s.py no se cargó: %s", _mod, _e) if __name__ == "__main__": app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081"))) diff --git a/backend/cuentas/2026-10-27_correos.json b/backend/cuentas/2026-10-27_correos.json new file mode 100644 index 0000000..50416dc --- /dev/null +++ b/backend/cuentas/2026-10-27_correos.json @@ -0,0 +1,54 @@ +{ + "_nota": "27-oct: correos de cada quien según la lista de cuentas activas que mandó Gibran (23-oct), confirmada por Leo: correo = @helgen.tech cuando existe; correo_alt = @helgen.services. daniel (Daniel Martínez) = daniel@helgen.tech, jesus (Jesús Romero) = jesusr@helgen.tech y jesush (Jesús Honorato) = jesus@helgen.tech ya estaban; danielf@ y jesusd@ son otras personas (sin acceso al sitio). Solo se llenan los vacíos (cuentas.py no pisa nada): el «Otro correo» de Leo (hoy ceo@helgen.tech, que es un alias) se cambia a mano en Equipo por leo@helgen.services. Correr: venv/bin/python3 cuentas.py alta cuentas/2026-10-27_correos.json", + "alta": [], + "actualizar": [ + { + "usuario": "abraham", + "correo": "abraham@helgen.services" + }, + { + "usuario": "adriana", + "correo_alt": "adriana@helgen.services" + }, + { + "usuario": "alan", + "correo_alt": "alan@helgen.services" + }, + { + "usuario": "antonio", + "correo": "antonio@helgen.tech" + }, + { + "usuario": "danielg", + "correo": "danielg@helgen.services" + }, + { + "usuario": "eduardo", + "correo": "eduardo@helgen.services" + }, + { + "usuario": "gibranlp", + "correo_alt": "gibranlp@helgen.services" + }, + { + "usuario": "joel", + "correo": "joel@helgen.tech" + }, + { + "usuario": "jose", + "correo": "jose@helgen.services" + }, + { + "usuario": "juan", + "correo": "juan@helgen.services" + }, + { + "usuario": "michelle", + "correo": "luz@helgen.services" + }, + { + "usuario": "miguel", + "correo": "miguel@helgen.tech" + } + ] +} diff --git a/backend/incidencias.py b/backend/incidencias.py new file mode 100644 index 0000000..7288fa1 --- /dev/null +++ b/backend/incidencias.py @@ -0,0 +1,404 @@ +"""Incidencias del sitio (27-oct-2026): lo que falla en drogheda.helgen.online, reportado desde cualquier página +(sección «Problemas del sitio» al pie), desde la página de entrada (sin sesión) o detectado solo (vigía). + +Lo engancha app.py al final con registrar(app, globals()), como instalaciones.py. + +Datos: DATOS_BASE/incidencias/INC-0001.json (un JSON por incidencia) + _indice.csv; folio con candado en _folio. + DATOS_BASE/incidencias/_errores/cliente-AAAA-MM-DD.jsonl errores de JavaScript y llamadas fallidas (navegador) + DATOS_BASE/incidencias/_errores/servidor-AAAA-MM-DD.jsonl excepciones y respuestas 5xx del backend + Nada se borra: una incidencia se resuelve o se descarta. + +Rutas con sesión: + GET /api/incidencias?estado=abiertas|cerradas|todas&tipo=&origen=&q= lista + conteos + GET /api/incidencias/resumen lo que muestra el pie de cada página (abiertas, mías, últimas 3) + GET /api/incidencia?id=INC-0001 + POST /api/incidencias {titulo?, descripcion, tipo, pagina, contexto, firma?} + (firma: quien detecta solo —vigia o claude— manda una huella; si ya hay una abierta con esa huella se suma + una ocurrencia en vez de abrir otra) + PATCH /api/incidencia?id= {comentario} cualquiera · {estado, gravedad, asignado, publica, + resumen_publico, duplicado_de, tarea_id, titulo, tipo} quien edita (INCIDENCIAS_EDITORES + administradores); + quien la reportó puede marcarla resuelta o reabrirla + POST /api/incidencias/error errores del navegador (los manda js/incidencias.js solo) + GET /api/incidencias/errores?dias=2 (editores) errores de cliente y servidor agrupados +Rutas sin sesión (nginx las deja pasar sin auth_request y SIN X-Remote-User): + POST /api/incidencias/publica {nombre, contacto, descripcion, tipo} desde /entrar.html + GET /api/incidencias/publicas avisos marcados «publica» (solo título público, estado y fecha) +""" +import datetime +import fcntl +import hashlib +import json +import os +import re +import traceback + +from flask import got_request_exception, jsonify, request + +TIPOS = ("acceso", "error", "datos", "lento", "sugerencia", "otro") +NOMBRE_TIPO = {"acceso": "No puedo entrar", "error": "Error o pantalla rota", "datos": "Datos incorrectos", + "lento": "Lento", "sugerencia": "Sugerencia", "otro": "Otro"} +ESTADOS = ("nueva", "en-revision", "en-curso", "esperando", "resuelta", "descartada") +ABIERTAS = ("nueva", "en-revision", "en-curso", "esperando") +GRAVEDADES = ("alta", "media", "baja") +ORIGENES = ("sitio", "entrada", "vigia", "agente") +SISTEMA = {"vigia", "claude"} # pueden abrir con origen vigia/agente y con firma +COLS = ["id", "titulo", "tipo", "estado", "gravedad", "origen", "reportado_por", "asignado", "pagina", "ocurrencias", + "publica", "tarea_id", "duplicado_de", "creado_en", "modificado_en", "resuelta_en"] +MAX_ERRORES_DIA = 3000 + + +def registrar(app, g): + carpeta = os.environ.get("INCIDENCIAS_DIR", os.path.join(g["DATOS_BASE"], "incidencias")) + carpeta_err = os.path.join(carpeta, "_errores") + editores = {u.strip().lower() for u in os.environ.get("INCIDENCIAS_EDITORES", "leo,gibranlp,adriana,claude").split(",") + if u.strip()} + texto, ahora, hist = g["_texto"], g["ahora"], g["_hist"] + + def yo(): + return g["current_user"]() + + def puede_editar(u): + return bool(u) and (u.lower() in editores or u.lower() in g["ADMINS"]) + + def ruta(iid): + return os.path.join(carpeta, iid + ".json") + + def todas(): + return [d for d in g["_listar_json"](carpeta) if str(d.get("id", "")).startswith("INC-")] + + def guardar(d): + g["_escribir_json"](ruta(d["id"]), d) + g["_reescribir_indice"](carpeta, todas(), COLS) + + def siguiente_folio(): + os.makedirs(carpeta, exist_ok=True) + with open(os.path.join(carpeta, "_folio"), "a+", encoding="utf-8") as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.seek(0) + try: + n = int((f.read() or "0").strip() or 0) + except ValueError: + n = 0 + existentes = [int(m.group(1)) for x in os.listdir(carpeta) if (m := re.match(r"INC-(\d+)\.json$", x))] + n = max([n] + existentes) + 1 + f.seek(0) + f.truncate() + f.write(str(n)) + f.flush() + fcntl.flock(f, fcntl.LOCK_UN) + return f"INC-{n:04d}" + + def contexto_limpio(c): + """El contexto técnico que manda la página: se guarda acotado (nada de cookies ni contraseñas).""" + if not isinstance(c, dict): + return {} + out = {} + for k in ("pantalla", "navegador", "idioma", "referer", "hora_local"): + if c.get(k): + out[k] = texto(c.get(k), 200) + for k in ("errores", "llamadas"): + v = c.get(k) + if isinstance(v, list): + out[k] = [{kk: texto(x.get(kk), 300) for kk in list(x)[:8]} for x in v[-10:] if isinstance(x, dict)] + return out + + def nueva(d, u, origen): + tipo = d.get("tipo") if d.get("tipo") in TIPOS else "otro" + desc = texto(d.get("descripcion"), 4000) + titulo = texto(d.get("titulo"), 140) or (desc.split("\n")[0][:120] if desc else NOMBRE_TIPO[tipo]) + r = {"id": siguiente_folio(), "titulo": titulo, "descripcion": desc, "tipo": tipo, "estado": "nueva", + "gravedad": d.get("gravedad") if d.get("gravedad") in GRAVEDADES else ("alta" if tipo == "acceso" else "media"), + "origen": origen, "reportado_por": u or "", "nombre": texto(d.get("nombre"), 80), + "contacto": texto(d.get("contacto"), 120), "asignado": "", "pagina": texto(d.get("pagina"), 300), + "navegador": texto(request.headers.get("User-Agent"), 200), "contexto": contexto_limpio(d.get("contexto")), + "firma": texto(d.get("firma"), 80), "ocurrencias": 1, "ultima_vez": ahora(), "evidencia": [], + "publica": False, "resumen_publico": "", "tarea_id": "", "duplicado_de": "", "comentarios": [], + "creado_por": u or "(sin sesión)", "creado_en": ahora(), "resuelta_en": None} + if isinstance(d.get("evidencia"), list): + r["evidencia"] = [texto(x, 600) for x in d["evidencia"][:20]] + hist(r, u or "(sin sesión)", f"reportada desde {origen}") + guardar(r) + return r + + def para_lista(r, completo=False): + if completo: + return r + keys = ("id", "titulo", "tipo", "estado", "gravedad", "origen", "reportado_por", "nombre", "asignado", "pagina", + "ocurrencias", "ultima_vez", "publica", "tarea_id", "duplicado_de", "creado_en", "modificado_en", + "resuelta_en") + out = {k: r.get(k) for k in keys} + out["comentarios"] = len(r.get("comentarios") or []) + out["ultimo_comentario"] = (r.get("comentarios") or [{}])[-1] if r.get("comentarios") else None + return out + + # ------------------------------------------------------------------ con sesión + @app.get("/api/incidencias") + def incidencias_lista(): + u = yo() + if not u: + return g["_sin_usuario"]() + est = (request.args.get("estado") or "abiertas").strip() + tipo = (request.args.get("tipo") or "").strip() + origen = (request.args.get("origen") or "").strip() + q = (request.args.get("q") or "").strip().lower() + regs = todas() + conteo = {"abiertas": sum(1 for r in regs if r.get("estado") in ABIERTAS), + "por_estado": {e: sum(1 for r in regs if r.get("estado") == e) for e in ESTADOS}, + "por_tipo": {t: sum(1 for r in regs if r.get("tipo") == t and r.get("estado") in ABIERTAS) for t in TIPOS}} + if est == "abiertas": + regs = [r for r in regs if r.get("estado") in ABIERTAS] + elif est == "cerradas": + regs = [r for r in regs if r.get("estado") not in ABIERTAS] + elif est in ESTADOS: + regs = [r for r in regs if r.get("estado") == est] + if tipo in TIPOS: + regs = [r for r in regs if r.get("tipo") == tipo] + if origen in ORIGENES: + regs = [r for r in regs if r.get("origen") == origen] + if q: + regs = [r for r in regs if q in (r.get("id", "") + " " + r.get("titulo", "") + " " + r.get("descripcion", "")).lower()] + abiertas = sorted([r for r in regs if r.get("estado") in ABIERTAS], + key=lambda r: r.get("modificado_en") or r.get("creado_en") or "", reverse=True) + abiertas.sort(key=lambda r: GRAVEDADES.index(r["gravedad"]) if r.get("gravedad") in GRAVEDADES else 1) # estable + cerradas = sorted([r for r in regs if r.get("estado") not in ABIERTAS], + key=lambda r: r.get("modificado_en") or "", reverse=True) + return jsonify(ok=True, incidencias=[para_lista(r) for r in abiertas + cerradas][:300], conteo=conteo, + tipos=[{"id": t, "nombre": NOMBRE_TIPO[t]} for t in TIPOS], estados=list(ESTADOS), + gravedades=list(GRAVEDADES), puede_editar=puede_editar(u), yo=u) + + @app.get("/api/incidencias/resumen") + def incidencias_resumen(): + u = yo() + if not u: + return g["_sin_usuario"]() + regs = [r for r in todas() if r.get("estado") in ABIERTAS] + regs.sort(key=lambda r: r.get("modificado_en") or r.get("creado_en") or "", reverse=True) + mias = [r for r in regs if (r.get("reportado_por") or "").lower() == u.lower()] + return jsonify(ok=True, abiertas=len(regs), mias=len(mias), + ultimas=[{k: r.get(k) for k in ("id", "titulo", "estado", "tipo")} for r in regs[:3]]) + + @app.get("/api/incidencia") + def incidencia_una(): + if not yo(): + return g["_sin_usuario"]() + r = g["_leer_json"](ruta(g["slug"](request.args.get("id"), 12))) + if not r: + return jsonify(ok=False, error="No existe esa incidencia."), 404 + return jsonify(ok=True, incidencia=r, puede_editar=puede_editar(yo())) + + @app.post("/api/incidencias") + def incidencias_crear(): + u = yo() + if not u: + return g["_sin_usuario"]() + d = g["_json_body"]() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + if not texto(d.get("descripcion")) and not texto(d.get("titulo")): + return jsonify(ok=False, error="Cuéntanos qué pasó (aunque sea una línea)."), 400 + origen = d.get("origen") if d.get("origen") in ORIGENES else "sitio" + if origen in ("vigia", "agente") and u.lower() not in SISTEMA: + origen = "sitio" + firma = texto(d.get("firma"), 80) + if firma and u.lower() in SISTEMA: + for r in todas(): + if r.get("firma") == firma and r.get("estado") in ABIERTAS: + r["ocurrencias"] = int(r.get("ocurrencias") or 1) + 1 + r["ultima_vez"] = ahora() + ev = [texto(x, 600) for x in (d.get("evidencia") or [])][:5] + r["evidencia"] = (r.get("evidencia") or [])[-(20 - len(ev)):] + ev if ev else r.get("evidencia") or [] + hist(r, u, f"se repitió (ocurrencia {r['ocurrencias']})") + guardar(r) + return jsonify(ok=True, id=r["id"], nueva=False, ocurrencias=r["ocurrencias"]) + r = nueva(d, u, origen) + return jsonify(ok=True, id=r["id"], nueva=True, incidencia=r) + + @app.patch("/api/incidencia") + def incidencia_cambiar(): + u = yo() + if not u: + return g["_sin_usuario"]() + r = g["_leer_json"](ruta(g["slug"](request.args.get("id"), 12))) + if not r: + return jsonify(ok=False, error="No existe esa incidencia."), 404 + d = g["_json_body"]() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + editor = puede_editar(u) + reporto = (r.get("reportado_por") or "").lower() == u.lower() + cambios = [] + if texto(d.get("comentario")): + r.setdefault("comentarios", []).append({"quien": u, "cuando": ahora(), "texto": texto(d.get("comentario"), 4000)}) + cambios.append("comentó") + if "estado" in d: + e = d.get("estado") + if e not in ESTADOS: + return jsonify(ok=False, error="Estado inválido."), 400 + if not editor and not (reporto and e in ("resuelta", "nueva")): + return jsonify(ok=False, error="Solo quien atiende las incidencias cambia su estado " + "(tú puedes marcar la tuya como resuelta o reabrirla)."), 403 + if e != r.get("estado"): + cambios.append(f"estado: {r.get('estado')} → {e}") + r["estado"] = e + r["resuelta_en"] = ahora() if e in ("resuelta", "descartada") else None + for campo, validos, largo in (("gravedad", GRAVEDADES, 0), ("tipo", TIPOS, 0), ("asignado", None, 40), + ("tarea_id", None, 60), ("duplicado_de", None, 12), ("titulo", None, 140), + ("resumen_publico", None, 200)): + if campo not in d: + continue + if not editor: + return jsonify(ok=False, error=f"Solo quien atiende las incidencias cambia «{campo}»."), 403 + v = d.get(campo) if validos else texto(d.get(campo), largo) + if validos and v not in validos: + return jsonify(ok=False, error=f"Valor inválido para {campo}."), 400 + if v != r.get(campo): + cambios.append(f"{campo}: {g['_corto'](r.get(campo))} → {g['_corto'](v)}") + r[campo] = v + if "publica" in d: + if not editor: + return jsonify(ok=False, error="Solo quien atiende las incidencias decide qué se ve en la entrada."), 403 + v = g["_bool"](d.get("publica"), False) + if v and not texto(r.get("resumen_publico")): # el título puede traer nombres o teléfonos: nunca se publica + return jsonify(ok=False, error="Para mostrarla en la entrada escribe primero el texto corto público " + "(sin nombres, teléfonos ni datos internos)."), 400 + if v != bool(r.get("publica")): + cambios.append("se muestra en la página de entrada" if v else "ya no se muestra en la entrada") + r["publica"] = v + if not cambios: + return jsonify(ok=True, incidencia=r, sin_cambios=True) + hist(r, u, "; ".join(cambios)) + guardar(r) + return jsonify(ok=True, incidencia=r) + + # ------------------------------------------------------------------ errores (navegador y servidor) + def anotar_error(lado, reg): + os.makedirs(carpeta_err, exist_ok=True) + fp = os.path.join(carpeta_err, f"{lado}-{datetime.date.today():%Y-%m-%d}.jsonl") + try: + if os.path.exists(fp) and os.path.getsize(fp) > MAX_ERRORES_DIA * 1200: + return False + with open(fp, "a", encoding="utf-8") as f: + f.write(json.dumps(reg, ensure_ascii=False) + "\n") + return True + except OSError: + return False + + @app.post("/api/incidencias/error") + def incidencias_error(): + u = yo() + if not u: + return g["_sin_usuario"]() + d = g["_json_body"]() or {} + reg = {"cuando": ahora(), "usuario": u, "tipo": texto(d.get("tipo"), 20) or "js", + "mensaje": texto(d.get("mensaje"), 400), "fuente": texto(d.get("fuente"), 200), + "linea": g["_entero"](d.get("linea"), 0), "pagina": texto(d.get("pagina"), 200), + "url_api": texto(d.get("url_api"), 200), "estado_http": g["_entero"](d.get("estado_http"), 0), + "pila": texto(d.get("pila"), 1200), "navegador": texto(request.headers.get("User-Agent"), 160)} + return jsonify(ok=anotar_error("cliente", reg)) + + def leer_errores(dias): + out = {"cliente": [], "servidor": []} + for i in range(dias): + dia = datetime.date.today() - datetime.timedelta(days=i) + for lado in out: + fp = os.path.join(carpeta_err, f"{lado}-{dia:%Y-%m-%d}.jsonl") + if os.path.isfile(fp): + with open(fp, encoding="utf-8", errors="ignore") as f: + for linea in f: + try: + out[lado].append(json.loads(linea)) + except Exception: # noqa: BLE001 + pass + return out + + def agrupar(regs, claves): + grupos = {} + for r in regs: + k = hashlib.sha1("|".join(str(r.get(c, "")) for c in claves).encode()).hexdigest()[:12] + gr = grupos.setdefault(k, {"firma": k, "veces": 0, "usuarios": set(), "primero": r.get("cuando"), + "ultimo": r.get("cuando"), "ejemplo": r}) + gr["veces"] += 1 + gr["usuarios"].add(r.get("usuario") or "") + gr["ultimo"] = max(gr["ultimo"] or "", r.get("cuando") or "") + out = sorted(grupos.values(), key=lambda x: -x["veces"]) + for x in out: + x["usuarios"] = sorted(x["usuarios"]) + return out + + @app.get("/api/incidencias/errores") + def incidencias_errores(): + u = yo() + if not puede_editar(u): + return jsonify(ok=False, error="Solo quien atiende las incidencias ve los errores."), 403 + dias = max(1, min(14, g["_entero"](request.args.get("dias"), 2))) + e = leer_errores(dias) + return jsonify(ok=True, dias=dias, + cliente=agrupar(e["cliente"], ("tipo", "mensaje", "fuente", "linea", "url_api", "estado_http")), + servidor=agrupar(e["servidor"], ("tipo", "ruta", "error"))) + + def _excepcion(sender, exception, **extra): # noqa: ARG001 + try: + anotar_error("servidor", {"cuando": ahora(), "tipo": "excepcion", "ruta": request.path, + "metodo": request.method, "usuario": yo(), + "error": f"{type(exception).__name__}: {exception}"[:400], + "traza": "".join(traceback.format_exception(exception))[-3000:]}) + except Exception: # noqa: BLE001 + pass + + got_request_exception.connect(_excepcion, app, weak=False) + + @app.after_request + def _respuestas_5xx(resp): + try: + if resp.status_code >= 500 and request.path.startswith("/api/"): + cuerpo = resp.get_json(silent=True) if resp.is_json else None + anotar_error("servidor", {"cuando": ahora(), "tipo": f"respuesta-{resp.status_code}", "ruta": request.path, + "metodo": request.method, "usuario": yo(), + "error": texto((cuerpo or {}).get("error") if isinstance(cuerpo, dict) else "", 400)}) + except Exception: # noqa: BLE001 + pass + return resp + + # ------------------------------------------------------------------ sin sesión (página de entrada) + @app.post("/api/incidencias/publica") + def incidencias_publica(): + d = g["_json_body"]() or request.form or {} + if texto(d.get("sitio_web")): # campo trampa: solo lo llenan los robots + return jsonify(ok=True, id="INC-0000") + desc = texto(d.get("descripcion"), 2000) + contacto = texto(d.get("contacto"), 120) + nombre = texto(d.get("nombre"), 80) + if len(desc) < 5: + return jsonify(ok=False, error="Cuéntanos qué pasa (aunque sea una línea)."), 400 + if not (nombre or contacto): + return jsonify(ok=False, error="Dinos quién eres (tu usuario o tu nombre) para poder ayudarte."), 400 + # límite: 5 por IP por hora (además del limit_req de nginx) + ip = (request.headers.get("X-Real-IP") or request.remote_addr or "")[:60] + hace_1h = (datetime.datetime.now() - datetime.timedelta(hours=1)).replace(microsecond=0).isoformat() + recientes = [r for r in todas() if r.get("origen") == "entrada" and (r.get("creado_en") or "") >= hace_1h + and (r.get("contexto") or {}).get("ip") == ip] + if len(recientes) >= 5: + return jsonify(ok=False, error="Ya recibimos varios reportes desde aquí. Te contactamos pronto."), 429 + tipo = d.get("tipo") if d.get("tipo") in ("acceso", "error", "otro") else "acceso" + r = nueva({"descripcion": desc, "tipo": tipo, "nombre": nombre, "contacto": contacto, + "titulo": (NOMBRE_TIPO[tipo] + " — " + (nombre or contacto))[:140], + "pagina": "/entrar.html", "contexto": {"pantalla": texto(d.get("pantalla"), 40)}}, "", "entrada") + r["contexto"]["ip"] = ip + r["reportado_por"] = "" + guardar(r) + return jsonify(ok=True, id=r["id"]) + + @app.get("/api/incidencias/publicas") + def incidencias_publicas(): + hace = (datetime.datetime.now() - datetime.timedelta(days=14)).replace(microsecond=0).isoformat() + regs = [r for r in todas() if r.get("publica") and texto(r.get("resumen_publico")) + and (r.get("estado") in ABIERTAS or (r.get("resuelta_en") or "") >= hace)] + regs.sort(key=lambda r: r.get("modificado_en") or "", reverse=True) + return jsonify(ok=True, avisos=[{"id": r["id"], "titulo": r.get("resumen_publico"), + "estado": r.get("estado"), + "resuelta": r.get("estado") in ("resuelta", "descartada"), + "actualizado": (r.get("modificado_en") or r.get("creado_en") or "")[:10]} + for r in regs[:8]]) + + g["_incidencias"] = {"todas": todas, "nueva": nueva, "guardar": guardar, "leer_errores": leer_errores, + "ABIERTAS": ABIERTAS} diff --git a/backend/lotes/2026-10-27_entrada-incidencias-agentes.json b/backend/lotes/2026-10-27_entrada-incidencias-agentes.json new file mode 100644 index 0000000..ec448eb --- /dev/null +++ b/backend/lotes/2026-10-27_entrada-incidencias-agentes.json @@ -0,0 +1,58 @@ +{ + "lote": "2026-10-27_entrada-incidencias-agentes", + "autor": "claude", + "motivo": "27-oct: página de entrada propia (el reporte de Adriana de no poder entrar después de restablecer), incidencias del sitio, bot de standup, vigía y agente diario; propuesta del cotizador con las decisiones de Leo (recibir en el sitio, SQLite)", + "fuente": "/agentes/", + "cambios": [ + { "id": "t-cambio-contrasenas", + "poner": { "fecha_limite": "2026-10-31" }, + "nota": "27-oct: 15 de 19 siguen con la contraseña inicial. Con la página de entrada nueva cambiarla ya no saca a nadie y el teléfono guarda la nueva (antes llenaba la vieja). Equipo marca quién falta." }, + + { "id": "t-correos-equipo", + "si_estado": ["por-hacer", "en-curso"], + "poner": { "estado": "en-curso" }, + "nota": "Lista de Gibran confirmada por Leo (27-oct): cuentas.py alta llenó 12 correos (helgen.tech y helgen.services). Solo falta: Equipo → Leo → Otro correo = leo@helgen.services (ceo@ es alias)." }, + + { "id": "t-login-correo", + "nota": "Parte 1 hecha (27-oct): página de entrada propia; se entra con el usuario o con el correo de Equipo (los dos dominios), y cerrar sesión ya funciona. Falta «Entrar con Google» y los avisos por correo: se decide después de una semana con la página nueva." }, + + { "id": "t-cotizador-integracion", + "poner": { "estado": "hecho" }, + "nota": "Propuesta lista (27-oct) y decidida por Leo: se recibe en el sitio (Recibir del inventario) y la base es SQLite. Siguen la fase 1 del centro de mando y los cambios del cotizador (pendientes nuevos)." }, + + { "crear": { "id": "d-27-3-sitios-oc", + "titulo": "D-27-3/4/5: códigos de sitio VER·ZLO·ESE·LZC·QRO, autorización de OC en el cotizador (sin montos en el sitio) y DROGHEDA como proyecto ligado — confirma Leo", + "pista": "compras", "estado": "por-hacer", "responsable": "leo", "fecha_limite": "2026-10-29", + "sitios": ["qro"], "etiquetas": ["decisión", "cotizador"], + "descripcion": "Propuestas de Claude en la integración del cotizador. D-27-3: códigos = los del aeropuerto y del puerto (UN/LOCODE): VER Veracruz (ICAVE), ZLO Manzanillo (TIMSA), ESE Ensenada (EIT), LZC Lázaro Cárdenas (LCT), QRO Querétaro; el sitio ya los acepta. D-27-4: la autorización y los montos se quedan en el cotizador. D-27-5: solo las OC de PRY-2026-0002-DRO van al centro de mando.", + "enlaces": [{ "titulo": "Integración del cotizador", "url": "/inventario/integracion-cotizador/#decisiones" }] }, + "si_no_hay": ["D-27-3"] }, + + { "crear": { "id": "t-compras-fase1", + "titulo": "Compras fase 1 en el centro de mando: OC del cotizador en SQLite, envíos «en camino» por sitio y un pendiente por OC", + "pista": "compras", "estado": "por-hacer", "responsable": "claude, leo", "fecha_limite": "2026-11-04", + "sitios": ["qro"], "etiquetas": ["cotizador", "sitio"], + "descripcion": "backend/compras.py (SQLite /srv/projects/drogheda/drogheda.db en WAL), las 3 rutas firmadas /api/integracion/… (evento, ítems, recepciones), página Compras con renglones por ligar, liga desde el envío en camino del inventario, pendiente por OC que se cierra al recibir todo, y pruebas. Leo genera el secreto compartido y lo pone en el servidor (COTIZADOR_SECRETO) y en el cotizador (PORTAL_SECRETO). Empieza cuando Leo diga «va».", + "enlaces": [{ "titulo": "Diseño y contrato", "url": "/inventario/integracion-cotizador/" }] }, + "si_no_hay": ["Compras fase 1"] }, + + { "crear": { "id": "t-cotizador-oc-sitio", + "titulo": "Cotizador: sitio de entrega, ítem del inventario por renglón, estado «En camino», avisos al centro de mando y cierre automático", + "pista": "compras", "estado": "por-hacer", "responsable": "efrain, leo", "fecha_limite": "2026-11-06", + "sitios": ["qro"], "etiquetas": ["cotizador"], + "descripcion": "Lo hace la sesión de Claude del proyecto del cotizador con Efraín, siguiendo las instrucciones del 27-oct (pasos 1 a 7, con ronda en PRUEBAS). Puede empezar ya contra un servidor simulado; la prueba de punta a punta espera a t-compras-fase1.", + "enlaces": [{ "titulo": "Instrucciones para el cotizador", "url": "/inventario/integracion-cotizador-instrucciones/" }] }, + "si_no_hay": ["sitio de entrega"] }, + + { "crear": { "id": "t-standup-arranque", + "titulo": "Standup diario: primera semana — que cada quien conteste en Standup (el bot lo prepara cada tarde)", + "pista": "gestion", "estado": "por-hacer", "responsable": "adriana", "fecha_limite": "2026-11-03", + "sitios": ["qro", "icave", "timsa", "eit", "lzc"], "etiquetas": ["quick-win"], + "descripcion": "El bot deja en /tableros/standup/ las instrucciones de cada persona y tres preguntas sobre sus pendientes; el sitio avisa con una franja azul hasta que contesten. Adriana ve en la pestaña Equipo quién contestó y los bloqueos. Al final de la semana: ¿se queda, cambia la hora o las preguntas?", + "enlaces": [{ "titulo": "Standup", "url": "/tableros/standup/" }, { "titulo": "Agentes del sitio", "url": "/agentes/" }] }, + "si_no_hay": ["Standup diario"] }, + + { "id": "t-juntas", + "nota": "Desde el 28-oct el bot de standup pregunta a cada quien por escrito y mueve sus pendientes si lo deja marcado; la junta grabada se queda para lo que necesita conversación." } + ] +} diff --git a/backend/lotes/2026-10-27_t45-lista.json b/backend/lotes/2026-10-27_t45-lista.json new file mode 100644 index 0000000..50d8731 --- /dev/null +++ b/backend/lotes/2026-10-27_t45-lista.json @@ -0,0 +1,72 @@ +{ + "lote": "2026-10-27_t45-lista", + "autor": "claude", + "motivo": "T45 listo y probado en una copia de la v6: R80 abajo de Q7 (/VEH5V por F.Cu y tomas Kelvin por B.Cu), vías de los EP de U4 y U7 y limpieza. La RF LTE pasa a T45b", + "fuente": "/hardware/instrucciones-michelle-27oct/#2-despues-de-t45-martes-28-y-miercoles-29-bloque-b-y-r112", + "cambios": [ + { + "id": "t-v6-t45", + "poner": { + "titulo": "T45 en la v6 por script: R80 abajo de Q7 con /VEH5V por F.Cu y tomas Kelvin, vías de los EP de U4 y U7 y limpieza", + "descripcion": "D-26-1 (a), Leo 27-oct. Script tools/t45_v6.py sobre el commit de Michelle del martes (después de Actualizar PCB con T48 + T49 + T41), con KiCad cerrado:\n1. R80 pasa a (174.6, 108.87), abajo de Q7: donde estaba, el anillo de /MAIN5V (Q7 → C77…C81 → U17.1/24) no dejaba llegar a R80.1 por F.Cu.\n2. /VEH5V de C100 a R80.1 por F.Cu (1.5 mm en el cuello entre C99.2 y R86.2; C100 → C101 a 2.0 mm): ≈ 7 mΩ desde L5.2. Fuera la vía de (180.1, 103.2) y los 8 mm de In2.\n3. /VIN_SW de R80.2 a los pines S de Q7 con 2.0 mm.\n4. Tomas VIN y CLN por el lado de adentro de R80, por B.Cu, hasta C86 (VIN) y la pista de U17.7 (CLN). El script hace la prueba de Kelvin.\n5. /MAIN5V de JP5.1 y R85.1 desde Q7.5, con 0.35 mm entre los pines 3 y 4.\n6. 6 vías en el EP de U4 (fuera el cuello de GND) y 6 en el de U7.\n7. Limpieza del 26-oct: 3 vías y 3 pistas sueltas.\nProbado en una copia de 9a6f250: DRC con 0 errores y 0 sin conectar. Si algo de la zona cambió, el script no escribe nada." + }, + "nota": "27-oct, 15:45: script listo (tools/t45_v6.py), probado en una copia: DRC 0 errores, 0 sin conectar, Kelvin bien. Mueve R80 porque lo encerraba /MAIN5V. La RF LTE pasa a t-v6-t45b-rf." + }, + { + "crear": { + "id": "t-v6-t45b-rf", + "titulo": "T45b en la v6 por script: RF LTE en F.Cu con cerca de vías y /VBAT_MODEM de C13 a los pines 55–57 con el cruce por B.Cu", + "pista": "ingenieria", + "estado": "por-hacer", + "responsable": "claude", + "version_tarjeta": "v6", + "fecha_limite": "2026-10-30", + "descripcion": "Sale de T45 (27-oct). Script sobre el commit de Michelle del miércoles 29 (bloque B), con KiCad cerrado:\n1. /RF_LTE y /RF_ANT_LTE solo en F.Cu: U6.60 → C19/R9 → C5 → D8 → J8, coplanar de 0.35/0.20 mm, sin esquinas de 90°. Se borran el tramo de In1 y sus 2 vías.\n2. /VBAT_MODEM de C13 a los pines 55–57, con C14–C18, cruzando la RF por B.Cu (polígono de ≥ 3 mm y ≥ 6 vías por lado). C14 o C15 se recorren y siguen a ≤ 3 mm de los pines.\n3. Cerca de vías de GND a los dos lados cada ≤ 3 mm; 2 vías por pad de tierra de J8 y una en D8.2.\n4. In1 sin pistas a 3 mm de la línea. Lo que no se pueda mover ahí (/KCOIL, /SW_IN) se lista para T29a y el bloque D.\nSe prueba en una copia con DRC y paridad antes de aplicar.", + "bloqueada_por": [ + "t-v6-t45" + ], + "enlaces": [ + { + "titulo": "Plan de fines de semana, §4.1 (RF del módem)", + "url": "/hardware/v6-plan-fines-de-semana/#41-rf-del-modem-bloque-c" + }, + { + "titulo": "Instrucciones para Michelle (27-oct), §3", + "url": "/hardware/instrucciones-michelle-27oct/#3-jueves-30-y-viernes-31-bloques-c-d-y-e" + } + ], + "etiquetas": [ + "v6", + "script", + "rf" + ] + }, + "si_no_hay": [ + "T45b" + ] + }, + { + "id": "t-v6-kelvin-vin-r80", + "nota": "27-oct, 15:45: lo resuelve T45: R80 abajo de Q7 y las tomas VIN y CLN por el lado de adentro, por B.Cu; la prueba de Kelvin del script pasa. Se cierra cuando T45 quede aplicado (28-oct)." + }, + { + "id": "t-v6-limpieza-26oct", + "nota": "27-oct, 15:45: las 3 vías y las 3 pistas sueltas van en T45. Si Michelle ya borró alguna, el script avisa y sigue." + }, + { + "id": "t-v6-b-main5v-vsys", + "nota": "27-oct, 15:45: con T45, R80 pasa abajo de Q7 y su lugar viejo (entre Q7, C77 y C86) queda libre para el polígono de /MAIN5V y C77–C81. No mover R80, Q7, C86/C87, C100/C101 ni las tomas Kelvin." + }, + { + "id": "t-v6-b-bstfb-igate", + "nota": "27-oct, 15:45: T45 da el 5 V a JP5.1 y R85.1 desde Q7.5 (0.35 mm entre los pines 3 y 4 de Q7). Al mover R85 a U17.18, su rama de arriba toma el 5 V de los capacitores de VSYS; JP5.1 se queda así." + }, + { + "id": "t-v6-c-modem", + "poner": { + "descripcion": "Bloque C del plan de fines de semana (§4.2), sin la RF:\n1. C44/C34 a ≤ 3 mm y C52 junto a U4.2.\n2. R28/R27 a ≤ 2 mm de U4.5, con el nodo FB corto.\n3. /VBAT_MODEM como polígono de ≥ 3 mm de L1.2 a C13; C4 a ≤ 3 mm de L1.2.\n4. TP5 a 2.54 mm de TP6.\nDe C13 a los pines 55/56/57 (con C14–C18), la RF LTE y su cerca de vías los hace T45b (t-v6-t45b-rf) el jueves 30 temprano: no tocarlos. Las 6 vías del EP de U4 y el cuello de GND ya los resuelve T45." + }, + "nota": "27-oct, 15:45: la RF LTE, su cerca de vías y /VBAT_MODEM de C13 a los pines 55–57 (con C14–C18) pasan a T45b, el 30-oct. A Michelle le queda el VBAT de L1.2 a C13 y el resto del bloque C." + } + ] +} diff --git a/backend/nginx/cambiar-entrada.sh b/backend/nginx/cambiar-entrada.sh new file mode 100755 index 0000000..b3a9a0f --- /dev/null +++ b/backend/nginx/cambiar-entrada.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Página de entrada del sitio (27-oct-2026): instala el nginx nuevo de drogheda.helgen.online con respaldo, prueba y +# regreso automático. Se corre EN EL SERVIDOR con sudo (lo llama scripts/despliegue-1027.sh desde la Mac): +# sudo bash /srv/projects/drogheda/hbox-docs/backend/nginx/cambiar-entrada.sh servicio # 1. servicio nuevo (hilos, admins) +# sudo bash …/cambiar-entrada.sh aplicar # 2. nginx con página de entrada (si una prueba falla, regresa solo) +# sudo bash …/cambiar-entrada.sh deshacer # regresar al nginx anterior (ventana gris) si algo no te gusta +# sudo bash …/cambiar-entrada.sh probar # solo las pruebas, sin cambiar nada +set -uo pipefail +AQUI="$(cd "$(dirname "$0")" && pwd)" +BACK="$(dirname "$AQUI")" +# El archivo real de nginx de este sitio (el que está habilitado), no una ruta supuesta +CONF="$(readlink -f "$(grep -l 'server_name drogheda.helgen.online' /etc/nginx/sites-enabled/* 2>/dev/null | head -1)" 2>/dev/null)" +NUEVO="$AQUI/drogheda.helgen.online.conf" +HTP=/etc/nginx/hbox-docs.htpasswd +DATOS=/srv/projects/drogheda +UNIDAD=/etc/systemd/system/actas-hbox.service +HOST=drogheda.helgen.online +[ "$(id -u)" = 0 ] || { echo "Córrelo con sudo."; exit 1; } + +ok=0; mal=0 +prueba() { # nombre, esperado (subcadena), comando + local r; r="$(eval "$3" 2>&1)" + if [[ "$r" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else mal=$((mal+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${r:0:160}"; fi +} +C="curl -s --max-time 10 --resolve $HOST:443:127.0.0.1" + +probar_backend() { + echo "## backend (127.0.0.1:8081)" + prueba "salud" '"ok":true' "curl -s --max-time 5 http://127.0.0.1:8081/api/salud" + prueba "/api/_auth sin sesión → 401" "401" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/_auth" + prueba "/api/sesion responde" '"sesion":false' "curl -s http://127.0.0.1:8081/api/sesion" + prueba "incidencias cargadas" '"ok":true' "curl -s -H 'X-Remote-User: vigia' http://127.0.0.1:8081/api/incidencias/resumen" + prueba "standup cargado" '"ok":true' "curl -s -H 'X-Remote-User: vigia' http://127.0.0.1:8081/api/standup/estado" +} +cookie_de_prueba() { # sesión de 5 minutos para leo, firmada con el secreto del servicio (sin contraseña) + python3 - "$DATOS/.sesion-secreto" "$HTP" <<'PY' +import sys, time, hmac, hashlib +sec = open(sys.argv[1]).read().strip().encode() +h = [l.split(":", 1)[1].strip() for l in open(sys.argv[2]) if l.split(":", 1)[0].strip() == "leo"][0] +ver = hashlib.sha256(h.encode()).hexdigest()[:12]; v = int(time.time()) + 300 +print("leo~%d~%s~%s" % (v, ver, hmac.new(sec, ("leo|%d|%s" % (v, ver)).encode(), hashlib.sha256).hexdigest()[:40])) +PY +} +probar_nginx() { + echo "## nginx ($HOST)" + prueba "/ sin sesión → 302 a /entrar.html" "entrar.html" "$C -o /dev/null -w '%{http_code} %{redirect_url}' https://$HOST/tableros/pendientes/" + prueba "/entrar.html → 200" "200" "$C -o /dev/null -w '%{http_code}' https://$HOST/entrar.html" + prueba "estilos públicos → 200" "200" "$C -o /dev/null -w '%{http_code}' https://$HOST/stylesheets/helgen.css" + prueba "/publico/entrar.js → 200" "200" "$C -o /dev/null -w '%{http_code}' https://$HOST/publico/entrar.js" + prueba "/api/sesion sin sesión → 200" '"sesion":false' "$C https://$HOST/api/sesion" + prueba "/api/tareas sin sesión → 401 JSON" '"sesion":false' "$C https://$HOST/api/tareas" + prueba "/api/_auth desde fuera → 404" "404" "$C -o /dev/null -w '%{http_code}' https://$HOST/api/_auth" + prueba "X-Remote-User falso no entra" '"sesion":false' "$C -H 'X-Remote-User: leo' https://$HOST/api/tareas" + prueba "entrar con contraseña mala → 401" '"codigo":"' "$C -H 'Content-Type: application/json' -d '{\"usuario\":\"vigia-prueba\",\"password\":\"x\"}' https://$HOST/api/entrar" + prueba "curl -u (Basic) ya no entra" '"sesion":false' "$C -A curl -u leo:x https://$HOST/api/whoami" + local CK; CK="$(cookie_de_prueba 2>/dev/null)" + prueba "con sesión: /api/whoami = leo" '"user":"leo"' "$C -b hbox_sesion=$CK https://$HOST/api/whoami" + prueba "con sesión: la página abre (200)" "200" "$C -b hbox_sesion=$CK -o /dev/null -w '%{http_code}' https://$HOST/tableros/pendientes/" + prueba "POST desde otro sitio → 403" "otro sitio" "$C -b hbox_sesion=$CK -H 'Origin: https://malo.example' -H 'Content-Type: application/json' -d '{}' https://$HOST/api/incidencias" +} +revisar_htpasswd() { # la entrada nueva lee $apr1$ y {SHA}; si alguien quedó con otro formato, no podría entrar + local otros; otros="$(cut -d: -f2 "$HTP" | grep -vcE '^(\$apr1\$|\{SHA\})' || true)" + if [ "${otros:-0}" != "0" ]; then + echo " ✕ $otros cuenta(s) del htpasswd con un formato que la entrada nueva no lee:"; cut -d: -f1,2 "$HTP" | grep -vE ':(\$apr1\$|\{SHA\})' | cut -d: -f1 | sed 's/^/ /' + echo " Restablécelas en Mi cuenta antes de cambiar nginx."; return 1 + fi + echo " ✓ todas las contraseñas en \$apr1\$" +} + +case "${1:-}" in + servicio) + UANT="$UNIDAD.antes-$(date +%Y%m%d-%H%M%S)" + cp -p "$UNIDAD" "$UANT" || { echo "No pude respaldar $UNIDAD: no cambio nada."; exit 1; } + install -m 644 "$BACK/actas-hbox.service" "$UNIDAD" + systemctl daemon-reload && systemctl restart actas-hbox + for i in 1 2 3 4 5 6 7 8 9 10; do curl -s --max-time 2 http://127.0.0.1:8081/api/salud >/dev/null && break; sleep 1; done + probar_backend + if [ "$mal" != 0 ]; then + echo "El servicio nuevo no pasó ($mal mal): regreso al anterior ($UANT). Revisa 'journalctl -u actas-hbox -n 50'." + cp -p "$UANT" "$UNIDAD" && systemctl daemon-reload && systemctl restart actas-hbox; exit 1 + fi + echo "Servicio listo ($ok pruebas). Respaldo del anterior: $UANT" + ;; + aplicar) + [ -n "$CONF" ] && [ -f "$CONF" ] || { echo "No encuentro el nginx de $HOST en sites-enabled. No cambio nada."; exit 1; } + echo "Archivo de nginx: $CONF" + probar_backend + [ "$mal" = 0 ] || { echo "El backend todavía no tiene la entrada nueva: corre primero «servicio». No cambié nginx."; exit 1; } + revisar_htpasswd || exit 1 + # respaldo FUERA de sites-enabled/ (lo que esté ahí lo carga nginx: un respaldo ahí duplicaría el sitio) + mkdir -p /etc/nginx/respaldos-drogheda + RESP="/etc/nginx/respaldos-drogheda/$(basename "$CONF").antes-entrada-$(date +%Y%m%d-%H%M%S)" + cp -p "$CONF" "$RESP" || { echo "No pude respaldar $CONF: no cambio nada."; exit 1; } + echo "Respaldo: $RESP" + install -m 644 "$NUEVO" "$CONF" + if ! nginx -t 2>&1; then echo "nginx -t falló: regreso al anterior."; cp -p "$RESP" "$CONF"; nginx -t && systemctl reload nginx; exit 1; fi + systemctl reload nginx; sleep 1 + ok=0; mal=0; probar_nginx + if [ "$mal" != 0 ]; then + echo "Algo falló ($mal): regreso al nginx anterior."; cp -p "$RESP" "$CONF"; nginx -t && systemctl reload nginx; exit 1 + fi + echo; echo "Listo ($ok pruebas). Abre https://$HOST : debe salir la página de entrada. Para regresar: sudo bash $0 deshacer" + ;; + deshacer) + [ -n "$CONF" ] || CONF=/etc/nginx/sites-available/drogheda.helgen.online.conf + RESP="$(ls -1t /etc/nginx/respaldos-drogheda/"$(basename "$CONF")".antes-entrada-* 2>/dev/null | head -1)" + [ -n "$RESP" ] || { echo "No hay respaldo de antes de la entrada."; exit 1; } + cp -p "$RESP" "$CONF" && nginx -t && systemctl reload nginx && echo "Regresé a $RESP (ventana gris de antes)." + ;; + probar) + probar_backend; probar_nginx; echo "$ok bien · $mal mal" + ;; + *) sed -n '2,9p' "$0"; exit 1 ;; +esac diff --git a/backend/nginx/drogheda.helgen.online.conf b/backend/nginx/drogheda.helgen.online.conf new file mode 100644 index 0000000..2c3b8eb --- /dev/null +++ b/backend/nginx/drogheda.helgen.online.conf @@ -0,0 +1,128 @@ +# drogheda.helgen.online — 27-oct-2026: página de entrada propia (backend/acceso.py) en lugar de la ventana de +# HTTP Basic. nginx pregunta a /api/_auth en cada petición; sin sesión manda a /entrar.html (las API reciben 401 JSON). +# Lo instala backend/nginx/cambiar-entrada.sh (respaldo, nginx -t, recarga, pruebas y regreso automático si algo falla). +# Lo de Certbot (443, certificados) es igual que antes. + +# Límites por IP (la oficina de Querétaro sale por una sola IP: holgados). Además, acceso.py frena por usuario. +limit_req_zone $binary_remote_addr zone=hbox_entrar:1m rate=30r/m; +limit_req_zone $binary_remote_addr zone=hbox_reporte:1m rate=6r/m; + +server { + server_name drogheda.helgen.online; + + root /srv/projects/drogheda/hbox-docs/site; + index index.html; + + # ---- sesión: todo pide sesión salvo lo marcado «auth_request off» abajo + auth_request /_auth; + auth_request_set $hbox_user $upstream_http_x_hbox_user; + error_page 401 = @entrar; + error_page 500 502 503 504 /publico/caido.html; + + location = /_auth { + internal; + proxy_pass http://127.0.0.1:8081/api/_auth; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Remote-User ""; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + } + + location @entrar { + # sin sesión: a la página de entrada; después regresa a donde ibas (la página lo lee después del #) + return 302 /entrar.html#$request_uri; + } + + location @api401 { + default_type application/json; + return 401 '{"ok":false,"sesion":false,"error":"Tu sesión terminó. Vuelve a entrar en /entrar.html"}'; + } + + location @lento { + default_type application/json; + return 429 '{"ok":false,"codigo":"bloqueado","error":"Demasiados intentos seguidos desde esta conexión. Espera un minuto y vuelve a intentar."}'; + } + + # ---- públicas (sin sesión): la página de entrada y lo que necesita para verse + location = /entrar.html { auth_request off; add_header Cache-Control "no-store"; } + location ^~ /publico/ { auth_request off; } + location ^~ /fonts/ { auth_request off; } + location = /stylesheets/helgen.css { auth_request off; } + location = /js/tema.js { auth_request off; } + location ~ ^/img/(helgen-mascara\.svg|favicon\.png|helgen-icono-180\.png)$ { auth_request off; } + location = /favicon.ico { auth_request off; try_files /img/favicon.png =404; } + + # ---- API sin sesión: entrar, salir, saber si hay sesión, salud, avisos y reporte desde la entrada + location = /api/_auth { return 404; } + location ~ ^/api/(salir|sesion|salud|incidencias/publicas)$ { + auth_request off; + client_max_body_size 16k; + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Remote-User ""; + } + location = /api/entrar { + auth_request off; + limit_req zone=hbox_entrar burst=20 nodelay; + limit_req_status 429; + error_page 429 = @lento; + client_max_body_size 16k; + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Remote-User ""; + } + location = /api/incidencias/publica { + auth_request off; + limit_req zone=hbox_reporte burst=4 nodelay; + limit_req_status 429; + error_page 429 = @lento; + client_max_body_size 16k; + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Remote-User ""; + } + + # ---- API con sesión: el usuario lo pone nginx (de la sesión), nunca el navegador + location /api/ { + error_page 401 = @api401; + client_max_body_size 25m; + proxy_read_timeout 120s; + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Remote-User $hbox_user; + } + + location / { + try_files $uri $uri/ =404; + } + + listen [::]:443 ssl; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/drogheda.helgen.online/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/drogheda.helgen.online/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} +server { + if ($host = drogheda.helgen.online) { + return 301 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name drogheda.helgen.online; + return 404; # managed by Certbot +} diff --git a/backend/semilla_1027.py b/backend/semilla_1027.py new file mode 100644 index 0000000..4e9bfb0 --- /dev/null +++ b/backend/semilla_1027.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Primeras incidencias (27-oct-2026): las dos fallas de acceso de esta semana, para que el tablero de Incidencias arranque +con su historia. Se corre EN EL SERVIDOR después de reiniciar el servicio (lo hace scripts/despliegue-1027.sh): + cd /srv/projects/drogheda/hbox-docs/backend && venv/bin/python3 semilla_1027.py +Idempotente: si ya existe una incidencia con la misma firma, no la vuelve a abrir. Escribe como «claude» por 127.0.0.1:8081. +""" +import json +import os +import urllib.request + +API = os.environ.get("VIGIA_API", "http://127.0.0.1:8081") + + +def api(metodo, ruta, cuerpo=None): + req = urllib.request.Request(API + ruta, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None, + headers={"X-Remote-User": "claude", "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read().decode()) + + +SEMILLA = [ + {"alta": {"firma": "semilla:acceso-restablecer", "origen": "agente", "tipo": "acceso", "gravedad": "alta", + "titulo": "Después de restablecer la contraseña no se podía volver a entrar (reporte de Adriana, 26-oct)", + "descripcion": "Adriana reportó el 26-oct que, después de restablecer contraseñas, la gente no podía volver a entrar. " + "El 23-oct se restableció a todos con una contraseña inicial y solo 4 de 19 la cambiaron. El 27-oct a " + "las 9:18 Gibran le restableció la suya a Adriana y con eso entró.", + "pagina": "/cuenta.html"}, + "cambio": {"estado": "en-curso", "asignado": "claude", "publica": True, + "resumen_publico": "Desde el 27-oct se entra con una página nueva: tu usuario (o tu correo de Helgen) y tu " + "contraseña. Si tu navegador llena una contraseña vieja, bórrala y escribe la tuya.", + "comentario": "Causa: con la ventana gris de antes (HTTP Basic de nginx) el navegador y el teléfono guardaban la " + "contraseña anterior y la volvían a mandar sola; Mi cuenta no tenía campo de usuario, así que el " + "gestor de contraseñas nunca la actualizaba; el usuario con mayúscula («Adriana») no existía para " + "nginx, y el servidor no registraba quién fallaba ni por qué. Arreglo (27-oct): página de entrada " + "propia (usuario o correo, sin importar mayúsculas), sesión que sobrevive al cambio de contraseña, " + "cerrar sesión que sí cierra, el restablecimiento cierra las sesiones viejas, contraseñas sin " + "mayúscula automática en el teléfono, y cada intento queda registrado (sin la contraseña). Queda " + "en curso una semana: el vigía avisa si alguien sigue sin poder entrar."}}, + {"alta": {"firma": "semilla:servicio-repetibles", "origen": "agente", "tipo": "datos", "gravedad": "media", + "titulo": "El servicio instalado a mano el 27-oct dejó fuera la prueba Ethernet/serie y no estaba en el repo", + "descripcion": "A las 9:15 del 27-oct se reinstaló /etc/systemd/system/actas-hbox.service con una copia vieja: " + "agregó a Gibran como administrador, pero ACTAS_REPETIBLES ya no incluía prueba-ethernet-serie " + "(la segunda acta de la misma serie se rechazaba con 409). El repo no tenía a Gibran.", + "pagina": "/formularios/ethernet-serie.html"}, + "cambio": {"estado": "resuelta", "asignado": "claude", + "comentario": "Arreglado en el despliegue del 27-oct: el servicio del repo ahora lleva a Gibran como " + "administrador y la lista completa de actas repetibles, y se instaló desde el repo. El vigía " + "compara cada hora el servicio instalado contra el del repo y abre una incidencia si no coinciden."}}, +] + + +def main(): + for s in SEMILLA: + lista = api("GET", "/api/incidencias?estado=todas&q=" + urllib.request.quote(s["alta"]["titulo"][:30])) + if any(r.get("titulo") == s["alta"]["titulo"] for r in lista.get("incidencias", [])): + print("ya estaba:", s["alta"]["titulo"][:70]) + continue + r = api("POST", "/api/incidencias", s["alta"]) + api("PATCH", "/api/incidencia?id=" + r["id"], s["cambio"]) + print("abierta:", r["id"], s["alta"]["titulo"][:70]) + + +if __name__ == "__main__": + main() diff --git a/backend/standup.py b/backend/standup.py new file mode 100644 index 0000000..c8f8139 --- /dev/null +++ b/backend/standup.py @@ -0,0 +1,198 @@ +"""Standup diario (27-oct-2026): el bot de scrum del sitio. + +Cada mañana el agente diario (Claude, tarea programada) lee el tablero y prepara para cada persona con pendientes +abiertos su «parte del día»: instrucciones concretas (qué hacer hoy, en qué orden, con las ligas a los documentos) y +las tres preguntas de siempre sobre SUS pendientes: ¿qué terminaste?, ¿qué te falta?, ¿qué te bloquea? +Cada quien contesta en /tableros/standup/ (el sitio le avisa con una franja arriba hasta que conteste). Al día +siguiente el agente lee las respuestas, arma un lote para el tablero (Leo lo aplica) y un resumen para Leo y Adriana. +Si el agente no corrió, la página igual pregunta por los pendientes abiertos de la persona (sin instrucciones). + +Lo engancha app.py al final con registrar(app, globals()). + +Datos: DATOS_BASE/standup/AAAA-MM-DD/.json {fecha, usuario, nombre, preparado_por, preparado_en, + instrucciones:[{texto, tarea_id?, liga?}], preguntas:[{tarea_id, titulo, pregunta}], nota_bot, + respuesta:{enviada_en, hecho, falta, bloqueos, comentario, por_tarea:[{tarea_id, estado, nota}]}, historial} + +Rutas (todas con sesión): + GET /api/standup/hoy[?fecha=] el mío de hoy (o el que el bot dejó en los últimos 3 días sin contestar) + POST /api/standup/respuesta {fecha, hecho, falta, bloqueos, comentario, por_tarea:[{tarea_id, estado, nota}]} + GET /api/standup?fecha= equipo: quién contestó, bloqueos (STANDUP_LECTORES + administradores) + POST /api/standup/preparar {fecha, personas:[{usuario, instrucciones, preguntas, nota_bot}]} (claude + admins) + GET /api/standup/estado {pendiente: bool, fecha} — para la franja de aviso (barato) +Estados por pendiente en la respuesta: hecho · sigo · bloqueado · no-empece. +""" +import datetime +import os + +from flask import jsonify, request + +ESTADOS_RESP = ("hecho", "sigo", "bloqueado", "no-empece") +DIAS_ATRAS = 3 + + +def registrar(app, g): + carpeta = os.environ.get("STANDUP_DIR", os.path.join(g["DATOS_BASE"], "standup")) + lectores = {u.strip().lower() for u in os.environ.get("STANDUP_LECTORES", "leo,adriana,claude,gibranlp").split(",") + if u.strip()} + preparan = {u.strip().lower() for u in os.environ.get("STANDUP_PREPARAN", "claude").split(",") if u.strip()} + texto, ahora, hist = g["_texto"], g["ahora"], g["_hist"] + + def yo(): + return g["current_user"]() + + def hoy(): + return datetime.date.today().isoformat() + + def fecha_valida(s): + s = texto(s, 10) + return s if g["FECHA_RE"].match(s or "") else None + + def ruta(fecha, usuario): + return os.path.join(carpeta, fecha, g["_norm_id"](usuario) + ".json") + + def mis_pendientes(u): + miembros = g["_miembros"]() + out = [] + for t in g["_listar_json"](g["TAREAS_DIR"]): + if t.get("estado") == "hecho": + continue + ids = g["_ids_responsable"](t.get("responsable"), miembros)[0] + if g["_norm_id"](u) in ids: + out.append({"id": t.get("id"), "titulo": t.get("titulo"), "estado": t.get("estado"), + "vence": t.get("vence"), "pista": t.get("pista"), "bloqueado_por": t.get("bloqueado_por")}) + orden = {"bloqueado": 0, "en-curso": 1, "por-hacer": 2} + out.sort(key=lambda t: (orden.get(t["estado"], 3), t.get("vence") or "9999")) + return out + + def armar_generico(u, fecha): + pend = mis_pendientes(u) + m = g["_miembros"]().get(g["_norm_id"](u)) or {} + return {"fecha": fecha, "usuario": g["_norm_id"](u), "nombre": m.get("nombre") or u, "preparado_por": "", + "preparado_en": None, "instrucciones": [], "nota_bot": "", + "preguntas": [{"tarea_id": t["id"], "titulo": t["titulo"], "pregunta": ""} for t in pend[:12]], + "respuesta": None, "_generico": True} + + def buscar_mio(u, fecha=None): + """El de la fecha pedida; si no, el de hoy; si no hay de hoy, el último sin contestar de los últimos días.""" + if fecha: + return g["_leer_json"](ruta(fecha, u)), fecha + d = g["_leer_json"](ruta(hoy(), u)) + if d: + return d, hoy() + for i in range(1, DIAS_ATRAS + 1): + f = (datetime.date.today() - datetime.timedelta(days=i)).isoformat() + d = g["_leer_json"](ruta(f, u)) + if d and not d.get("respuesta"): + return d, f + return None, hoy() + + def con_pendientes(d, u): + """Agrega el estado de hoy de cada pendiente preguntado (pudo moverse desde que el bot preguntó).""" + actuales = {t["id"]: t for t in mis_pendientes(u)} + for p in d.get("preguntas") or []: + t = actuales.get(p.get("tarea_id")) + p["estado_tablero"] = t["estado"] if t else "hecho o ya no es tuyo" + p["vence"] = t.get("vence") if t else None + d["pendientes_abiertos"] = list(actuales.values()) + return d + + @app.get("/api/standup/hoy") + def standup_hoy(): + u = yo() + if not u: + return g["_sin_usuario"]() + d, fecha = buscar_mio(u, fecha_valida(request.args.get("fecha"))) + d = d or armar_generico(u, fecha) + return jsonify(ok=True, standup=con_pendientes(d, u), estados=list(ESTADOS_RESP), hoy=hoy()) + + @app.get("/api/standup/estado") + def standup_estado(): + u = yo() + if not u: + return g["_sin_usuario"]() + d, fecha = buscar_mio(u) + return jsonify(ok=True, preparado=bool(d), pendiente=bool(d and not d.get("respuesta")), fecha=fecha) + + @app.post("/api/standup/respuesta") + def standup_respuesta(): + u = yo() + if not u: + return g["_sin_usuario"]() + d = g["_json_body"]() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + fecha = fecha_valida(d.get("fecha")) or hoy() + reg = g["_leer_json"](ruta(fecha, u)) or armar_generico(u, fecha) + reg.pop("_generico", None) + por_tarea = [] + for x in d.get("por_tarea") or []: + if isinstance(x, dict) and x.get("tarea_id") and x.get("estado") in ESTADOS_RESP: + por_tarea.append({"tarea_id": texto(x["tarea_id"], 60), "estado": x["estado"], "nota": texto(x.get("nota"), 600)}) + resp = {"enviada_en": ahora(), "hecho": texto(d.get("hecho"), 3000), "falta": texto(d.get("falta"), 3000), + "bloqueos": texto(d.get("bloqueos"), 3000), "comentario": texto(d.get("comentario"), 3000), + "por_tarea": por_tarea} + if not (resp["hecho"] or resp["falta"] or resp["bloqueos"] or por_tarea): + return jsonify(ok=False, error="Contesta al menos una pregunta o marca cómo va un pendiente."), 400 + cambio = "contestó" if not reg.get("respuesta") else "corrigió su respuesta" + reg["respuesta"] = resp + hist(reg, u, cambio) + g["_escribir_json"](ruta(fecha, u), reg) + return jsonify(ok=True, standup=reg) + + @app.post("/api/standup/preparar") + def standup_preparar(): + u = yo() + if not u or not (u.lower() in preparan or g["is_admin"]()): + return jsonify(ok=False, error="Solo el bot (claude) o un administrador prepara el standup."), 403 + d = g["_json_body"]() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + fecha = fecha_valida(d.get("fecha")) or hoy() + miembros = g["_miembros"]() + hechos, omitidos = [], [] + for p in d.get("personas") or []: + uid = g["_norm_id"](p.get("usuario")) + if uid not in miembros: + omitidos.append({"usuario": uid, "por": "no es miembro del equipo"}) + continue + previo = g["_leer_json"](ruta(fecha, uid)) + if previo and previo.get("respuesta"): + omitidos.append({"usuario": uid, "por": "ya contestó: no se reescribe"}) + continue + instr = [] + for x in p.get("instrucciones") or []: + if isinstance(x, str): + x = {"texto": x} + if isinstance(x, dict) and texto(x.get("texto")): + liga = texto(x.get("liga"), 300) + if liga.lower().startswith(("javascript:", "data:")): + liga = "" + instr.append({"texto": texto(x.get("texto"), 600), "tarea_id": texto(x.get("tarea_id"), 60), "liga": liga}) + preg = [] + for x in p.get("preguntas") or []: + if isinstance(x, dict) and (x.get("tarea_id") or x.get("pregunta")): + preg.append({"tarea_id": texto(x.get("tarea_id"), 60), "titulo": texto(x.get("titulo"), 200), + "pregunta": texto(x.get("pregunta"), 400)}) + reg = {"fecha": fecha, "usuario": uid, "nombre": miembros[uid].get("nombre") or uid, "preparado_por": u, + "preparado_en": ahora(), "instrucciones": instr[:12], "preguntas": preg[:15], + "nota_bot": texto(p.get("nota_bot"), 1200), "respuesta": None, "historial": (previo or {}).get("historial", [])} + hist(reg, u, "preparó el standup" if not previo else "rehízo el standup") + g["_escribir_json"](ruta(fecha, uid), reg) + hechos.append(uid) + return jsonify(ok=True, fecha=fecha, preparados=hechos, omitidos=omitidos) + + @app.get("/api/standup") + def standup_equipo(): + u = yo() + if not u: + return g["_sin_usuario"]() + if not (u.lower() in lectores or g["is_admin"]()): + return jsonify(ok=False, error="La vista del equipo es para Leo, Adriana y el bot."), 403 + fecha = fecha_valida(request.args.get("fecha")) or hoy() + base = os.path.join(carpeta, fecha) + regs = g["_listar_json"](base) if os.path.isdir(base) else [] + fechas = sorted([f for f in os.listdir(carpeta) if g["FECHA_RE"].match(f)], reverse=True)[:30] if os.path.isdir(carpeta) else [] + return jsonify(ok=True, fecha=fecha, fechas=fechas, standups=regs, + contestaron=sum(1 for r in regs if r.get("respuesta")), total=len(regs)) + + g["_standup"] = {"mis_pendientes": mis_pendientes, "carpeta": carpeta} diff --git a/backend/tests-1027.sh b/backend/tests-1027.sh new file mode 100755 index 0000000..b80d902 --- /dev/null +++ b/backend/tests-1027.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Pruebas del 27-oct: página de entrada (acceso.py), incidencias (incidencias.py), standup (standup.py), vigía y cuentas. +# Arranca app.py con carpetas y htpasswd temporales (como tests-1023.sh). No toca el servidor. +# Uso: bash backend/tests-1027.sh [puerto] +set -u +REPO=${1:?ruta a hbox-docs}; PORT=${2:-8097} +T=$(mktemp -d); export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock SESION_COOKIE_INSEGURA=1 \ + ACTAS_ADMINS=leo,gibranlp PORT INCIDENCIAS_DIR=$T/incidencias +mkdir -p $ACTAS_DIR $T/equipo $T/tareas +python3 - "$REPO" "$T" <<'EOF' +import sys, os, json, importlib.util +repo, t = sys.argv[1], sys.argv[2] +spec = importlib.util.spec_from_file_location("app", os.path.join(repo, "backend", "app.py")); A = importlib.util.module_from_spec(spec); spec.loader.exec_module(A) +with open(os.path.join(t, "htpasswd"), "w") as f: + for u, p in {"leo": "helgen-2026-leo", "adriana": "Puerto-Cobre-1234", "jesush": "faro-ancla-9911"}.items(): + f.write(u + ":" + A.apr1(p, ("salt" + u)[:8]) + "\n") +for u, n, c in [("leo", "Leo", "leo@helgen.tech"), ("adriana", "Adriana", "adriana@helgen.tech"), ("jesush", "Jesús Honorato", "jesus@helgen.tech"), ("claude", "Claude", "")]: + json.dump({"id": u, "nombre": n, "area": "ingenieria", "correo": c, "activo": True}, open(os.path.join(t, "equipo", u + ".json"), "w")) +for i, (ti, r, e) in enumerate([("Revisar kit", "adriana", "por-hacer"), ("Instalar 2 en TIMSA", "jesush", "en-curso"), ("Cotizar gabinetes", "adriana, leo", "bloqueado")]): + json.dump({"id": f"t-p{i}", "titulo": ti, "responsable": r, "estado": e, "pista": "compras", "sitios": ["qro"], "historial": []}, open(os.path.join(t, "tareas", f"t-p{i}.json"), "w")) +EOF +python3 $REPO/backend/app.py > $T/app.log 2>&1 & +APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8 +U="http://127.0.0.1:$PORT"; MOZ="Mozilla/5.0 (pruebas)" +ok=0; fail=0 +check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:200}"; fi; } +J() { echo "-H Content-Type:application/json"; } +as() { curl -s -H "X-Remote-User: $1" -H "Content-Type: application/json" "${@:2}"; } + +echo "## entrada (acceso.py)" +check "módulos cargados" "" "$(grep -i 'no se cargó' $T/app.log)" +check "_auth sin sesión 401" "401" "$(curl -s -o /dev/null -w '%{http_code}' $U/api/_auth)" +check "entrar con mayúscula y espacios" '"user":"adriana"' "$(curl -s -A "$MOZ" -c $T/j $(J) -d '{"usuario":" Adriana","password":"Puerto-Cobre-1234"}' $U/api/entrar)" +check "_auth con cookie 204 + X-Hbox-User" "X-Hbox-User: adriana" "$(curl -s -i -b $T/j $U/api/_auth | tr -d '\r')" +check "entrar por correo" '"user":"jesush"' "$(curl -s -A "$MOZ" -c $T/j2 $(J) -d '{"usuario":"jesus@helgen.tech","password":"faro-ancla-9911"}' $U/api/entrar)" +check "contraseña mala con Bloq Mayús" '"codigo":"contrasena"' "$(curl -s -A "$MOZ" $(J) -d '{"usuario":"jesush","password":"FARO-ANCLA-9911"}' $U/api/entrar)" +check "pista bloq-mayus registrada" "bloq-mayus" "$(cat $T/acceso/intentos-*.jsonl)" +check "la contraseña nunca se guarda" "" "$(grep -i 'faro-ancla\|FARO-ANCLA' $T/acceso/intentos-*.jsonl)" +curl -s -o /dev/null -A "$MOZ" $(J) -d '{"usuario":"mi-clave-secreta-123","password":"x"}' $U/api/entrar +check "lo tecleado en «usuario» se guarda enmascarado" "mi-…(20)" "$(cat $T/acceso/intentos-*.jsonl)" +check "…y nunca completo" "" "$(grep 'mi-clave-secreta' $T/acceso/intentos-*.jsonl)" +for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -A "$MOZ" -H 'X-Real-IP: 10.1.1.1' $(J) -d '{"usuario":"leo","password":"mala-1234"}' $U/api/entrar; done +check "8 fallas de leo desde una IP: esa IP queda en espera" '"codigo":"bloqueado"' "$(curl -s -A "$MOZ" -H 'X-Real-IP: 10.1.1.1' $(J) -d '{"usuario":"leo","password":"helgen-2026-leo"}' $U/api/entrar)" +check "…pero leo entra desde su IP (nadie lo deja fuera)" '"user":"leo"' "$(curl -s -A "$MOZ" -H 'X-Real-IP: 10.2.2.2' $(J) -d '{"usuario":"leo","password":"helgen-2026-leo"}' $U/api/entrar)" +check "Basic ya no entra (ni de script)" "401" "$(curl -s -o /dev/null -w '%{http_code}' -A curl -u leo:helgen-2026-leo $U/api/_auth)" +check "Basic de navegador no" "401" "$(curl -s -o /dev/null -w '%{http_code}' -A "$MOZ" -u leo:helgen-2026-leo $U/api/_auth)" +check "cookie con acento → 401 (no 500)" "401" "$(curl -s -o /dev/null -w '%{http_code}' -b 'hbox_sesion=é~1~2~3' $U/api/_auth)" +check "POST desde otro sitio → 403" "otro sitio" "$(curl -s -H 'X-Remote-User: adriana' -H 'Origin: https://malo.example' $(J) -d '{"descripcion":"csrf"}' $U/api/incidencias)" +check "POST con Sec-Fetch-Site same-site → 403" "otro sitio" "$(curl -s -H 'X-Remote-User: adriana' -H 'Sec-Fetch-Site: same-site' $(J) -d '{"descripcion":"csrf"}' $U/api/incidencias)" +check "cambio propio: cookie nueva" "hbox_sesion=adriana" "$(curl -s -i -b $T/j -c $T/j -H 'X-Remote-User: adriana' $(J) -d '{"actual":"Puerto-Cobre-1234","nueva":"marea-radar-5521","confirma":"marea-radar-5521"}' $U/api/password | tr -d '\r')" +check "la cookie nueva vale" "204" "$(curl -s -o /dev/null -w '%{http_code}' -b $T/j $U/api/_auth)" +check "reset de admin mata la sesión de jesush" "401" "$(as leo -d '{"usuario":"jesush","nueva":"cometa-trigo-4410","confirma":"cometa-trigo-4410"}' $U/api/password >/dev/null; curl -s -o /dev/null -w '%{http_code}' -b $T/j2 $U/api/_auth)" +check "salir" "hbox_sesion=;" "$(curl -s -i -X POST -b $T/j $U/api/salir | tr -d '\r')" + +echo "## incidencias" +check "crear (con contexto)" '"id":"INC-0001"' "$(as adriana -d '{"descripcion":"El inventario no carga en el teléfono","tipo":"error","pagina":"/tableros/inventario/","contexto":{"errores":[{"mensaje":"x is undefined","fuente":"inventario.js:12"}]}}' $U/api/incidencias)" +check "sin descripción → 400" "aunque sea una l" "$(as adriana -d '{"tipo":"error"}' $U/api/incidencias)" +check "resumen" '"abiertas":1' "$(as jesush $U/api/incidencias/resumen)" +check "lista abiertas" '"INC-0001"' "$(as jesush $U/api/incidencias)" +check "comentar cualquiera" '"comentarios":[{' "$(as jesush -X PATCH -d '{"comentario":"A mí también"}' "$U/api/incidencia?id=INC-0001")" +check "no editor no cambia gravedad" "Solo quien atiende" "$(as jesush -X PATCH -d '{"gravedad":"baja"}' "$U/api/incidencia?id=INC-0001")" +check "claude atiende: en curso + asignado" '"estado":"en-curso"' "$(as claude -X PATCH -d '{"estado":"en-curso","asignado":"claude","comentario":"Lo reproduje: falta una validación."}' "$U/api/incidencia?id=INC-0001")" +check "quien reportó la marca resuelta" '"estado":"resuelta"' "$(as adriana -X PATCH -d '{"estado":"resuelta"}' "$U/api/incidencia?id=INC-0001")" +check "vigía abre con firma" '"nueva":true' "$(as vigia -d '{"titulo":"leo no puede entrar","descripcion":"3 fallas","tipo":"acceso","origen":"vigia","firma":"acceso:leo"}' $U/api/incidencias)" +check "misma firma suma ocurrencia" '"ocurrencias":2' "$(as vigia -d '{"titulo":"leo no puede entrar","descripcion":"4 fallas","tipo":"acceso","origen":"vigia","firma":"acceso:leo"}' $U/api/incidencias)" +check "un usuario normal no se hace pasar por vigía" '"origen": "sitio"' "$(as adriana -d '{"descripcion":"prueba de origen","origen":"vigia","firma":"x"}' $U/api/incidencias >/dev/null; python3 -c "import json,glob; print(json.dumps([json.load(open(f)) for f in sorted(glob.glob('$T/incidencias/INC-*.json'))][-1]))")" +check "reporte público (sin sesión)" '"ok":true' "$(curl -s $(J) -d '{"nombre":"miguel","contacto":"333 000 0000","descripcion":"No puedo entrar desde ayer","tipo":"acceso"}' $U/api/incidencias/publica)" +check "público: campo trampa (robot) no guarda" '"INC-0000"' "$(curl -s $(J) -d '{"nombre":"x","descripcion":"spam spam","sitio_web":"http://spam"}' $U/api/incidencias/publica)" +check "avisos públicos vacíos" '"avisos":[]' "$(curl -s $U/api/incidencias/publicas)" +check "publicar aviso" '"publica":true' "$(as leo -X PATCH -d '{"publica":true,"resumen_publico":"Algunas personas no podían entrar; ya hay página de entrada nueva."}' "$U/api/incidencia?id=INC-0002")" +check "aviso visible sin sesión (texto público)" "entrada nueva" "$(curl -s $U/api/incidencias/publicas)" +check "publicar sin texto público → 400" "texto corto" "$(as leo -X PATCH -d '{"publica":true}' "$U/api/incidencia?id=INC-0001")" +check "error de navegador" '"ok":true' "$(as jesush -d '{"tipo":"js","mensaje":"TypeError: a is null","fuente":"/tableros/kanban.js","linea":40,"pagina":"/tableros/pendientes/"}' $U/api/incidencias/error)" +check "errores agrupados (editor)" "TypeError" "$(as leo $U/api/incidencias/errores)" +check "errores: no editor 403" "Solo quien atiende" "$(as jesush $U/api/incidencias/errores)" + +echo "## standup" +check "genérico sin bot: mis pendientes" "Revisar kit" "$(as adriana $U/api/standup/hoy)" +check "estado: nada preparado" '"preparado":false' "$(as adriana $U/api/standup/estado)" +check "solo el bot prepara" "Solo el bot" "$(as adriana -d '{"personas":[]}' $U/api/standup/preparar)" +HOY=$(date +%F) +check "el bot prepara" '"preparados":["adriana","jesush"]' "$(as claude -d '{"fecha":"'$HOY'","personas":[{"usuario":"adriana","instrucciones":["Cierra el kit con Leo","Revisa la cotización de gabinetes"],"preguntas":[{"tarea_id":"t-p0","titulo":"Revisar kit","pregunta":"¿Ya tienes las cantidades por sitio?"}]},{"usuario":"jesush","instrucciones":[{"texto":"Instala H0031 en la RTG 12","liga":"/formularios/instalacion.html"}],"preguntas":[{"tarea_id":"t-p1","titulo":"Instalar 2"}]},{"usuario":"nadie","instrucciones":["x"]}]}' $U/api/standup/preparar)" +check "estado: pendiente" '"pendiente":true' "$(as adriana $U/api/standup/estado)" +check "instrucciones del bot" "Cierra el kit" "$(as adriana $U/api/standup/hoy)" +check "respuesta vacía → 400" "Contesta" "$(as adriana -d '{"fecha":"'$HOY'"}' $U/api/standup/respuesta)" +check "contestar" '"enviada_en"' "$(as adriana -d '{"fecha":"'$HOY'","hecho":"Kit listo","falta":"Precios","bloqueos":"Espero a Leo","por_tarea":[{"tarea_id":"t-p0","estado":"hecho","nota":"ok"}]}' $U/api/standup/respuesta)" +check "ya no pendiente" '"pendiente":false' "$(as adriana $U/api/standup/estado)" +check "el bot no reescribe a quien ya contestó" "no se reescribe" "$(as claude -d '{"fecha":"'$HOY'","personas":[{"usuario":"adriana","instrucciones":["otra"]}]}' $U/api/standup/preparar)" +check "vista equipo (leo)" '"contestaron":1' "$(as leo "$U/api/standup?fecha=$HOY")" +check "vista equipo: jesush no" "La vista del equipo" "$(as jesush $U/api/standup)" + +echo "## vigía (en seco)" +cp $REPO/backend/actas-hbox.service $T/ 2>/dev/null +check "vigía corre" '"hallazgos"' "$(VIGIA_API=$U INCIDENCIAS_DIR=$T/incidencias python3 $REPO/backend/vigia.py --seco 2>&1 | tail -1)" + +echo "## sitios con código de 3 letras" +check "ZLO = TIMSA en pendientes" '"ok":true' "$(as leo "$U/api/tareas?sitio=zlo")" + +echo +echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T" +[ "$fail" = 0 ] diff --git a/backend/vigia.py b/backend/vigia.py new file mode 100644 index 0000000..2dc00da --- /dev/null +++ b/backend/vigia.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Vigía del sitio (27-oct-2026): revisa cada hora lo que puede fallar y abre incidencias solo. No usa IA. + +Corre EN EL SERVIDOR como leo, con cron (crontab de leo, sin sudo): + 7 * * * * cd /srv/projects/drogheda/hbox-docs/backend && venv/bin/python3 vigia.py >> /srv/projects/drogheda/incidencias/_vigia/vigia.log 2>&1 +A mano: venv/bin/python3 vigia.py --seco (dice qué abriría, sin abrir nada) + +Qué revisa (cada hallazgo lleva una «firma»: si ya hay una incidencia abierta con esa firma, solo le suma una ocurrencia): + 1. Entradas: alguien con 3 o más fallas en 24 h y sin entrar después → «X no puede entrar» (alta), con el motivo y las + pistas (Bloq Mayús, mayúscula inicial, espacio de más). Muchos usuarios que no existen → posible robot (baja). + 2. Servidor: excepciones de Python y respuestas 5xx del backend (incidencias/_errores/servidor-*.jsonl). + 3. Navegadores: el mismo error de JavaScript o la misma llamada fallida 3 veces o por 2 personas (cliente-*.jsonl). + 4. Salud: /api/salud responde; el htpasswd es leo:www-data 640; hay espacio en /srv; el secreto de sesión existe. + 5. Configuración: el servicio instalado (/etc/systemd/system/actas-hbox.service) dice lo mismo que el del repo + (ACTAS_ADMINS, ACTAS_REPETIBLES, workers) — el 27-oct se instaló a mano uno viejo sin prueba-ethernet-serie. +Deja el reporte de la corrida en incidencias/_vigia/AAAA-MM-DD.json (lo lee el agente diario). +""" +import argparse +import datetime +import grp +import hashlib +import json +import os +import pwd +import shutil +import stat +import sys +import urllib.error +import urllib.request + +AQUI = os.path.dirname(os.path.abspath(__file__)) +API = os.environ.get("VIGIA_API", "http://127.0.0.1:8081") +USUARIO = "vigia" + + +def entorno_servicio(ruta): + out = {} + if not os.path.isfile(ruta): + return out + with open(ruta, encoding="utf-8") as f: + for linea in f: + linea = linea.strip() + if linea.startswith("Environment="): + k, _, v = linea[len("Environment="):].partition("=") + out[k.strip()] = v.strip() + elif linea.startswith("ExecStart="): + out["ExecStart"] = linea[len("ExecStart="):] + return out + + +ENV_REPO = entorno_servicio(os.path.join(AQUI, "actas-hbox.service")) +DATOS = os.path.dirname((os.environ.get("ACTAS_DIR") or ENV_REPO.get("ACTAS_DIR", "/srv/projects/drogheda/actas")).rstrip("/")) \ + or "/srv/projects/drogheda" +INC = os.environ.get("INCIDENCIAS_DIR", os.path.join(DATOS, "incidencias")) +HTPASSWD = ENV_REPO.get("HTPASSWD_FILE", "/etc/nginx/hbox-docs.htpasswd") +SERVICIO_VIVO = "/etc/systemd/system/actas-hbox.service" + + +def huella(*partes): + return hashlib.sha1("|".join(str(p) for p in partes).encode()).hexdigest()[:10] + + +def leer_jsonl(ruta): + out = [] + if os.path.isfile(ruta): + with open(ruta, encoding="utf-8", errors="ignore") as f: + for linea in f: + try: + out.append(json.loads(linea)) + except Exception: # noqa: BLE001 + pass + return out + + +def api(metodo, ruta, cuerpo=None): + req = urllib.request.Request(API + ruta, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None, + headers={"X-Remote-User": USUARIO, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return r.status, json.loads(r.read().decode() or "{}") + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode() or "{}") + except Exception: # noqa: BLE001 + return e.code, {} + except Exception as e: # noqa: BLE001 + return 0, {"error": str(e)} + + +def revisar_entradas(ahora): + hallazgos = [] + desde = (ahora - datetime.timedelta(hours=24)).replace(microsecond=0).isoformat() + filas = [] + for mes in {ahora.strftime("%Y-%m"), (ahora - datetime.timedelta(days=1)).strftime("%Y-%m")}: + filas += [r for r in leer_jsonl(os.path.join(DATOS, "acceso", f"intentos-{mes}.jsonl")) if str(r.get("cuando", "")) >= desde] + filas.sort(key=lambda r: r.get("cuando", "")) + por_usuario = {} + desconocidos = set() + for r in filas: + u = r.get("usuario") + if r.get("resultado") == "usuario-no-existe": + desconocidos.add((r.get("tecleado") or "").lower()[:30]) + continue + if not u: + continue + p = por_usuario.setdefault(u, {"fallas": 0, "motivos": set(), "pistas": set(), "ultima_falla": None, "ok_despues": False}) + if r.get("resultado") == "ok": + if p["fallas"]: + p["ok_despues"] = True + else: + p["fallas"] += 1 + p["ok_despues"] = False + p["motivos"].add(r.get("resultado")) + p["pistas"].update(r.get("pistas") or []) + p["ultima_falla"] = r.get("cuando") + for u, p in por_usuario.items(): + if p["fallas"] >= 3 and not p["ok_despues"]: + pistas = ", ".join(sorted(p["pistas"])) or "ninguna" + hallazgos.append({ + "firma": "acceso:" + u, "tipo": "acceso", "gravedad": "alta", + "titulo": f"{u} no puede entrar al sitio ({p['fallas']} intentos fallidos en 24 h)", + "descripcion": (f"El vigía vio {p['fallas']} intentos fallidos de «{u}» en las últimas 24 horas y ninguno bueno " + f"después. Motivo: {', '.join(sorted(m for m in p['motivos'] if m))}. Pistas: {pistas} " + "(bloq-mayus = escribió con Bloq Mayús; mayuscula-inicial = el teléfono le puso mayúscula; " + "espacios-alrededor = un espacio de más). Si no hay pista, probablemente usa una contraseña " + "vieja guardada en el navegador o no le llegó la nueva."), + "evidencia": [f"última falla {p['ultima_falla']}; pistas: {pistas}"]}) + if len(desconocidos) >= 8: + hallazgos.append({"firma": "acceso:desconocidos", "tipo": "acceso", "gravedad": "baja", + "titulo": f"{len(desconocidos)} usuarios que no existen intentaron entrar en 24 h (¿robot?)", + "descripcion": "Intentos con usuarios que no existen. Si son nombres raros, es un robot y el límite de " + "intentos ya lo frena; si son nombres del equipo mal escritos, avisarles su usuario.", + "evidencia": [", ".join(sorted(desconocidos))[:500]]}) + return hallazgos, {"intentos_24h": len(filas), "usuarios_con_fallas": {u: p["fallas"] for u, p in por_usuario.items() if p["fallas"]}} + + +def revisar_errores(ahora): + hallazgos = [] + dias = [ahora.date(), ahora.date() - datetime.timedelta(days=1)] + srv, cli = [], [] + for d in dias: + srv += leer_jsonl(os.path.join(INC, "_errores", f"servidor-{d:%Y-%m-%d}.jsonl")) + cli += leer_jsonl(os.path.join(INC, "_errores", f"cliente-{d:%Y-%m-%d}.jsonl")) + grupos = {} + for e in srv: + k = huella(e.get("tipo"), e.get("ruta"), (e.get("error") or "")[:120]) + gr = grupos.setdefault(k, {"n": 0, "e": e, "usuarios": set()}) + gr["n"] += 1 + gr["usuarios"].add(e.get("usuario") or "") + for k, gr in grupos.items(): + e = gr["e"] + exc = e.get("tipo") == "excepcion" + hallazgos.append({ + "firma": "srv:" + k, "tipo": "error", "gravedad": "alta" if exc else "media", + "titulo": f"Servidor: {'excepción' if exc else e.get('tipo')} en {e.get('metodo', '')} {e.get('ruta', '')}"[:140], + "descripcion": f"{gr['n']} vez/veces en 2 días; usuarios: {', '.join(sorted(u for u in gr['usuarios'] if u)) or '—'}.\n" + f"Error: {e.get('error', '')}\n\nTraza (la última):\n{(e.get('traza') or '')[-1500:]}", + "pagina": e.get("ruta", ""), "evidencia": [f"{e.get('cuando')} {e.get('usuario', '')}: {e.get('error', '')}"[:500]]}) + grupos = {} + for e in cli: + k = huella(e.get("tipo"), e.get("mensaje"), e.get("fuente"), e.get("linea"), e.get("url_api")) + gr = grupos.setdefault(k, {"n": 0, "e": e, "usuarios": set(), "paginas": set()}) + gr["n"] += 1 + gr["usuarios"].add(e.get("usuario") or "") + gr["paginas"].add(e.get("pagina") or "") + for k, gr in grupos.items(): + if gr["n"] < 3 and len(gr["usuarios"]) < 2: + continue + e = gr["e"] + hallazgos.append({ + "firma": "js:" + k, "tipo": "error", "gravedad": "media", + "titulo": f"Navegador: {e.get('mensaje', '')}"[:140], + "descripcion": f"{gr['n']} veces en 2 días, {len(gr['usuarios'])} persona(s): {', '.join(sorted(gr['usuarios']))}.\n" + f"Páginas: {', '.join(sorted(gr['paginas']))[:400]}\nFuente: {e.get('fuente', '')}:{e.get('linea', '')}\n" + f"Navegador: {e.get('navegador', '')}\n\n{(e.get('pila') or '')[:1000]}", + "pagina": e.get("pagina", ""), "evidencia": [f"{e.get('cuando')} {e.get('usuario')}"]}) + return hallazgos, {"errores_servidor": len(srv), "errores_cliente": len(cli)} + + +def revisar_salud(): + hallazgos, datos = [], {} + cod, j = api("GET", "/api/salud") + datos["salud"] = cod + if cod != 200: + hallazgos.append({"firma": "salud:backend", "tipo": "error", "gravedad": "alta", + "titulo": "El backend (actas-hbox) no responde en 127.0.0.1:8081", + "descripcion": f"GET /api/salud → {cod} {j}. Sin backend no se puede entrar al sitio. " + "Leo: ssh leo@git.helgen.online 'sudo systemctl status actas-hbox'."}) + try: + st = os.stat(HTPASSWD) + dueno, grupo, modo = pwd.getpwuid(st.st_uid).pw_name, grp.getgrgid(st.st_gid).gr_name, stat.S_IMODE(st.st_mode) + datos["htpasswd"] = f"{dueno}:{grupo} {oct(modo)}" + if (dueno, grupo, modo) != ("leo", "www-data", 0o640): + hallazgos.append({"firma": "salud:htpasswd", "tipo": "acceso", "gravedad": "alta", + "titulo": f"El archivo de usuarios quedó {dueno}:{grupo} {oct(modo)} (debe ser leo:www-data 640)", + "descripcion": "Con otros permisos, Mi cuenta y Equipo no pueden cambiar contraseñas, o nginx no " + "puede leerlo. Leo: sudo chown leo:www-data /etc/nginx/hbox-docs.htpasswd && " + "sudo chmod 640 /etc/nginx/hbox-docs.htpasswd"}) + except OSError as e: + datos["htpasswd"] = str(e) + uso = shutil.disk_usage(DATOS) + datos["disco_libre_pct"] = round(100 * uso.free / uso.total, 1) + if uso.free / uso.total < 0.10: + hallazgos.append({"firma": "salud:disco", "tipo": "error", "gravedad": "alta", + "titulo": f"Queda {datos['disco_libre_pct']} % de disco en {DATOS}", + "descripcion": "Con el disco lleno no se guardan actas, fotos ni pendientes."}) + return hallazgos, datos + + +def revisar_configuracion(): + hallazgos = [] + vivo = entorno_servicio(SERVICIO_VIVO) + if not vivo or not ENV_REPO: + return hallazgos, {"servicio": "sin datos"} + difs = [] + for k in ("ACTAS_ADMINS", "ACTAS_REPETIBLES", "HTPASSWD_FILE", "PASS_MIN", "ExecStart"): + a = set((vivo.get(k) or "").split(",")) if k in ("ACTAS_ADMINS", "ACTAS_REPETIBLES") else vivo.get(k) + b = set((ENV_REPO.get(k) or "").split(",")) if k in ("ACTAS_ADMINS", "ACTAS_REPETIBLES") else ENV_REPO.get(k) + if a != b: + if isinstance(a, set): + difs.append(f"{k}: instalado sin {sorted(b - a) or '—'}, con de más {sorted(a - b) or '—'}") + else: + difs.append(f"{k}: instalado «{a}» · repo «{b}»") + if difs: + hallazgos.append({"firma": "config:servicio:" + huella(*difs), "tipo": "datos", "gravedad": "media", + "titulo": "El servicio instalado no coincide con backend/actas-hbox.service del repo", + "descripcion": "Diferencias:\n- " + "\n- ".join(difs) + "\n\nUno de los dos está mal: si el cambio " + "del servidor es bueno, súbelo al repo; si no, instala el del repo (DESPLIEGUE.md)."}) + return hallazgos, {"servicio_difs": difs} + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Vigía del sitio: abre incidencias de lo que falla.") + ap.add_argument("--seco", action="store_true", help="solo decir qué abriría") + a = ap.parse_args(argv) + ahora = datetime.datetime.now() + hallazgos, datos = [], {} + for f in (revisar_entradas, revisar_errores, revisar_salud, revisar_configuracion): + try: + h, d = f(ahora) if f in (revisar_entradas, revisar_errores) else f() + hallazgos += h + datos.update(d) + except Exception as e: # noqa: BLE001 + datos[f.__name__] = f"falló: {e}" + resultado = [] + for h in hallazgos: + if a.seco: + resultado.append({"firma": h["firma"], "titulo": h["titulo"], "accion": "(seco)"}) + continue + cuerpo = dict(h, origen="vigia") + cod, j = api("POST", "/api/incidencias", cuerpo) + resultado.append({"firma": h["firma"], "titulo": h["titulo"], "http": cod, "id": j.get("id"), + "nueva": j.get("nueva"), "error": j.get("error")}) + reporte = {"cuando": ahora.replace(microsecond=0).isoformat(), "datos": datos, "hallazgos": resultado} + if not a.seco: + carpeta = os.path.join(INC, "_vigia") + os.makedirs(carpeta, exist_ok=True) + with open(os.path.join(carpeta, f"{ahora:%Y-%m-%d}.json"), "w", encoding="utf-8") as f: + json.dump(reporte, f, ensure_ascii=False, indent=2) + print(json.dumps(reporte, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/admin.html b/docs/admin.html index a6d4dd0..623e5c4 100644 --- a/docs/admin.html +++ b/docs/admin.html @@ -5,6 +5,7 @@ Administración — HBOX +