del-user.py (26957B)
1 #!/usr/bin/env python3 2 """ 3 Remove permanentemente uma conta Unix (banimento) no servidor runv.club (Debian). 4 5 Usa ``deluser`` com remoção da home. Opcionalmente remove o registro em 6 ``/var/lib/runv/users.json`` se existir. 7 8 Antes de ``deluser``: desmonta jail SSH (bind em ``/srv/jail/…``), quota Gemini, etc. 9 Após actualizar ``users.json``: opcionalmente executa ``site/genlanding.py --sync-public-only`` 10 (alinhado a ``create_runv_user``). 11 12 Executar como root. Não altera a configuração Apache; a sincronização só copia ficheiros estáticos. 13 14 Versão 0.04 — runv.club 15 """ 16 17 from __future__ import annotations 18 19 import argparse 20 import fcntl 21 import json 22 import logging 23 import os 24 import pwd 25 import shutil 26 import re 27 import subprocess 28 import sys 29 import tempfile 30 from datetime import datetime, timezone 31 from pathlib import Path 32 from typing import Any, Final 33 34 # Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script não entra em sys.path. 35 _SCRIPT_DIR = Path(__file__).resolve().parent 36 if str(_SCRIPT_DIR) not in sys.path: 37 sys.path.insert(0, str(_SCRIPT_DIR)) 38 39 import runv_jail 40 from runv_landing_sync import try_sync_landing_via_genlanding 41 42 # constantes 43 USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") 44 45 # Contas de sistema / serviço — nunca remover por engano 46 RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( 47 { 48 "root", 49 "daemon", 50 "bin", 51 "sys", 52 "sync", 53 "games", 54 "man", 55 "lp", 56 "mail", 57 "news", 58 "uucp", 59 "proxy", 60 "www-data", 61 "backup", 62 "list", 63 "irc", 64 "_apt", 65 "nobody", 66 "admin", 67 "postmaster", 68 } 69 ) 70 71 DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") 72 DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock") 73 DEFAULT_ALLOWED_ADMIN_USERS: Final[tuple[str, ...]] = ("pmurad-admin",) 74 75 VERSION: Final[str] = "0.04" 76 77 _REPO_ROOT: Final[Path] = _SCRIPT_DIR.parent.parent 78 79 EXIT_OK: Final[int] = 0 80 EXIT_VALIDATION: Final[int] = 1 81 EXIT_SYSTEM: Final[int] = 2 82 83 MIN_UID_NORMAL_USER: Final[int] = 1000 84 85 86 def setup_del_user_log(*, verbose: bool) -> logging.Logger: 87 log = logging.getLogger("runv.del_user") 88 log.setLevel(logging.DEBUG if verbose else logging.INFO) 89 log.propagate = False 90 if not log.handlers: 91 h = logging.StreamHandler(sys.stderr) 92 h.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) 93 log.addHandler(h) 94 return log 95 96 97 def resolve_allowed_admin_users() -> set[str]: 98 raw = os.environ.get("RUNV_ADMIN_USERS", "").strip() 99 if not raw: 100 return set(DEFAULT_ALLOWED_ADMIN_USERS) 101 names = {part.strip() for part in raw.split(",") if part.strip()} 102 return names or set(DEFAULT_ALLOWED_ADMIN_USERS) 103 104 105 def resolve_operator_user() -> str: 106 sudo_user = os.environ.get("SUDO_USER", "").strip() 107 if sudo_user: 108 return sudo_user 109 user = os.environ.get("USER", "").strip() 110 return user or "root" 111 112 113 def require_authorized_admin_operator() -> str: 114 operator = resolve_operator_user() 115 allowed = resolve_allowed_admin_users() 116 if operator not in allowed: 117 allowed_list = ", ".join(sorted(allowed)) 118 print( 119 f"Acesso negado: operador {operator!r} não está autorizado. Permitidos: {allowed_list}.", 120 file=sys.stderr, 121 ) 122 raise SystemExit(EXIT_VALIDATION) 123 return operator 124 125 126 # validação / root 127 def validate_privileges() -> None: 128 if os.geteuid() != 0: 129 print( 130 "Este script deve ser executado como root (ou com sudo).", 131 file=sys.stderr, 132 ) 133 raise SystemExit(EXIT_VALIDATION) 134 135 136 def validate_username_syntax(username: str) -> str: 137 if not username or not username.strip(): 138 print("Erro: username é obrigatório.", file=sys.stderr) 139 raise SystemExit(EXIT_VALIDATION) 140 u = username.strip() 141 if u != username: 142 print("Erro: username não pode ter espaços no início ou fim.", file=sys.stderr) 143 raise SystemExit(EXIT_VALIDATION) 144 if not USERNAME_PATTERN.fullmatch(u): 145 print( 146 "Erro: username inválido (use letras minúsculas, dígitos, _ e -; " 147 "2–32 caracteres, começando com letra).", 148 file=sys.stderr, 149 ) 150 raise SystemExit(EXIT_VALIDATION) 151 return u 152 153 154 def check_user_exists(username: str) -> tuple[int, Path]: 155 """Retorna (uid, home) ou encerra com erro.""" 156 try: 157 pw = pwd.getpwnam(username) 158 except KeyError: 159 print(f"Erro: usuário {username!r} não existe neste sistema.", file=sys.stderr) 160 raise SystemExit(EXIT_VALIDATION) 161 return pw.pw_uid, Path(pw.pw_dir) 162 163 164 def enforce_safety_rules( 165 username: str, 166 uid: int, 167 *, 168 force: bool, 169 ) -> None: 170 """Impede remoção acidental de contas críticas.""" 171 if username == "root": 172 print("Erro: remover 'root' não é permitido.", file=sys.stderr) 173 raise SystemExit(EXIT_VALIDATION) 174 175 if username in RESERVED_USERNAMES and not force: 176 print( 177 f"Erro: {username!r} é uma conta reservada do sistema. " 178 "Se tem certeza absoluta, repita com --force (não recomendado).", 179 file=sys.stderr, 180 ) 181 raise SystemExit(EXIT_VALIDATION) 182 183 if username in runv_jail.JAIL_SKIP_USERNAMES and not force: 184 print( 185 f"Erro: {username!r} é conta de serviço runv (SSH signup / admin). " 186 "Não remover excepto com --force (quebra o sistema).", 187 file=sys.stderr, 188 ) 189 raise SystemExit(EXIT_VALIDATION) 190 191 if uid < MIN_UID_NORMAL_USER and not force: 192 print( 193 f"Erro: UID {uid} < {MIN_UID_NORMAL_USER} (conta de sistema). " 194 "Para remover, use --force (perigoso).", 195 file=sys.stderr, 196 ) 197 raise SystemExit(EXIT_VALIDATION) 198 199 200 def confirm_interactive(username: str) -> bool: 201 print() 202 print(" ATENÇÃO: esta operação remove a conta, a home e o acesso SSH por chave") 203 print(" (o utilizador deixa de existir no sistema).") 204 print() 205 typed = input(f" Digite exatamente o username para confirmar [{username}]: ").strip() 206 return typed == username 207 208 209 # Gemini (bind em /var/gemini/users) 210 GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") 211 FSTAB_PATH: Final[Path] = Path("/etc/fstab") 212 _GEMINI_BIND_FSTAB_RE: Final[re.Pattern[str]] = re.compile( 213 r"^(.+)\s+(/var/gemini/users/\S+)\s+none\s+bind\s+0\s+0\s*\Z" 214 ) 215 216 217 def _unescape_fstab_path(s: str) -> str: 218 return s.replace("\\040", " ") 219 220 221 def remove_gemini_user_symlink(username: str, *, dry_run: bool, verbose: bool) -> None: 222 """ 223 Desmonta bind mount em /var/gemini/users/<user>, remove linha fstab correspondente, 224 remove symlink legado ou directório vazio. 225 """ 226 mp = GEMINI_USERS_DIR / username 227 228 if dry_run: 229 print(f" [dry-run] Gemini: umount/fstab/symlink se aplicável em {mp}") 230 return 231 232 r_mp = subprocess.run( 233 ["mountpoint", "-q", str(mp)], 234 capture_output=True, 235 timeout=60, 236 ) 237 if r_mp.returncode == 0: 238 u = subprocess.run( 239 ["umount", str(mp)], 240 capture_output=True, 241 text=True, 242 timeout=120, 243 ) 244 if u.returncode != 0: 245 print( 246 f" [aviso] umount {mp}: {(u.stderr or u.stdout or '').strip()}", 247 file=sys.stderr, 248 ) 249 elif verbose: 250 print(f" [ok] umount Gemini: {mp}") 251 252 if FSTAB_PATH.is_file(): 253 try: 254 text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace") 255 except OSError as e: 256 if verbose: 257 print(f" [aviso] ler fstab: {e}", file=sys.stderr) 258 else: 259 new_lines: list[str] = [] 260 removed_line = False 261 for line in text.splitlines(keepends=True): 262 st = line.strip() 263 if not st or st.startswith("#"): 264 new_lines.append(line) 265 continue 266 m = _GEMINI_BIND_FSTAB_RE.match(st) 267 if m and Path(_unescape_fstab_path(m.group(2))) == mp: 268 removed_line = True 269 continue 270 new_lines.append(line) 271 if removed_line: 272 new_content = "".join(new_lines) 273 if new_content != text: 274 ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") 275 bak = FSTAB_PATH.with_suffix(f".bak.{ts}") 276 shutil.copy2(FSTAB_PATH, bak) 277 FSTAB_PATH.write_text(new_content, encoding="utf-8") 278 if verbose: 279 print(f" [ok] fstab: removida linha bind para {mp} (backup {bak})") 280 281 if mp.is_symlink(): 282 try: 283 mp.unlink() 284 print(f" [ok] symlink Gemini removido: {mp}") 285 except OSError as e: 286 print(f" [aviso] não foi possível remover {mp}: {e}", file=sys.stderr) 287 return 288 289 if mp.is_dir(): 290 try: 291 if not any(mp.iterdir()): 292 mp.rmdir() 293 if verbose: 294 print(f" [ok] directório Gemini vazio removido: {mp}") 295 except OSError as e: 296 if verbose: 297 print(f" [aviso] {mp}: {e}", file=sys.stderr) 298 elif mp.exists() and verbose: 299 print( 300 f" [aviso] {mp} ainda existe (não é symlink/dir vazio); verificar manualmente.", 301 file=sys.stderr, 302 ) 303 304 305 # deluser / quota 306 def clear_user_quota_before_removal( 307 username: str, 308 home: Path, 309 *, 310 verbose: bool, 311 dry_run: bool, 312 ) -> None: 313 """ 314 Se existir ext4+usrquota no mount da home, repõe limites a zero antes de apagar o utilizador 315 (alinhado ao mount detetado por create_runv_user / runv_mount). 316 """ 317 from runv_mount import MountLookupError, find_mount_triple, quota_opts_allow_user 318 319 if not shutil.which("setquota"): 320 if verbose: 321 print(" [info] setquota ausente; não limpo quotas antes de deluser.") 322 return 323 try: 324 tgt, fst, opts = find_mount_triple(home) 325 except MountLookupError as e: 326 if verbose: 327 print(f" [info] mount da home não resolvido ({e}); salto limpeza de quota.") 328 return 329 if fst != "ext4" or not quota_opts_allow_user(opts): 330 if verbose: 331 print(" [info] sem ext4+usrquota neste mount; salto limpeza de quota.") 332 return 333 cmd = ["setquota", "-u", username, "0", "0", "0", "0", tgt] 334 if dry_run: 335 print(f" [dry-run] executaria: {' '.join(cmd)}") 336 return 337 r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) 338 if r.returncode != 0: 339 err = (r.stderr or r.stdout or "").strip() 340 print( 341 f" [aviso] setquota para limpar quotas falhou (código {r.returncode}): {err}", 342 file=sys.stderr, 343 ) 344 print( 345 " Continuo com deluser; verifique repquota/edquota se necessário.", 346 file=sys.stderr, 347 ) 348 elif verbose: 349 print(f" [ok] quotas repostas a ilimitado para {username!r} em {tgt!r}") 350 351 352 def run_deluser( 353 username: str, 354 *, 355 purge_all_files: bool, 356 dry_run: bool, 357 verbose: bool, 358 ) -> None: 359 if dry_run: 360 cmd = ["deluser", username] 361 if purge_all_files: 362 cmd.insert(1, "--remove-all-files") 363 else: 364 cmd.insert(1, "--remove-home") 365 print(f" [dry-run] executaria: {' '.join(cmd)}") 366 return 367 368 env = os.environ.copy() 369 env["DEBIAN_FRONTEND"] = "noninteractive" 370 env["LC_ALL"] = "C" 371 372 cmd: list[str] = ["deluser"] 373 if purge_all_files: 374 cmd.append("--remove-all-files") 375 else: 376 cmd.append("--remove-home") 377 cmd.append(username) 378 379 if verbose: 380 print(f" [exec] {' '.join(cmd)}") 381 382 try: 383 r = subprocess.run( 384 cmd, 385 capture_output=True, 386 text=True, 387 timeout=300, 388 env=env, 389 ) 390 except FileNotFoundError: 391 print( 392 "Erro: comando 'deluser' não encontrado (pacote adduser no Debian).", 393 file=sys.stderr, 394 ) 395 raise SystemExit(EXIT_SYSTEM) from None 396 397 if r.returncode != 0: 398 print(f"Erro: deluser falhou (código {r.returncode}).", file=sys.stderr) 399 if r.stdout: 400 print(r.stdout, file=sys.stderr) 401 if r.stderr: 402 print(r.stderr, file=sys.stderr) 403 raise SystemExit(EXIT_SYSTEM) 404 405 if verbose and r.stdout: 406 print(r.stdout.rstrip()) 407 408 409 # users.json 410 def remove_user_metadata( 411 metadata_path: Path, 412 lock_path: Path, 413 username: str, 414 *, 415 dry_run: bool, 416 verbose: bool, 417 ) -> str: 418 """ 419 Remove entrada com mesmo 'username' da lista JSON. 420 Retorna: 'removed' | 'absent' | 'skipped' | 'dry-run' 421 """ 422 if not metadata_path.is_file(): 423 if verbose: 424 print(f" [metadata] ficheiro inexistente, nada a fazer: {metadata_path}") 425 return "skipped" 426 427 if dry_run: 428 raw = metadata_path.read_text(encoding="utf-8").strip() 429 if not raw: 430 return "dry-run" 431 try: 432 data = json.loads(raw) 433 except json.JSONDecodeError: 434 print( 435 f"Aviso: {metadata_path} não é JSON válido; não alterado no dry-run.", 436 file=sys.stderr, 437 ) 438 return "dry-run" 439 if isinstance(data, list) and any( 440 isinstance(x, dict) and x.get("username") == username for x in data 441 ): 442 print(f" [dry-run] removeria entrada de {username!r} em {metadata_path}") 443 else: 444 print(f" [dry-run] sem entrada para {username!r} em {metadata_path}") 445 return "dry-run" 446 447 lock_path.parent.mkdir(parents=True, exist_ok=True) 448 lock_f = open(lock_path, "a+", encoding="utf-8") 449 try: 450 fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) 451 raw = metadata_path.read_text(encoding="utf-8").strip() 452 if not raw: 453 return "absent" 454 parsed = json.loads(raw) 455 if not isinstance(parsed, list): 456 print( 457 f"Erro: formato inválido em {metadata_path} (esperada lista JSON).", 458 file=sys.stderr, 459 ) 460 raise SystemExit(EXIT_SYSTEM) 461 before = len(parsed) 462 data = [x for x in parsed if not (isinstance(x, dict) and x.get("username") == username)] 463 after = len(data) 464 if before == after: 465 if verbose: 466 print(f" [metadata] nenhum registo para {username!r} em {metadata_path}") 467 return "absent" 468 469 tmp_fd, tmp_name = tempfile.mkstemp( 470 prefix="users.", 471 suffix=".tmp", 472 dir=str(metadata_path.parent), 473 ) 474 tmp_path = Path(tmp_name) 475 try: 476 with os.fdopen(tmp_fd, "w", encoding="utf-8") as out: 477 json.dump(data, out, indent=2, ensure_ascii=False) 478 out.flush() 479 os.fsync(out.fileno()) 480 os.replace(tmp_path, metadata_path) 481 except Exception: 482 tmp_path.unlink(missing_ok=True) 483 raise 484 print(f" [metadata] removido registo de {username!r} em {metadata_path}") 485 return "removed" 486 finally: 487 fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) 488 lock_f.close() 489 490 491 def read_user_email_from_metadata(metadata_path: Path, username: str) -> str | None: 492 """Lê o email do registo com mesmo ``username`` em ``users.json`` (lista de dicts).""" 493 if not metadata_path.is_file(): 494 return None 495 raw = metadata_path.read_text(encoding="utf-8").strip() 496 if not raw: 497 return None 498 try: 499 data = json.loads(raw) 500 except json.JSONDecodeError: 501 return None 502 if not isinstance(data, list): 503 return None 504 for x in data: 505 if isinstance(x, dict) and x.get("username") == username: 506 em = x.get("email") 507 if em is None: 508 return None 509 s = str(em).strip() 510 return s if s else None 511 return None 512 513 514 def _resolve_email_package_root(state: dict[str, Any] | None) -> Path | None: 515 """Pasta ``email/`` do repositório para importar ``lib.mailer``.""" 516 env = os.environ.get("RUNV_EMAIL_ROOT", "").strip() 517 if env: 518 p = Path(env) 519 return p if p.is_dir() else None 520 if state: 521 er = str(state.get("email_package_root", "")).strip() 522 if er: 523 p = Path(er) 524 if p.is_dir(): 525 return p 526 cand = _REPO_ROOT / "email" 527 return cand if cand.is_dir() else None 528 529 530 def try_send_community_ban_notice( 531 username: str, 532 user_email: str | None, 533 *, 534 no_ban_notify_email: bool, 535 dry_run: bool, 536 verbose: bool, 537 ) -> None: 538 """ 539 Envia ``user_account_community_deactivated`` se existir ``/etc/runv-email.json`` e pasta ``email/``. 540 Falhas não abortam a remoção da conta. 541 """ 542 if no_ban_notify_email: 543 if verbose: 544 print(" notificação ban: omitida (--no-ban-notify-email)") 545 return 546 if dry_run: 547 return 548 if not user_email: 549 if verbose: 550 print(" notificação ban: sem email nos metadados — não enviado") 551 return 552 553 state_file = Path("/etc/runv-email.json") 554 if not state_file.is_file(): 555 if verbose: 556 print( 557 f" notificação ban: {state_file} ausente — email não enviado", 558 ) 559 return 560 try: 561 state = json.loads(state_file.read_text(encoding="utf-8")) 562 except (OSError, json.JSONDecodeError) as e: 563 print(f"Aviso: notificação ban: estado inválido ({state_file}): {e}", file=sys.stderr) 564 return 565 566 email_root = _resolve_email_package_root(state) 567 if email_root is None: 568 print( 569 "Aviso: notificação ban: pasta email/ não encontrada " 570 f"(RUNV_EMAIL_ROOT, email_package_root no JSON ou {_REPO_ROOT / 'email'})", 571 file=sys.stderr, 572 ) 573 return 574 575 root_s = str(email_root.resolve()) 576 if root_s not in sys.path: 577 sys.path.insert(0, root_s) 578 579 try: 580 from lib.mailer import send_user_notice 581 from lib.templates import USER_ACCOUNT_COMMUNITY_DEACTIVATED 582 except ImportError as e: 583 print(f"Aviso: notificação ban: import lib.mailer falhou: {e}", file=sys.stderr) 584 return 585 586 from_addr = str(state.get("default_from", "")).strip() 587 if not from_addr: 588 print(f"Aviso: notificação ban: default_from ausente em {state_file}", file=sys.stderr) 589 return 590 591 try: 592 send_user_notice( 593 USER_ACCOUNT_COMMUNITY_DEACTIVATED, 594 user_email, 595 subject="[runv.club] Conta desativada", 596 from_addr=from_addr, 597 _state=state, 598 username=username, 599 email=user_email, 600 ) 601 print(f" notificação ban: email enviado para {user_email}") 602 except Exception as e: 603 print(f"Aviso: notificação ban falhou (conta já removida): {e}", file=sys.stderr) 604 if verbose: 605 import traceback 606 607 traceback.print_exc() 608 609 610 # CLI 611 def main() -> int: 612 parser = argparse.ArgumentParser( 613 description="Remove permanentemente um utilizador Unix (banimento, runv.club).", 614 ) 615 parser.add_argument( 616 "--username", 617 "-u", 618 required=True, 619 metavar="USER", 620 help="nome de utilizador Unix a remover", 621 ) 622 parser.add_argument( 623 "--dry-run", 624 action="store_true", 625 help="mostra o que seria feito sem remover nada (não exige root)", 626 ) 627 parser.add_argument( 628 "--verbose", 629 "-v", 630 action="store_true", 631 help="mais detalhes na saída", 632 ) 633 parser.add_argument( 634 "--yes", 635 "-y", 636 action="store_true", 637 help="não pedir confirmação interativa (para scripts)", 638 ) 639 parser.add_argument( 640 "--force", 641 action="store_true", 642 help="permite remover contas reservadas ou UID de sistema (muito perigoso)", 643 ) 644 parser.add_argument( 645 "--purge-all-files", 646 action="store_true", 647 help="usa deluser --remove-all-files em vez de só --remove-home", 648 ) 649 parser.add_argument( 650 "--skip-metadata", 651 action="store_true", 652 help="não altera /var/lib/runv/users.json", 653 ) 654 parser.add_argument( 655 "--metadata-file", 656 type=Path, 657 default=DEFAULT_METADATA_PATH, 658 help=f"caminho do JSON de metadados (default: {DEFAULT_METADATA_PATH})", 659 ) 660 parser.add_argument( 661 "--lock-file", 662 type=Path, 663 default=DEFAULT_LOCK_PATH, 664 help=f"ficheiro de lock flock (default: {DEFAULT_LOCK_PATH})", 665 ) 666 parser.add_argument( 667 "--no-ban-notify-email", 668 action="store_true", 669 help="não envia email ao utilizador sobre desativação por normas da comunidade", 670 ) 671 parser.add_argument( 672 "--landing-document-root", 673 type=Path, 674 default=Path("/var/www/runv.club/html"), 675 help=( 676 "DocumentRoot da landing; após remover entrada em users.json, executa " 677 "genlanding --sync-public-only (omitido com --skip-metadata ou --no-refresh-landing-members)" 678 ), 679 ) 680 parser.add_argument( 681 "--no-refresh-landing-members", 682 action="store_true", 683 help="não copiar site/public nem regenerar data/members.json após users.json", 684 ) 685 parser.add_argument( 686 "--members-homes-root", 687 type=Path, 688 default=None, 689 metavar="DIR", 690 help="opcional: --members-homes-root para genlanding (ex. /home)", 691 ) 692 parser.add_argument( 693 "--version", 694 action="version", 695 version=f"%(prog)s {VERSION} — runv.club", 696 ) 697 args = parser.parse_args() 698 699 log = setup_del_user_log(verbose=args.verbose) 700 _operator_user = require_authorized_admin_operator() 701 702 username = validate_username_syntax(args.username) 703 704 uid, home = check_user_exists(username) 705 enforce_safety_rules(username, uid, force=args.force) 706 707 if args.dry_run: 708 print("del-user.py — modo dry-run (nenhuma alteração)\n") 709 print(f" utilizador: {username!r}") 710 print(f" UID: {uid}") 711 print(f" home: {home}") 712 clear_user_quota_before_removal( 713 username, 714 home, 715 verbose=args.verbose, 716 dry_run=True, 717 ) 718 remove_gemini_user_symlink(username, dry_run=True, verbose=args.verbose) 719 runv_jail.teardown_runv_jail_for_user(username, home, log, dry_run=True) 720 run_deluser( 721 username, 722 purge_all_files=args.purge_all_files, 723 dry_run=True, 724 verbose=args.verbose, 725 ) 726 if not args.skip_metadata: 727 remove_user_metadata( 728 args.metadata_file, 729 args.lock_file, 730 username, 731 dry_run=True, 732 verbose=args.verbose, 733 ) 734 if not args.no_refresh_landing_members and args.landing_document_root: 735 dr = args.landing_document_root.resolve() 736 if dr.is_dir(): 737 print( 738 f" [dry-run] executaria genlanding --sync-public-only " 739 f"(document-root={dr}, users.json={args.metadata_file})" 740 ) 741 elif args.verbose: 742 print( 743 f" [dry-run] landing: DocumentRoot inexistente ({dr}); sync omitido", 744 file=sys.stderr, 745 ) 746 ban_email = read_user_email_from_metadata(args.metadata_file, username) 747 if args.no_ban_notify_email: 748 print(" notificação ban: omitida (--no-ban-notify-email)") 749 elif not ban_email: 750 print(" notificação ban: sem email nos metadados — nada a enviar") 751 else: 752 print( 753 f" notificação ban: enviaria para {ban_email!r} " 754 "(template user_account_community_deactivated)", 755 ) 756 print("\nNada foi alterado. Execute sem --dry-run como root para aplicar.") 757 return EXIT_OK 758 759 if not args.yes: 760 if not confirm_interactive(username): 761 print("Cancelado: confirmação não coincide.") 762 return EXIT_VALIDATION 763 764 validate_privileges() 765 766 ban_email = read_user_email_from_metadata(args.metadata_file, username) 767 768 print(f"\ndel-user.py — removendo {username!r} (UID {uid})\n") 769 770 clear_user_quota_before_removal( 771 username, 772 home, 773 verbose=args.verbose, 774 dry_run=False, 775 ) 776 777 remove_gemini_user_symlink(username, dry_run=False, verbose=args.verbose) 778 779 try: 780 runv_jail.teardown_runv_jail_for_user(username, home, log, dry_run=False) 781 except RuntimeError as e: 782 print(f"Erro: jail SSH: {e}", file=sys.stderr) 783 print( 784 " Resolva o bind em /srv/jail/… antes de remover o utilizador (umount, fstab).", 785 file=sys.stderr, 786 ) 787 raise SystemExit(EXIT_SYSTEM) from e 788 789 run_deluser( 790 username, 791 purge_all_files=args.purge_all_files, 792 dry_run=False, 793 verbose=args.verbose, 794 ) 795 print(f" [ok] deluser concluído para {username!r}") 796 797 if not args.skip_metadata: 798 remove_user_metadata( 799 args.metadata_file, 800 args.lock_file, 801 username, 802 dry_run=False, 803 verbose=args.verbose, 804 ) 805 if not args.no_refresh_landing_members and args.landing_document_root: 806 root = args.landing_document_root.resolve() 807 if root.is_dir(): 808 log.info("sincronizar landing após remoção de metadados (%s)", root) 809 try_sync_landing_via_genlanding( 810 document_root=root, 811 users_json=args.metadata_file, 812 homes_root=args.members_homes_root.resolve() 813 if args.members_homes_root 814 else None, 815 log=log, 816 ) 817 else: 818 log.warning( 819 "DocumentRoot da landing inexistente (%s); constelação não actualizada", 820 root, 821 ) 822 823 try_send_community_ban_notice( 824 username, 825 ban_email, 826 no_ban_notify_email=args.no_ban_notify_email, 827 dry_run=False, 828 verbose=args.verbose, 829 ) 830 831 print("\n--- Resumo ---") 832 print(f" Conta removida: {username!r}") 833 print(" Próximo passo: verificar se não restam processos desse UID e revogar acessos externos se aplicável.") 834 835 return EXIT_OK 836 837 838 if __name__ == "__main__": 839 raise SystemExit(main())