runv-server

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

tools.py (22740B)


      1 #!/usr/bin/env python3
      2 """
      3 runv.club — ferramentas globais, MOTD, comandos em /usr/local/bin e /etc/skel.
      4 
      5 Debian 13 · Python 3 stdlib apenas · sem shell=True.
      6 Execute como root. Ver docs/05-tools-and-system-experience.md no repositório.
      7 """
      8 
      9 from __future__ import annotations
     10 
     11 import argparse
     12 import filecmp
     13 import logging
     14 import os
     15 import shutil
     16 import subprocess
     17 import sys
     18 from dataclasses import dataclass, field
     19 from pathlib import Path
     20 
     21 TOOL_ROOT: Path = Path(__file__).resolve().parent
     22 ADMIN_TOOLS_DIR: Path = TOOL_ROOT.parent / "scripts" / "admin"
     23 if str(ADMIN_TOOLS_DIR) not in sys.path:
     24     sys.path.insert(0, str(ADMIN_TOOLS_DIR))
     25 
     26 from admin_guard import ensure_admin_cli
     27 
     28 MANIFEST_PATH: Path = TOOL_ROOT / "manifests" / "apt_packages.txt"
     29 
     30 # Nome no manifesto → pacote apt real ("chat" = IRC no terminal; Debian usa o pacote weechat).
     31 _APT_PACKAGE_ALIASES: dict[str, str] = {
     32     "chat": "weechat",
     33 }
     34 BIN_DIR: Path = TOOL_ROOT / "bin"
     35 LIB_DIR: Path = TOOL_ROOT / "lib"
     36 MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv"
     37 SKEL_DIR: Path = TOOL_ROOT / "skel"
     38 SUDOERS_ADMIN_SRC: Path = TOOL_ROOT / "sudoers" / "90-runv-pmurad-admin"
     39 
     40 DEST_BIN_DIR: Path = Path("/usr/local/bin")
     41 DEST_COMMUNITY_LIB_DIR: Path = Path("/usr/local/share/runv/lib")
     42 DEST_MOTD: Path = Path("/etc/update-motd.d/60-runv")
     43 DEST_SKEL: Path = Path("/etc/skel")
     44 DEST_SSHD_DROPIN: Path = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf")
     45 DEST_SUDOERS_ADMIN: Path = Path("/etc/sudoers.d/90-runv-pmurad-admin")
     46 PATCH_IRC_PATH: Path = TOOL_ROOT.parent / "patches" / "patch_irc.py"
     47 REMOVE_JAILS_PATH: Path = TOOL_ROOT.parent / "scripts" / "admin" / "remove_runv_jails.py"
     48 
     49 
     50 @dataclass
     51 class RunSummary:
     52     """Acumula ações para o resumo final."""
     53 
     54     dry_run: bool = False
     55     apt_updated: bool = False
     56     apt_install_attempted: bool = False
     57     packages_requested: list[str] = field(default_factory=list)
     58     copied: list[str] = field(default_factory=list)
     59     skipped: list[str] = field(default_factory=list)
     60     errors: list[str] = field(default_factory=list)
     61 
     62 
     63 def setup_logging(verbose: bool) -> logging.Logger:
     64     level = logging.DEBUG if verbose else logging.INFO
     65     logging.basicConfig(
     66         level=level,
     67         format="%(levelname)s: %(message)s",
     68     )
     69     return logging.getLogger("runv-tools")
     70 
     71 
     72 def require_root(log: logging.Logger) -> None:
     73     if os.geteuid() != 0:
     74         log.error("Este script precisa ser executado como root (sudo).")
     75         sys.exit(1)
     76 
     77 
     78 def run_subprocess(
     79     cmd: list[str],
     80     *,
     81     dry_run: bool,
     82     log: logging.Logger,
     83     env: dict[str, str] | None = None,
     84 ) -> subprocess.CompletedProcess[str] | None:
     85     """Executa comando sem shell; em dry-run apenas registra."""
     86     log.debug("exec: %s", " ".join(cmd))
     87     if dry_run:
     88         log.info("[dry-run] %s", " ".join(cmd))
     89         return None
     90     e = os.environ.copy()
     91     if env:
     92         e.update(env)
     93     return subprocess.run(
     94         cmd,
     95         check=False,
     96         capture_output=True,
     97         text=True,
     98         env=e,
     99         timeout=3600,
    100     )
    101 
    102 
    103 def read_apt_manifest(path: Path, log: logging.Logger) -> list[str]:
    104     if not path.is_file():
    105         log.error("Manifesto não encontrado: %s", path)
    106         sys.exit(1)
    107     packages: list[str] = []
    108     for raw in path.read_text(encoding="utf-8").splitlines():
    109         line = raw.strip()
    110         if not line or line.startswith("#"):
    111             continue
    112         packages.append(_APT_PACKAGE_ALIASES.get(line, line))
    113     return packages
    114 
    115 
    116 def install_apt_packages(
    117     packages: list[str],
    118     *,
    119     dry_run: bool,
    120     log: logging.Logger,
    121     summary: RunSummary,
    122 ) -> None:
    123     if not packages:
    124         log.info("Nenhum pacote listado no manifesto; etapa apt ignorada.")
    125         return
    126     summary.packages_requested = list(packages)
    127     env_apt = {
    128         "DEBIAN_FRONTEND": "noninteractive",
    129         "LC_ALL": "C",
    130     }
    131     log.info("Atualizando índice apt (apt-get update)...")
    132     r = run_subprocess(
    133         ["apt-get", "update", "-qq"],
    134         dry_run=dry_run,
    135         log=log,
    136         env=env_apt,
    137     )
    138     if dry_run:
    139         summary.apt_updated = True
    140     elif r is not None:
    141         if r.returncode != 0:
    142             err = (r.stderr or r.stdout or "").strip()
    143             msg = f"apt-get update falhou (código {r.returncode})" + (f": {err}" if err else "")
    144             summary.errors.append(msg)
    145             log.error("%s", msg)
    146             return
    147         summary.apt_updated = True
    148 
    149     log.info("Instalando pacotes: %s", ", ".join(packages))
    150     summary.apt_install_attempted = True
    151     cmd = ["apt-get", "install", "-y", "--no-install-recommends", *packages]
    152     r = run_subprocess(cmd, dry_run=dry_run, log=log, env=env_apt)
    153     if dry_run:
    154         return
    155     if r is None:
    156         return
    157     if r.returncode != 0:
    158         err = (r.stderr or r.stdout or "").strip()
    159         msg = f"apt-get install falhou (código {r.returncode})" + (f": {err}" if err else "")
    160         summary.errors.append(msg)
    161         log.error("%s", msg)
    162     else:
    163         log.info("Pacotes instalados ou já presentes (apt idempotente).")
    164 
    165 
    166 def ensure_parent(path: Path, log: logging.Logger) -> None:
    167     path.parent.mkdir(parents=True, exist_ok=True)
    168 
    169 
    170 def copy_one(
    171     src: Path,
    172     dst: Path,
    173     mode: int,
    174     *,
    175     force: bool,
    176     dry_run: bool,
    177     log: logging.Logger,
    178     summary: RunSummary,
    179 ) -> None:
    180     if not src.is_file():
    181         summary.errors.append(f"origem inexistente: {src}")
    182         log.error("Origem inexistente: %s", src)
    183         return
    184 
    185     def same_content() -> bool:
    186         if not dst.is_file():
    187             return False
    188         try:
    189             return filecmp.cmp(src, dst, shallow=False)
    190         except OSError:
    191             return False
    192 
    193     # Sem --force: só pula se o ficheiro já for byte-a-byte igual à origem (reexecução actualiza mudanças do repo).
    194     if not force and dst.exists() and same_content():
    195         log.info("Destino já coincide com a origem, pulando: %s", dst)
    196         summary.skipped.append(str(dst))
    197         return
    198     if dry_run:
    199         log.info("[dry-run] copiaria %s -> %s (modo %o)", src, dst, mode)
    200         summary.copied.append(f"{src} -> {dst} (simulado)")
    201         return
    202     ensure_parent(dst, log)
    203     shutil.copy2(src, dst)
    204     os.chmod(dst, mode)
    205     try:
    206         os.chown(dst, 0, 0)
    207     except OSError as e:
    208         log.warning("chown root:root em %s: %s", dst, e)
    209     log.info("Instalado: %s", dst)
    210     summary.copied.append(str(dst))
    211 
    212 
    213 def install_bin_scripts(
    214     *,
    215     force: bool,
    216     dry_run: bool,
    217     log: logging.Logger,
    218     summary: RunSummary,
    219 ) -> None:
    220     if not dry_run:
    221         DEST_BIN_DIR.mkdir(parents=True, exist_ok=True)
    222     for name in (
    223         "runv-help",
    224         "runv-links",
    225         "runv-status",
    226         "runv-games",
    227         "runvers",
    228         "chat",
    229         "runv-profile",
    230         "runv-finger",
    231         "runv-who",
    232         "runv-bulletin",
    233         "runv-email-alias",
    234         "runv-admin-email-alias",
    235     ):
    236         copy_one(
    237             BIN_DIR / name,
    238             DEST_BIN_DIR / name,
    239             0o755,
    240             force=force,
    241             dry_run=dry_run,
    242             log=log,
    243             summary=summary,
    244         )
    245 
    246 
    247 def install_community_lib(
    248     *,
    249     force: bool,
    250     dry_run: bool,
    251     log: logging.Logger,
    252     summary: RunSummary,
    253 ) -> None:
    254     """Copia bibliotecas partilhadas para /usr/local/share/runv/lib/."""
    255     if not dry_run:
    256         DEST_COMMUNITY_LIB_DIR.mkdir(parents=True, exist_ok=True)
    257     for src in sorted(LIB_DIR.glob("*.py")):
    258         copy_one(
    259             src,
    260             DEST_COMMUNITY_LIB_DIR / src.name,
    261             0o644,
    262             force=force,
    263             dry_run=dry_run,
    264             log=log,
    265             summary=summary,
    266         )
    267 
    268 
    269 def install_motd(
    270     *,
    271     force: bool,
    272     dry_run: bool,
    273     log: logging.Logger,
    274     summary: RunSummary,
    275 ) -> None:
    276     copy_one(
    277         MOTD_SRC,
    278         DEST_MOTD,
    279         0o755,
    280         force=force,
    281         dry_run=dry_run,
    282         log=log,
    283         summary=summary,
    284     )
    285 
    286 
    287 def install_admin_sudoers(
    288     *,
    289     force: bool,
    290     dry_run: bool,
    291     log: logging.Logger,
    292     summary: RunSummary,
    293 ) -> None:
    294     copy_one(
    295         SUDOERS_ADMIN_SRC,
    296         DEST_SUDOERS_ADMIN,
    297         0o440,
    298         force=force,
    299         dry_run=dry_run,
    300         log=log,
    301         summary=summary,
    302     )
    303     if dry_run or summary.errors:
    304         return
    305     check = subprocess.run(
    306         ["visudo", "-cf", str(DEST_SUDOERS_ADMIN)],
    307         capture_output=True,
    308         text=True,
    309         timeout=30,
    310     )
    311     if check.returncode != 0:
    312         err = (check.stderr or check.stdout or "").strip()
    313         msg = f"visudo -cf falhou para {DEST_SUDOERS_ADMIN}: {err}"
    314         summary.errors.append(msg)
    315         log.error("%s", msg)
    316         return
    317     log.info("Sudoers validado para pmurad-admin: %s", DEST_SUDOERS_ADMIN)
    318 
    319 
    320 def remove_obsolete_skel_readme(
    321     *,
    322     dry_run: bool,
    323     log: logging.Logger,
    324     summary: RunSummary,
    325 ) -> None:
    326     stale = DEST_SKEL / "README.md"
    327     if not stale.is_file():
    328         return
    329     if dry_run:
    330         log.info("[dry-run] removeria %s", stale)
    331         summary.copied.append(f"rm {stale} (simulado)")
    332         return
    333     try:
    334         stale.unlink()
    335         log.info("Removido (política skel): %s", stale)
    336         summary.copied.append(f"removido {stale}")
    337     except OSError as e:
    338         summary.errors.append(f"remover {stale}: {e}")
    339         log.error("Não foi possível remover %s: %s", stale, e)
    340 
    341 
    342 def remove_jail_ssh_baseline(
    343     *,
    344     dry_run: bool,
    345     log: logging.Logger,
    346     summary: RunSummary,
    347 ) -> None:
    348     """Remove o drop-in antigo de ChrootDirectory para membros runv."""
    349     if not DEST_SSHD_DROPIN.exists():
    350         log.info("Drop-in SSH runv-jailed ausente: %s", DEST_SSHD_DROPIN)
    351         summary.skipped.append(str(DEST_SSHD_DROPIN))
    352         return
    353     if dry_run:
    354         log.info("[dry-run] removeria %s; testaria sshd -t; recarregaria ssh", DEST_SSHD_DROPIN)
    355         summary.copied.append(f"remover {DEST_SSHD_DROPIN} (simulado)")
    356         return
    357     try:
    358         DEST_SSHD_DROPIN.unlink()
    359     except OSError as e:
    360         msg = f"remover {DEST_SSHD_DROPIN}: {e}"
    361         summary.errors.append(msg)
    362         log.error("%s", msg)
    363         return
    364     summary.copied.append(f"removido {DEST_SSHD_DROPIN}")
    365 
    366     test = subprocess.run(
    367         ["sshd", "-t"],
    368         capture_output=True,
    369         text=True,
    370         timeout=30,
    371     )
    372     if test.returncode != 0:
    373         err = (test.stderr or test.stdout or "").strip()
    374         msg = f"sshd -t falhou após remover drop-in runv-jailed: {err}"
    375         summary.errors.append(msg)
    376         log.error("%s", msg)
    377         return
    378 
    379     reloaded = False
    380     for unit in ("ssh", "sshd"):
    381         rr = subprocess.run(
    382             ["systemctl", "reload", unit],
    383             capture_output=True,
    384             text=True,
    385             timeout=60,
    386         )
    387         if rr.returncode == 0:
    388             log.info("systemctl reload %s concluído", unit)
    389             reloaded = True
    390             break
    391         log.debug("systemctl reload %s: %s", unit, (rr.stderr or rr.stdout or "").strip())
    392     if not reloaded:
    393         msg = "systemctl reload ssh/sshd falhou — recarregue o sshd manualmente"
    394         summary.errors.append(msg)
    395         log.error("%s", msg)
    396 
    397 
    398 def install_skel(
    399     *,
    400     force: bool,
    401     dry_run: bool,
    402     log: logging.Logger,
    403     summary: RunSummary,
    404 ) -> None:
    405     """Copia apenas arquivos modelo; não instala pacotes."""
    406     if not dry_run:
    407         DEST_SKEL.mkdir(parents=True, exist_ok=True)
    408 
    409     remove_obsolete_skel_readme(dry_run=dry_run, log=log, summary=summary)
    410 
    411     skel_files: list[tuple[Path, Path, int]] = [
    412         (SKEL_DIR / ".bash_aliases", DEST_SKEL / ".bash_aliases", 0o644),
    413         (SKEL_DIR / ".plan", DEST_SKEL / ".plan", 0o644),
    414         (SKEL_DIR / ".project", DEST_SKEL / ".project", 0o644),
    415     ]
    416     for src, dst, mode in skel_files:
    417         copy_one(src, dst, mode, force=force, dry_run=dry_run, log=log, summary=summary)
    418 
    419     runv_skel_dir = DEST_SKEL / ".runv"
    420     profile_src = SKEL_DIR / ".runv" / "profile.json"
    421     profile_dst = runv_skel_dir / "profile.json"
    422     if not profile_src.is_file():
    423         summary.errors.append(f"origem inexistente: {profile_src}")
    424         log.error("Origem inexistente: %s", profile_src)
    425     else:
    426         if not dry_run:
    427             runv_skel_dir.mkdir(parents=True, exist_ok=True)
    428             os.chmod(runv_skel_dir, 0o755)
    429             try:
    430                 os.chown(runv_skel_dir, 0, 0)
    431             except OSError as e:
    432                 log.warning("chown em %s: %s", runv_skel_dir, e)
    433         elif not runv_skel_dir.exists():
    434             log.info("[dry-run] criaria diretório %s (755)", runv_skel_dir)
    435         copy_one(
    436             profile_src,
    437             profile_dst,
    438             0o644,
    439             force=force,
    440             dry_run=dry_run,
    441             log=log,
    442             summary=summary,
    443         )
    444 
    445     pub_dir = DEST_SKEL / "public_html"
    446     index_src = SKEL_DIR / "public_html" / "index.html"
    447     index_dst = pub_dir / "index.html"
    448 
    449     if not index_src.is_file():
    450         summary.errors.append(f"origem inexistente: {index_src}")
    451         log.error("Origem inexistente: %s", index_src)
    452         return
    453 
    454     if not dry_run:
    455         pub_dir.mkdir(parents=True, exist_ok=True)
    456         os.chmod(pub_dir, 0o755)
    457         try:
    458             os.chown(pub_dir, 0, 0)
    459         except OSError as e:
    460             log.warning("chown em %s: %s", pub_dir, e)
    461     elif not pub_dir.exists() and dry_run:
    462         log.info("[dry-run] criaria diretório %s (755)", pub_dir)
    463 
    464     copy_one(
    465         index_src,
    466         index_dst,
    467         0o644,
    468         force=force,
    469         dry_run=dry_run,
    470         log=log,
    471         summary=summary,
    472     )
    473 
    474     if not dry_run and pub_dir.is_dir():
    475         os.chmod(pub_dir, 0o755)
    476         try:
    477             os.chown(pub_dir, 0, 0)
    478         except OSError:
    479             pass
    480 
    481     # public_gopher / public_gemini (Gopher / Gemini — mesmo critério que public_html)
    482     gopher_dir = DEST_SKEL / "public_gopher"
    483     gopher_src = SKEL_DIR / "public_gopher" / "gophermap"
    484     gopher_dst = gopher_dir / "gophermap"
    485     gemini_dir = DEST_SKEL / "public_gemini"
    486     gemini_src = SKEL_DIR / "public_gemini" / "index.gmi"
    487     gemini_dst = gemini_dir / "index.gmi"
    488 
    489     if not gopher_src.is_file() or not gemini_src.is_file():
    490         for p in (gopher_src, gemini_src):
    491             if not p.is_file():
    492                 summary.errors.append(f"origem inexistente: {p}")
    493                 log.error("Origem inexistente: %s", p)
    494     else:
    495         if not dry_run:
    496             gopher_dir.mkdir(parents=True, exist_ok=True)
    497             os.chmod(gopher_dir, 0o755)
    498             gemini_dir.mkdir(parents=True, exist_ok=True)
    499             os.chmod(gemini_dir, 0o755)
    500             try:
    501                 os.chown(gopher_dir, 0, 0)
    502                 os.chown(gemini_dir, 0, 0)
    503             except OSError as e:
    504                 log.warning("chown em skel gopher/gemini: %s", e)
    505         else:
    506             if not gopher_dir.exists():
    507                 log.info("[dry-run] criaria diretório %s (755)", gopher_dir)
    508             if not gemini_dir.exists():
    509                 log.info("[dry-run] criaria diretório %s (755)", gemini_dir)
    510 
    511         copy_one(
    512             gopher_src,
    513             gopher_dst,
    514             0o644,
    515             force=force,
    516             dry_run=dry_run,
    517             log=log,
    518             summary=summary,
    519         )
    520         if gemini_dst.is_file():
    521             log.info("Destino já existe, mantido (index.gmi em skel): %s", gemini_dst)
    522             summary.skipped.append(str(gemini_dst))
    523         else:
    524             copy_one(
    525                 gemini_src,
    526                 gemini_dst,
    527                 0o644,
    528                 force=force,
    529                 dry_run=dry_run,
    530                 log=log,
    531                 summary=summary,
    532             )
    533 
    534         if not dry_run:
    535             if gopher_dir.is_dir():
    536                 os.chmod(gopher_dir, 0o755)
    537             if gemini_dir.is_dir():
    538                 os.chmod(gemini_dir, 0o755)
    539 
    540 
    541 def apply_irc_patch(
    542     *,
    543     dry_run: bool,
    544     log: logging.Logger,
    545     summary: RunSummary,
    546 ) -> None:
    547     if not PATCH_IRC_PATH.is_file():
    548         msg = f"patch IRC não encontrado: {PATCH_IRC_PATH}"
    549         summary.errors.append(msg)
    550         log.error("%s", msg)
    551         return
    552 
    553     cmd = [sys.executable, str(PATCH_IRC_PATH), "--all-users", "--force"]
    554     if dry_run:
    555         log.info("[dry-run] %s", " ".join(cmd))
    556         summary.copied.append(f"patch IRC (simulado): {' '.join(cmd)}")
    557         return
    558 
    559     r = run_subprocess(cmd, dry_run=False, log=log)
    560     assert r is not None
    561     if r.returncode != 0:
    562         err = (r.stderr or r.stdout or "").strip()
    563         msg = f"patch_irc.py falhou (código {r.returncode})" + (f": {err}" if err else "")
    564         summary.errors.append(msg)
    565         log.error("%s", msg)
    566         return
    567 
    568     summary.copied.append("patch IRC aplicado a todos os utilizadores (--force)")
    569     if r.stdout.strip():
    570         log.info("patch IRC: %s", r.stdout.strip().splitlines()[-1])
    571 
    572 
    573 def remove_existing_jails(
    574     *,
    575     dry_run: bool,
    576     log: logging.Logger,
    577     summary: RunSummary,
    578 ) -> None:
    579     if not REMOVE_JAILS_PATH.is_file():
    580         msg = f"remove_runv_jails.py não encontrado: {REMOVE_JAILS_PATH}"
    581         summary.errors.append(msg)
    582         log.error("%s", msg)
    583         return
    584 
    585     cmd = [sys.executable, str(REMOVE_JAILS_PATH)]
    586     if dry_run:
    587         cmd.append("--dry-run")
    588     if log.isEnabledFor(logging.DEBUG):
    589         cmd.append("--verbose")
    590 
    591     r = run_subprocess(cmd, dry_run=False if not dry_run else True, log=log)
    592     if dry_run:
    593         summary.copied.append(f"remoção de jails existentes (simulado): {' '.join(cmd)}")
    594         return
    595 
    596     assert r is not None
    597     if r.returncode != 0:
    598         err = (r.stderr or r.stdout or "").strip()
    599         msg = f"remove_runv_jails.py falhou (código {r.returncode})" + (f": {err}" if err else "")
    600         summary.errors.append(msg)
    601         log.error("%s", msg)
    602         return
    603 
    604     summary.copied.append("jails SSH removidas de utilizadores existentes")
    605     if r.stdout.strip():
    606         log.info("remove_runv_jails.py: %s", r.stdout.strip().splitlines()[-1])
    607 
    608 
    609 def print_summary(summary: RunSummary, log: logging.Logger) -> None:
    610     print()
    611     print("========== runv-tools — resumo ==========")
    612     if summary.dry_run:
    613         print("Modo: DRY-RUN (nenhuma alteração no sistema)")
    614     print(f"apt-get update executado/simulado: {summary.apt_updated}")
    615     if summary.packages_requested:
    616         print(f"Pacotes (manifesto): {', '.join(summary.packages_requested)}")
    617     print(f"Instalação apt tentada/simulada: {summary.apt_install_attempted}")
    618     if summary.copied:
    619         print("Copiados / simulados:")
    620         for c in summary.copied:
    621             print(f"  + {c}")
    622     if summary.skipped:
    623         print("Sem alteração (destino já idêntico à origem no repositório):")
    624         for s in summary.skipped:
    625             print(f"  = {s}")
    626     if summary.errors:
    627         print("Erros:")
    628         for e in summary.errors:
    629             print(f"  ! {e}")
    630         print("==========================================")
    631         sys.exit(1)
    632     print("Concluído sem erros fatais registrados pelo script.")
    633     print("==========================================")
    634 
    635 
    636 def parse_args(argv: list[str] | None) -> argparse.Namespace:
    637     p = argparse.ArgumentParser(
    638         description="Instala pacotes globais, comandos runv, MOTD e arquivos em /etc/skel.",
    639     )
    640     p.add_argument(
    641         "--dry-run",
    642         action="store_true",
    643         help="não altera o sistema; mostra o que seria feito",
    644     )
    645     p.add_argument(
    646         "--verbose",
    647         action="store_true",
    648         help="log detalhado",
    649     )
    650     p.add_argument(
    651         "--force",
    652         action="store_true",
    653         help="sobrescreve sempre (mesmo conteúdo idêntico); sem isto, só copia se origem e destino diferirem",
    654     )
    655     p.add_argument(
    656         "--skip-apt",
    657         action="store_true",
    658         help="não executa apt-get (útil para reaplicar só arquivos/MOTD/skel)",
    659     )
    660     p.add_argument(
    661         "--reconcile-existing-users",
    662         action="store_true",
    663         help="reaplica/verifica patch IRC em utilizadores já existentes",
    664     )
    665     p.add_argument(
    666         "--remove-existing-jails",
    667         action="store_true",
    668         help="remove runv-jailed, binds/fstab e /srv/jail de membros existentes",
    669     )
    670     return p.parse_args(argv)
    671 
    672 
    673 def main(argv: list[str] | None = None) -> int:
    674     args = parse_args(argv)
    675     ensure_admin_cli(
    676         script_name=Path(__file__).name,
    677         dry_run=bool(args.dry_run),
    678     )
    679     log = setup_logging(args.verbose)
    680     summary = RunSummary(dry_run=args.dry_run)
    681 
    682     if not args.dry_run:
    683         require_root(log)
    684     else:
    685         log.info("Dry-run: validação de root ignorada (nada será gravado).")
    686 
    687     if not args.skip_apt:
    688         pkgs = read_apt_manifest(MANIFEST_PATH, log)
    689         install_apt_packages(pkgs, dry_run=args.dry_run, log=log, summary=summary)
    690     else:
    691         log.info("Etapa apt ignorada (--skip-apt).")
    692 
    693     log.info("Instalando scripts em %s", DEST_BIN_DIR)
    694     install_bin_scripts(force=args.force, dry_run=args.dry_run, log=log, summary=summary)
    695 
    696     log.info("Instalando biblioteca comunitária em %s", DEST_COMMUNITY_LIB_DIR)
    697     install_community_lib(force=args.force, dry_run=args.dry_run, log=log, summary=summary)
    698 
    699     log.info("Instalando MOTD em %s", DEST_MOTD)
    700     install_motd(force=args.force, dry_run=args.dry_run, log=log, summary=summary)
    701 
    702     log.info("Garantindo sudo administrativo para pmurad-admin")
    703     install_admin_sudoers(
    704         force=args.force,
    705         dry_run=args.dry_run,
    706         log=log,
    707         summary=summary,
    708     )
    709 
    710     log.info("Removendo baseline antigo de SSH runv-jailed (sem jail por padrão)")
    711     remove_jail_ssh_baseline(
    712         dry_run=args.dry_run,
    713         log=log,
    714         summary=summary,
    715     )
    716 
    717     log.info("Sincronizando skel em %s", DEST_SKEL)
    718     install_skel(force=args.force, dry_run=args.dry_run, log=log, summary=summary)
    719 
    720     if args.remove_existing_jails:
    721         log.info("Removendo jails SSH de utilizadores existentes")
    722         remove_existing_jails(dry_run=args.dry_run, log=log, summary=summary)
    723     else:
    724         log.info("Jails existentes não serão removidas (sem --remove-existing-jails).")
    725 
    726     if args.reconcile_existing_users:
    727         log.info("Aplicando patch IRC (chat / WeeChat) aos utilizadores existentes")
    728         apply_irc_patch(dry_run=args.dry_run, log=log, summary=summary)
    729     else:
    730         log.info("Patch IRC em utilizadores existentes ignorado (sem --reconcile-existing-users).")
    731 
    732     print_summary(summary, log)
    733     return 0
    734 
    735 
    736 if __name__ == "__main__":
    737     raise SystemExit(main())