runv-server

server tooling for runv.club
Log | Files | Refs | README

doom.py (10209B)


      1 #!/usr/bin/env python3
      2 """
      3 Apaga todas as contas runv listadas em users.json, excepto o conjunto protegido.
      4 
      5 Nunca apaga quem está ligado ao processo: ``SUDO_USER``, real UID e effective UID
      6 (cobre ``sudo -u bob``). O ``--keep USER`` define
      7 a conta runv de referência; mesmo assim, quem rodou nunca entra na lista de
      8 remoção. Em sessão root pura (sem SUDO_USER), é obrigatório ``--keep USER``.
      9 
     10 Para cada utilizador a remover, delega em ``scripts/admin/del-user.py`` (-y),
     11 para manter o mesmo fluxo (deluser, quotas, users.json).
     12 
     13 Executar como root. Operação irreversível.
     14 
     15 Versão 0.02 — runv.club
     16 """
     17 
     18 from __future__ import annotations
     19 
     20 import argparse
     21 import json
     22 import os
     23 import pwd
     24 import re
     25 import subprocess
     26 import sys
     27 from pathlib import Path
     28 from typing import Final
     29 
     30 VERSION: Final[str] = "0.02"
     31 EXIT_OK: Final[int] = 0
     32 EXIT_VALIDATION: Final[int] = 1
     33 EXIT_SYSTEM: Final[int] = 2
     34 
     35 USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$")
     36 
     37 DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json")
     38 
     39 _DOOM_DIR = Path(__file__).resolve().parent
     40 _REPO_SCRIPTS = _DOOM_DIR.parent
     41 _ADMIN_DIR = _REPO_SCRIPTS / "admin"
     42 if str(_ADMIN_DIR) not in sys.path:
     43     sys.path.insert(0, str(_ADMIN_DIR))
     44 _DEL_USER_PY: Final[Path] = _REPO_SCRIPTS / "admin" / "del-user.py"
     45 
     46 from admin_guard import ensure_admin_cli
     47 
     48 
     49 def eprint(msg: str) -> None:
     50     print(msg, file=sys.stderr)
     51 
     52 
     53 def validate_privileges() -> None:
     54     if os.geteuid() != 0:
     55         eprint("Erro: execute como root (ex.: sudo python3 doom.py …).")
     56         raise SystemExit(EXIT_VALIDATION)
     57 
     58 
     59 def validate_username_syntax(username: str) -> str:
     60     if not username or not username.strip():
     61         eprint("Erro: username vazio.")
     62         raise SystemExit(EXIT_VALIDATION)
     63     u = username.strip()
     64     if not USERNAME_PATTERN.fullmatch(u):
     65         eprint(
     66             "Erro: username inválido (minúsculas, dígitos, _ e -; "
     67             "2–32 caracteres, começando com letra).",
     68         )
     69         raise SystemExit(EXIT_VALIDATION)
     70     return u
     71 
     72 
     73 def username_for_metadata_match(raw: str) -> str:
     74     """Forma canónica para comparar com entradas de users.json (runv em minúsculas)."""
     75     u = raw.strip()
     76     if not u:
     77         return u
     78     if USERNAME_PATTERN.fullmatch(u):
     79         return u
     80     low = u.lower()
     81     if USERNAME_PATTERN.fullmatch(low):
     82         return low
     83     return u
     84 
     85 
     86 def collect_runners_who_must_survive() -> set[str]:
     87     """
     88     Contas que não podem ser apagadas em relação a quem corre o processo.
     89 
     90     - SUDO_USER: quem invocou ``sudo`` (quando definido).
     91     - Real UID e effective UID: cobre ``sudo -u bob`` (RUID ainda pode ser alice,
     92       EUID é bob — ambos ficam protegidos).
     93     - Root sem SUDO_USER: também protege ``root`` se existir no JSON.
     94     """
     95     out: set[str] = set()
     96     su = os.environ.get("SUDO_USER", "").strip()
     97     if su:
     98         out.add(username_for_metadata_match(su))
     99     for uid in (os.getuid(), os.geteuid()):
    100         try:
    101             login = pwd.getpwuid(uid).pw_name
    102             if login:
    103                 out.add(username_for_metadata_match(login))
    104         except KeyError:
    105             pass
    106     if os.geteuid() == 0 and not su:
    107         out.add("root")
    108     return {x for x in out if x}
    109 
    110 
    111 def resolve_keeper(args: argparse.Namespace) -> str:
    112     if args.keep:
    113         return validate_username_syntax(args.keep)
    114     if os.geteuid() == 0:
    115         su = os.environ.get("SUDO_USER", "").strip()
    116         if su:
    117             return validate_username_syntax(username_for_metadata_match(su))
    118         eprint(
    119             "Erro: sessão root sem SUDO_USER. Indique explicitamente a conta a preservar:\n"
    120             "       --keep alice\n"
    121             "       ou execute a partir da conta desejada, ex.: sudo -u alice python3 …/doom.py",
    122         )
    123         raise SystemExit(EXIT_VALIDATION)
    124     return validate_username_syntax(
    125         username_for_metadata_match(pwd.getpwuid(os.getuid()).pw_name),
    126     )
    127 
    128 
    129 def load_runv_usernames(metadata_path: Path) -> list[str]:
    130     if not metadata_path.is_file():
    131         return []
    132     raw = metadata_path.read_text(encoding="utf-8").strip()
    133     if not raw:
    134         return []
    135     try:
    136         data = json.loads(raw)
    137     except json.JSONDecodeError as e:
    138         eprint(f"Erro: JSON inválido em {metadata_path}: {e}")
    139         raise SystemExit(EXIT_SYSTEM) from e
    140     if not isinstance(data, list):
    141         eprint(f"Erro: {metadata_path} deve ser uma lista JSON.")
    142         raise SystemExit(EXIT_SYSTEM)
    143     out: list[str] = []
    144     for item in data:
    145         if isinstance(item, dict) and item.get("username"):
    146             u = str(item["username"]).strip()
    147             if u:
    148                 out.append(u)
    149     return out
    150 
    151 
    152 def confirm_doom(keeper: str, protected: set[str], victims: list[str]) -> bool:
    153     print()
    154     print("  ═══════════════════════════════════════════════════════════")
    155     print("  DOOM — remoção em massa de contas runv")
    156     print("  ═══════════════════════════════════════════════════════════")
    157     print(f"  Conta runv alvo (referência):  {keeper!r}")
    158     extra = sorted(protected - {keeper})
    159     if extra:
    160         print(f"  Nunca apagar (quem invocou / efectivo):  {', '.join(repr(x) for x in extra)}")
    161     print(f"  Contas a apagar:   {len(victims)}")
    162     if victims:
    163         preview = ", ".join(sorted(victims)[:20])
    164         if len(victims) > 20:
    165             preview += ", …"
    166         print(f"                     {preview}")
    167     print()
    168     typed = input("  Digite DOOM em maiúsculas para confirmar: ").strip()
    169     return typed == "DOOM"
    170 
    171 
    172 def run_del_user(
    173     username: str,
    174     *,
    175     metadata_path: Path,
    176     lock_path: Path,
    177     purge_all_files: bool,
    178     verbose: bool,
    179     dry_run: bool,
    180 ) -> None:
    181     if not _DEL_USER_PY.is_file():
    182         eprint(f"Erro: não encontrei del-user.py em {_DEL_USER_PY}")
    183         raise SystemExit(EXIT_SYSTEM)
    184 
    185     cmd: list[str] = [
    186         sys.executable,
    187         str(_DEL_USER_PY),
    188         "--username",
    189         username,
    190         "--yes",
    191         "--metadata-file",
    192         str(metadata_path),
    193         "--lock-file",
    194         str(lock_path),
    195     ]
    196     if purge_all_files:
    197         cmd.append("--purge-all-files")
    198     if verbose:
    199         cmd.append("--verbose")
    200     if dry_run:
    201         cmd.append("--dry-run")
    202 
    203     r = subprocess.run(cmd, timeout=600)
    204     if r.returncode != 0:
    205         eprint(f"Erro: del-user.py falhou para {username!r} (código {r.returncode}).")
    206         raise SystemExit(EXIT_SYSTEM)
    207 
    208 
    209 def main() -> int:
    210     p = argparse.ArgumentParser(
    211         description="Remove todas as contas em users.json excepto a conta indicada (runv.club).",
    212     )
    213     p.add_argument(
    214         "--keep",
    215         metavar="USER",
    216         help="conta Unix a preservar (obrigatório se root sem SUDO_USER)",
    217     )
    218     p.add_argument(
    219         "--metadata-file",
    220         type=Path,
    221         default=DEFAULT_METADATA_PATH,
    222         help=f"caminho users.json (default: {DEFAULT_METADATA_PATH})",
    223     )
    224     p.add_argument(
    225         "--lock-file",
    226         type=Path,
    227         default=Path("/var/lib/runv/users.lock"),
    228         help="ficheiro de lock (default: /var/lib/runv/users.lock)",
    229     )
    230     p.add_argument(
    231         "--purge-all-files",
    232         action="store_true",
    233         help="repassa --purge-all-files ao del-user (além de --remove-home)",
    234     )
    235     p.add_argument("--dry-run", action="store_true", help="só simula (del-user em dry-run)")
    236     p.add_argument("--verbose", "-v", action="store_true")
    237     p.add_argument(
    238         "--yes",
    239         "-y",
    240         action="store_true",
    241         help="não pedir confirmação DOOM (perigoso)",
    242     )
    243     p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club")
    244     args = p.parse_args()
    245     ensure_admin_cli(
    246         script_name=Path(__file__).name,
    247         dry_run=bool(args.dry_run),
    248     )
    249 
    250     keeper = resolve_keeper(args)
    251     keeper = validate_username_syntax(keeper)
    252 
    253     runners = collect_runners_who_must_survive()
    254     protected = runners | {keeper}
    255 
    256     all_names = load_runv_usernames(args.metadata_file)
    257     victims = sorted({u for u in all_names if u not in protected})
    258 
    259     if not victims:
    260         print(
    261             f"doom.py — nada a fazer (entradas em users.json já só dentro do conjunto protegido; "
    262             f"referência {keeper!r}).",
    263         )
    264         return EXIT_OK
    265 
    266     if args.dry_run:
    267         print("doom.py — dry-run\n")
    268         print(f"  protegidos (nunca apagar): {', '.join(sorted(protected))}")
    269         for u in victims:
    270             print(f"  removia:   {u!r}")
    271         for u in victims:
    272             run_del_user(
    273                 u,
    274                 metadata_path=args.metadata_file,
    275                 lock_path=args.lock_file,
    276                 purge_all_files=args.purge_all_files,
    277                 verbose=args.verbose,
    278                 dry_run=True,
    279             )
    280         return EXIT_OK
    281 
    282     if not args.yes:
    283         if not confirm_doom(keeper, protected, victims):
    284             eprint("Cancelado.")
    285             return EXIT_VALIDATION
    286 
    287     validate_privileges()
    288 
    289     overlap = protected & set(victims)
    290     if overlap:
    291         eprint(f"Erro: utilizador(es) protegido(s) na lista de vítimas: {sorted(overlap)!r}")
    292         return EXIT_SYSTEM
    293 
    294     print(
    295         f"\ndoom.py — a remover {len(victims)} conta(s); "
    296         f"protegidos: {', '.join(sorted(protected))}\n",
    297     )
    298 
    299     for u in victims:
    300         print(f"--- {u!r} ---")
    301         run_del_user(
    302             u,
    303             metadata_path=args.metadata_file,
    304             lock_path=args.lock_file,
    305             purge_all_files=args.purge_all_files,
    306             verbose=args.verbose,
    307             dry_run=False,
    308         )
    309 
    310     print("\n--- Resumo ---")
    311     print(f"  Protegidos (não removidos): {', '.join(sorted(protected))}")
    312     print(f"  Removidos:  {len(victims)} utilizador(es) runv.")
    313     print(f"  Verifique {args.metadata_file} e repquota se necessário.")
    314     return EXIT_OK
    315 
    316 
    317 if __name__ == "__main__":
    318     raise SystemExit(main())