hbox-docs/backend/vigia.py

266 lines
14 KiB
Python

#!/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())