#!/usr/bin/env python3 """Cuentas del sitio en bloque (23-oct-2026): restablecer contraseñas y dar de alta a varios de una vez. Se corre EN EL SERVIDOR como leo (el dueño de /etc/nginx/hbox-docs.htpasswd), junto al app.py desplegado: cd /srv/projects/drogheda/hbox-docs/backend venv/bin/python3 cuentas.py lista # quién tiene acceso (sin contraseñas) venv/bin/python3 cuentas.py todo cuentas/2026-10-23_alta-sitios.json # altas + datos + restablecer a todos venv/bin/python3 cuentas.py alta cuentas/ARCHIVO.json # solo las altas y los datos del archivo venv/bin/python3 cuentas.py restablecer [--excepto leo,claude] [--solo ana,beto] La contraseña se pide en la terminal (dos veces, sin eco): nunca va en un archivo ni como argumento, porque se vería en `ps`. Para pruebas existe --password-stdin (la lee de la entrada estándar). Antes de escribir muestra el plan y pide «si» (o --si). Usa las mismas funciones y rutas que el servicio (lee las Environment= de actas-hbox.service): hash $apr1$, el candado del archivo y los miembros del equipo. Antes de escribir deja un respaldo del archivo de usuarios en equipo/_respaldos/ (modo 600, contiene los hashes: no lo copies a otro lado). «todo» y «restablecer» tocan a todos los que tienen acceso, salvo --excepto (por omisión leo y claude) y salvo las cuentas sin miembro activo, que se listan para que decidas si se quitan (Equipo → «Quitar acceso»). A quien se le restablece o se le da de alta le queda la marca «contraseña inicial»: el sitio le muestra un aviso para cambiarla en Mi cuenta y Equipo (admin) muestra quién falta. Archivo de altas (JSON): {"alta": [{"usuario": "jesush", "nombre": "…", "area": "campo", "sitio": "timsa", "rol": "…", "correo": "…"}], "actualizar": [{"usuario": "jesus", "nombre": "Jesús Romero", "correo": "…"}]} En «actualizar» (y en las altas de quien ya era miembro) correo, correo_alt, sitio, rol y teléfono solo se llenan si están vacíos; el nombre solo si el nuevo completa al actual («Jesús» → «Jesús Romero»). Nada se borra. """ import argparse import getpass import json import os import shutil import sys import unicodedata AQUI = os.path.dirname(os.path.abspath(__file__)) EXCEPTO = "leo,claude" CAMPOS_SI_VACIO = ("correo", "correo_alt", "sitio", "rol", "telefono") def _entorno_del_servicio(): """Las mismas rutas que el servicio: las Environment= de actas-hbox.service, sin pisar lo que ya venga fijado.""" ruta = os.path.join(AQUI, "actas-hbox.service") if not os.path.isfile(ruta): return 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("=") if k.strip(): os.environ.setdefault(k.strip(), v.strip()) _entorno_del_servicio() sys.path.insert(0, AQUI) import app as A # noqa: E402 (después de fijar el entorno: app.py lee las rutas al importarse) def _plano(s): return unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower().strip() def _nombre_sitio(s): return {i: c for i, _, c in A.SITIOS_TAREA}.get(s, s or "—") def _logins(): lineas = A._leer_htpasswd() if lineas is None: sys.exit(f"No encuentro el archivo de usuarios: {A.HTPASSWD}") return [l.split(":", 1)[0].strip() for l in lineas if ":" in l and not l.lstrip().startswith("#")] def _leer_archivo(ruta): if not ruta: return {"alta": [], "actualizar": []} with open(ruta, encoding="utf-8") as f: d = json.load(f) alta, act = d.get("alta") or [], d.get("actualizar") or [] vistos = set() for p in alta + act: u = str(p.get("usuario") or "").strip().lower() if not A.USUARIO_RE.match(u): sys.exit(f"Usuario inválido en {ruta}: {u!r} (minúsculas, sin espacios ni acentos)") if u in vistos: sys.exit(f"{u} aparece dos veces en {ruta}") vistos.add(u) p["usuario"] = u datos = {k: v for k, v in p.items() if k != "usuario"} _, err = A._miembro_valido(datos, {"nombre": u, "area": "produccion"} if p in act else None) if err: sys.exit(f"{u}: {err}") return {"alta": alta, "actualizar": act} def _cambios_de_datos(base, nuevo): """Qué se llena de «nuevo» en el miembro «base» sin pisar nada que ya tenga.""" c = {} for k in CAMPOS_SI_VACIO: v = str(nuevo.get(k) or "").strip() if v and not str(base.get(k) or "").strip(): c[k] = v n = str(nuevo.get("nombre") or "").strip() if n and _plano(n) != _plano(base.get("nombre")) and _plano(n).startswith(_plano(base.get("nombre"))): c["nombre"] = n return c def hacer_plan(accion, archivo, excepto, solo): logins = _logins() presentes = {u.lower() for u in logins} miembros = A._miembros() d = _leer_archivo(archivo) excepto = {x.strip().lower() for x in (excepto or "").split(",") if x.strip()} solo = {x.strip().lower() for x in (solo or "").split(",") if x.strip()} plan = {"crear": [], "restablecer": [], "omitidos": [], "sin_miembro": [], "datos": [], "logins": len(logins)} if accion in ("todo", "alta"): for p in d["alta"]: if p["usuario"] in presentes: # ya tenía acceso: solo se completan sus datos base = miembros.get(p["usuario"]) c = _cambios_de_datos(base, p) if base else {k: v for k, v in p.items() if k != "usuario"} if c: plan["datos"].append((p["usuario"], c, base is None)) else: plan["crear"].append(p) for p in d["actualizar"]: base = miembros.get(p["usuario"]) if not base: plan["omitidos"].append((p["usuario"], "no es miembro del equipo: no se actualiza")) continue c = _cambios_de_datos(base, p) if c: plan["datos"].append((p["usuario"], c, False)) if accion in ("todo", "restablecer"): nuevos = {p["usuario"] for p in plan["crear"]} for u in sorted(presentes): if u in nuevos: continue if solo and u not in solo: continue if u in excepto: plan["omitidos"].append((u, "excepto")) continue m = miembros.get(u) if not m or m.get("activo", True) is False: plan["sin_miembro"].append(u) continue plan["restablecer"].append(u) for u in sorted(solo - presentes): plan["omitidos"].append((u, "no tiene acceso al sitio")) return plan def mostrar_plan(plan): print(f"Archivo de usuarios: {A.HTPASSWD} ({plan['logins']} cuentas)") print(f"Miembros del equipo: {A.EQUIPO_DIR}") if plan["crear"]: print(f"\nAltas nuevas ({len(plan['crear'])}):") for p in plan["crear"]: print(f" + {p['usuario']:<10} {p.get('nombre', '')} · {p.get('area', 'produccion')} · {_nombre_sitio(p.get('sitio'))}" + (f" · {p['correo']}" if p.get("correo") else " · (sin correo)")) if plan["restablecer"]: print(f"\nContraseña restablecida ({len(plan['restablecer'])}): " + ", ".join(plan["restablecer"])) if plan["datos"]: print(f"\nDatos de miembros ({len(plan['datos'])}; solo se llena lo vacío):") for u, c, nuevo in plan["datos"]: print(f" ~ {u:<10} " + ("(miembro nuevo) " if nuevo else "") + "; ".join(f"{k} → {v}" for k, v in c.items())) for u, por in plan["omitidos"]: print(f" · no se toca {u}: {por}") if plan["sin_miembro"]: print("\nCuentas con acceso pero sin miembro activo (no se tocan; si ya no se usan, quítalas en Equipo → " "«Quitar acceso» o con htpasswd -D): " + ", ".join(plan["sin_miembro"])) if not (plan["crear"] or plan["restablecer"] or plan["datos"]): print("\nNada que hacer.") def pedir_password(desde_stdin=False): if desde_stdin: pw = sys.stdin.readline().rstrip("\n") confirma = pw else: pw = getpass.getpass("Contraseña para todos (no se ve al escribir): ") confirma = getpass.getpass("Repítela: ") if pw != confirma: sys.exit("Las dos contraseñas no coinciden. No se cambió nada.") if len(pw) < A.PASS_MIN: sys.exit(f"La contraseña debe tener al menos {A.PASS_MIN} caracteres. No se cambió nada.") if pw != pw.strip(): sys.exit("La contraseña empieza o termina con un espacio; escríbela sin espacios a los lados. No se cambió nada.") return pw def _respaldo(): carpeta = os.path.join(A.EQUIPO_DIR, "_respaldos") os.makedirs(carpeta, mode=0o700, exist_ok=True) destino = os.path.join(carpeta, "htpasswd-" + A.ahora().replace(":", "").replace("-", "").replace("T", "-")) shutil.copyfile(A.HTPASSWD, destino) os.chmod(destino, 0o600) return destino def aplicar(plan, password, yo): sal = lambda: "".join(A.ITOA64[b % 64] for b in os.urandom(8)) resp = _respaldo() nuevos = {p["usuario"]: p for p in plan["crear"]} reset = set(plan["restablecer"]) with A._candado_htpasswd(): lineas = A._leer_htpasswd() presentes = {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l} salida = [] for l in lineas: if ":" in l and l.split(":", 1)[0].strip().lower() in reset: u = l.split(":", 1)[0].strip() l = u + ":" + A.apr1(password, sal()) salida.append(l) salida = [l for l in salida if l.strip()] for u in nuevos: if u not in presentes: # alguien la creó mientras tanto: no se duplica salida.append(u + ":" + A.apr1(password, sal())) A._escribir_htpasswd(salida) hecho = {"creados": [], "restablecidos": [], "datos": []} for u, p in nuevos.items(): base = A._leer_json(A._ruta_registro(A.EQUIPO_DIR, u)) datos = {k: v for k, v in p.items() if k != "usuario"} if base: t, err = A._miembro_valido(_cambios_de_datos(base, p), base) else: t, err = A._miembro_valido(datos) if not err: t.update({"id": u, "creado_por": yo, "creado_en": A.ahora(), "historial": []}) if err: print(f" ! {u}: {err} (el acceso sí quedó creado)") continue t["id"] = u t["activo"] = True t["contrasena_inicial"] = True t["contrasena_cambiada_en"] = A.ahora() A._hist(t, yo, "acceso al sitio creado (cuentas.py); queda la contraseña inicial hasta que la cambie") A._guardar_miembro(t) A._registrar_acceso(yo, "alta", u) hecho["creados"].append(u) for u in sorted(reset): A._marcar_contrasena(u, True, yo, "contraseña restablecida en bloque (cuentas.py): queda la inicial hasta que la cambie") A._registrar_acceso(yo, "contraseña restablecida", u) hecho["restablecidos"].append(u) for u, c, nuevo in plan["datos"]: base = A._leer_json(A._ruta_registro(A.EQUIPO_DIR, u)) if nuevo or not base: # tenía acceso pero no era miembro t, err = A._miembro_valido(dict(c, nombre=c.get("nombre") or u)) if not err: t.update({"id": u, "activo": True, "creado_por": yo, "creado_en": A.ahora(), "historial": []}) else: t, err = A._miembro_valido(c, base) if err: print(f" ! {u}: {err}") continue A._hist(t, yo, "datos (cuentas.py): " + "; ".join(f"{k}: {A._corto((base or {}).get(k))} → {A._corto(v)}" for k, v in c.items())) A._guardar_miembro(t) hecho["datos"].append(u) return hecho, resp def lista(): miembros = A._miembros() logins = _logins() print(f"{A.HTPASSWD}: {len(logins)} cuentas\n") print(f"{'usuario':<11} {'nombre':<24} {'sitio':<6} {'correo':<28} contraseña") for u in sorted(logins, key=str.lower): m = miembros.get(u.lower()) or {} estado = ("inicial" if m.get("contrasena_inicial") else "propia" if "contrasena_inicial" in m else "—") if not m: estado = "sin miembro" elif m.get("activo", True) is False: estado += " · inactivo" print(f"{u:<11} {str(m.get('nombre') or '')[:24]:<24} {_nombre_sitio(m.get('sitio')) if m.get('sitio') else '':<6} " f"{str(m.get('correo') or '')[:28]:<28} {estado}") def main(argv=None): ap = argparse.ArgumentParser(description="Cuentas del sitio en bloque (ver el encabezado de cuentas.py).") ap.add_argument("accion", choices=("lista", "todo", "alta", "restablecer")) ap.add_argument("archivo", nargs="?", help="JSON con «alta» y «actualizar» (todo, alta)") ap.add_argument("--excepto", default=EXCEPTO, help=f"no restablecer a estos (por omisión {EXCEPTO})") ap.add_argument("--solo", default="", help="restablecer solo a estos, separados por coma") ap.add_argument("--si", action="store_true", help="no preguntar antes de escribir") ap.add_argument("--password-stdin", action="store_true", help="leer la contraseña de la entrada estándar (pruebas)") a = ap.parse_args(argv) if a.accion == "lista": return lista() if a.accion in ("todo", "alta") and not a.archivo: ap.error(f"«{a.accion}» necesita el archivo de altas") plan = hacer_plan(a.accion, a.archivo, a.excepto, a.solo) mostrar_plan(plan) if not (plan["crear"] or plan["restablecer"] or plan["datos"]): return 0 if not a.si: if input("\n¿Continuar? Escribe si: ").strip().lower() not in ("si", "sí"): print("No se cambió nada.") return 1 password = pedir_password(a.password_stdin) if (plan["crear"] or plan["restablecer"]) else None yo = os.environ.get("SUDO_USER") or getpass.getuser() hecho, resp = aplicar(plan, password, yo) print(f"\nListo. Respaldo del archivo anterior: {resp}") for k, v in hecho.items(): if v: print(f" {k}: {', '.join(v)}") if hecho["creados"] or hecho["restablecidos"]: print("\nCada quien entra con su usuario y esa contraseña, y la cambia en Mi cuenta (el sitio se lo recuerda).") return 0 if __name__ == "__main__": sys.exit(main())