runv-server

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

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