Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
266 lines
14 KiB
Python
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())
|