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())