runv-server

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

setup_entre.py (36234B)


      1 #!/usr/bin/env python3
      2 """
      3 Prepara infraestrutura do utilizador «entre» e instala o módulo terminal em
      4 /opt/runv/terminal.
      5 
      6 Onboarding estilo tilde.town (join@tilde.town):
      7   O padrão documentado por tilde.town usa utilizador especial + Match User + SSH com
      8   PasswordAuthentication, PermitEmptyPasswords yes, PubkeyAuthentication no, e muitas vezes
      9   uma linha em /etc/pam.d/sshd com pam_succeed_if (ex.: user ingroup join) para que a
     10   autenticação PAM não exija palavra-passe para esse grupo. Não é «sem autenticação» no
     11   protocolo: é aceitar palavra-passe vazia / sucesso PAM antecipado só para essa conta
     12   e políticas explícitas. Deliberadamente menos seguro — usar só para onboarding público,
     13   não para contas normais.
     14 
     15 Modo recomendado neste projecto (default): --auth-mode empty-password
     16   Onboarding público estilo tilde.town, com PAM pam_succeed_if por omissão.
     17 
     18 Modo --auth-mode empty-password (primeira classe):
     19   Replica o espírito tilde.town para «entre»: senha vazia (passwd -d), grupo suplementar
     20   (omissão: entre-open), e por omissão drop-in com AuthenticationMethods keyboard-interactive
     21   + KbdInteractiveAuthentication yes (PAM pam_succeed_if sem prompts) — compatível com
     22   OpenSSH do Windows, que em geral não envia palavra-passe vazia no método password.
     23   Por omissão altera /etc/pam.d/sshd (pam_succeed_if user ingroup …) com backup — no Debian,
     24   sem isto o PAM recusa o fluxo e a sessão pode fechar. Use --skip-pam-empty-password-rule
     25   só se configurar PAM à mão.
     26   Para o esquema README tilde (password + PermitEmptyPasswords yes), use
     27   --empty-password-tilde-password-auth (Linux/Git Bash).
     28 
     29 Porque /bin/sh e não nologin:
     30   O OpenSSH usa o shell de passwd no contexto do login; nologin impede o fluxo até ao
     31   ForceCommand. Use /bin/sh; o visitante não fica com shell interactivo normal.
     32 
     33 Por defeito (sem --skip-sshd):
     34   - cria «entre» com /bin/sh; chsh se já existir com outro shell;
     35   - em empty-password: grupo onboarding, membro, passwd -d, validação NP, regra PAM (por omissão);
     36   - escreve runv-entre.conf; sshd -t; sshd -T -C …; reload ssh.
     37 
     38 Use --skip-sshd / --no-reload / --dry-run conforme necessário.
     39 
     40 Executar como root no servidor Debian.
     41 
     42 Reexecução: com instalação existente, em TTY pede confirmação antes de actualizar o módulo
     43   e (em separado) antes de substituir config.toml; use --yes / --force-config para automatizar.
     44 
     45 Versão 0.11 — runv.club
     46 """
     47 
     48 from __future__ import annotations
     49 
     50 import argparse
     51 import grp
     52 import os
     53 import pwd
     54 import re
     55 import shutil
     56 import subprocess
     57 import sys
     58 import time
     59 from pathlib import Path
     60 from typing import Final
     61 
     62 _ADMIN_DIR = Path(__file__).resolve().parent.parent / "scripts" / "admin"
     63 if str(_ADMIN_DIR) not in sys.path:
     64     sys.path.insert(0, str(_ADMIN_DIR))
     65 
     66 from admin_guard import ensure_admin_cli
     67 from gen_config_toml import write_terminal_config_toml  # type: ignore
     68 
     69 VERSION: Final[str] = "0.11"
     70 ENTRE_USER: Final[str] = "entre"
     71 INSTALL_ROOT: Final[Path] = Path("/opt/runv/terminal")
     72 QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue")
     73 LOG_DIR: Final[Path] = Path("/var/log/runv")
     74 SSHD_DROPIN: Final[Path] = Path("/etc/ssh/sshd_config.d/runv-entre.conf")
     75 PAM_SSHD: Final[Path] = Path("/etc/pam.d/sshd")
     76 MODULE_SRC: Final[Path] = Path(__file__).resolve().parent
     77 
     78 AUTH_SHARED: Final[str] = "shared-password"
     79 AUTH_KEY: Final[str] = "key-only"
     80 AUTH_EMPTY: Final[str] = "empty-password"
     81 
     82 # Grupo suplementar para PAM pam_succeed_if (tilde.town usa «join»; aqui «entre-open»).
     83 ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT: Final[str] = "entre-open"
     84 
     85 INSECURE_EMPTY_BANNER: Final[str] = """
     86 ******************************************************************************
     87 * AVISO: modo empty-password — onboarding estilo tilde.town / join@tilde.town   *
     88 * Não é «SSH sem autenticação»: é palavra-passe vazia + políticas só para «entre». *
     89 * Qualquer cliente que alcance o porto SSH pode entrar nesta conta.            *
     90 * Não use para contas normais nem exponha sem firewall / política consciente.   *
     91 ******************************************************************************
     92 """
     93 
     94 
     95 def eprint(msg: str) -> None:
     96     print(msg, file=sys.stderr)
     97 
     98 
     99 def prompt_yes(question: str, *, default: bool) -> bool:
    100     """Confirmação em TTY; fora de TTY devolve ``default``."""
    101     if not sys.stdin.isatty():
    102         return default
    103     suffix = "[S/n]" if default else "[s/N]"
    104     try:
    105         raw = input(f"{question}{suffix} ").strip().lower()
    106     except EOFError:
    107         return default
    108     if not raw:
    109         return default
    110     return raw in ("s", "sim", "y", "yes")
    111 
    112 
    113 def require_root() -> None:
    114     if os.geteuid() != 0:
    115         eprint("Execute como root (sudo).")
    116         raise SystemExit(1)
    117 
    118 
    119 def run(cmd: list[str], *, timeout: int = 120) -> None:
    120     r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    121     if r.returncode != 0:
    122         err = (r.stderr or r.stdout or "").strip()
    123         raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}")
    124 
    125 
    126 def run_capture(cmd: list[str], *, timeout: int = 120) -> str:
    127     r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    128     if r.returncode != 0:
    129         err = (r.stderr or r.stdout or "").strip()
    130         raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}")
    131     return (r.stdout or "").strip()
    132 
    133 
    134 def user_exists(name: str) -> bool:
    135     try:
    136         pwd.getpwnam(name)
    137     except KeyError:
    138         return False
    139     return True
    140 
    141 
    142 def group_exists(name: str) -> bool:
    143     try:
    144         grp.getgrnam(name)
    145     except KeyError:
    146         return False
    147     return True
    148 
    149 
    150 def user_in_group(username: str, group_name: str) -> bool:
    151     try:
    152         g = grp.getgrnam(group_name)
    153     except KeyError:
    154         return False
    155     if username in g.gr_mem:
    156         return True
    157     try:
    158         pw = pwd.getpwnam(username)
    159     except KeyError:
    160         return False
    161     return pw.pw_gid == g.gr_gid
    162 
    163 
    164 def ensure_onboarding_group(
    165     group_name: str,
    166     *,
    167     dry_run: bool,
    168 ) -> None:
    169     if dry_run:
    170         print(f"[dry-run] groupadd -f {group_name!r} (se não existir)")
    171         return
    172     if not group_exists(group_name):
    173         run(["groupadd", group_name])
    174         print(f"Criado grupo {group_name!r}.")
    175     else:
    176         print(f"Grupo {group_name!r} já existe.")
    177 
    178 
    179 def ensure_user_in_onboarding_group(group_name: str, *, dry_run: bool) -> None:
    180     if dry_run:
    181         print(f"[dry-run] usermod -aG {group_name} {ENTRE_USER}")
    182         return
    183     if user_in_group(ENTRE_USER, group_name):
    184         print(f"{ENTRE_USER!r} já está no grupo {group_name!r}.")
    185         return
    186     run(["usermod", "-aG", group_name, ENTRE_USER])
    187     print(f"Adicionado {ENTRE_USER!r} ao grupo {group_name!r}.")
    188 
    189 
    190 def pam_line_for_onboarding_group(group_name: str) -> str:
    191     return (
    192         "auth [success=done default=ignore] pam_succeed_if.so "
    193         f"user ingroup {group_name}"
    194     )
    195 
    196 
    197 def install_pam_empty_password_rule(
    198     group_name: str,
    199     *,
    200     dry_run: bool,
    201 ) -> None:
    202     """
    203     Insere regra tilde.town-style antes da autenticação PAM padrão (ex.: @include common-auth).
    204     Backup: /etc/pam.d/sshd.bak.<timestamp>
    205     """
    206     line = pam_line_for_onboarding_group(group_name)
    207     marker = f"runv.club setup_entre.py — onboarding {group_name}"
    208     block = (
    209         f"# {marker}\n"
    210         f"{line}\n"
    211     )
    212 
    213     if dry_run:
    214         print(f"[dry-run] backup + inserir em {PAM_SSHD}:\n{line}")
    215         return
    216 
    217     if not PAM_SSHD.is_file():
    218         raise RuntimeError(f"{PAM_SSHD} não existe; não é possível instalar regra PAM.")
    219 
    220     current = PAM_SSHD.read_text(encoding="utf-8", errors="replace")
    221     if line in current:
    222         print(f"Regra PAM já presente em {PAM_SSHD} (saltar).")
    223         return
    224 
    225     backup = PAM_SSHD.with_name(f"{PAM_SSHD.name}.bak.{int(time.time())}")
    226     shutil.copy2(PAM_SSHD, backup)
    227     print(f"Backup PAM: {backup}")
    228 
    229     lines = current.splitlines(keepends=True)
    230     insert_at = 0
    231     for i, raw in enumerate(lines):
    232         s = raw.strip()
    233         if not s or s.startswith("#"):
    234             continue
    235         if s.startswith("@include") or re.match(r"^auth\s", s):
    236             insert_at = i
    237             break
    238         insert_at = i + 1
    239 
    240     part1 = [lines[j] for j in range(insert_at)]
    241     part2 = [lines[j] for j in range(insert_at, len(lines))]
    242     new_body = "".join(part1) + block + "".join(part2)
    243     PAM_SSHD.write_text(new_body, encoding="utf-8")
    244     print(f"Inserida regra PAM em {PAM_SSHD} (antes da auth padrão).")
    245 
    246 
    247 def ensure_user_entre(*, home: Path, shell: str) -> None:
    248     if user_exists(ENTRE_USER):
    249         print(f"Utilizador {ENTRE_USER!r} já existe.")
    250         return
    251     run(
    252         [
    253             "useradd",
    254             "--create-home",
    255             "--home-dir",
    256             str(home),
    257             "--shell",
    258             shell,
    259             "--user-group",
    260             ENTRE_USER,
    261         ]
    262     )
    263     print(f"Criado utilizador {ENTRE_USER!r} (shell {shell!r}).")
    264 
    265 
    266 def ensure_entre_shell(shell: str, *, dry_run: bool) -> None:
    267     """Garante shell em passwd (ex.: migração de contas antigas com nologin)."""
    268     if dry_run:
    269         return
    270     pw = pwd.getpwnam(ENTRE_USER)
    271     if pw.pw_shell == shell:
    272         return
    273     run(["chsh", "-s", shell, ENTRE_USER])
    274     print(f"Shell de {ENTRE_USER!r} actualizado de {pw.pw_shell!r} para {shell!r}.")
    275 
    276 
    277 def ensure_entre_dot_ssh(home: Path, uid: int, gid: int, *, dry_run: bool) -> None:
    278     """Garante ~/.ssh/authorized_keys com modos correctos (ficheiro pode ficar vazio)."""
    279     if dry_run:
    280         print(f"[dry-run] garantiria {home}/.ssh e authorized_keys")
    281         return
    282     home.mkdir(parents=True, exist_ok=True)
    283     try:
    284         os.chown(home, uid, gid)
    285     except OSError:
    286         pass
    287     ssh = home / ".ssh"
    288     ssh.mkdir(mode=0o700, exist_ok=True)
    289     os.chmod(ssh, 0o700)
    290     os.chown(ssh, uid, gid)
    291     auth = ssh / "authorized_keys"
    292     if not auth.exists():
    293         auth.write_text("", encoding="utf-8")
    294     os.chmod(auth, 0o600)
    295     os.chown(auth, uid, gid)
    296     print(f"Garantido {ssh} e {auth} (dono {ENTRE_USER}).")
    297 
    298 
    299 def clear_entre_password(*, dry_run: bool) -> None:
    300     """Palavra-passe vazia (modo empty-password)."""
    301     if dry_run:
    302         print("[dry-run] passwd -d entre (palavra-passe vazia)")
    303         return
    304     run(["passwd", "-d", ENTRE_USER])
    305     print(f"Palavra-passe de {ENTRE_USER!r} removida (passwd -d).")
    306 
    307 
    308 def assert_entre_password_empty(*, dry_run: bool) -> None:
    309     """Estado NP em passwd -S (sem palavra-passe utilizável)."""
    310     if dry_run:
    311         print("[dry-run] validaria passwd -S entre (esperado NP)")
    312         return
    313     out = run_capture(["passwd", "-S", ENTRE_USER], timeout=30)
    314     parts = out.split()
    315     if len(parts) < 2:
    316         raise RuntimeError(f"passwd -S inesperado: {out!r}")
    317     status = parts[1]
    318     if status != "NP":
    319         raise RuntimeError(
    320             f"Esperava estado NP (sem palavra-passe) após passwd -d; obtido {status!r} "
    321             f"em «{out}». Verifique bloqueios (usermod -U) ou política de palavras-passe."
    322         )
    323     print(f"passwd -S: {ENTRE_USER!r} está NP (sem palavra-passe utilizável).")
    324 
    325 
    326 def build_sshd_dropin_content(
    327     python_path: str,
    328     app_path: Path,
    329     auth_mode: str,
    330     *,
    331     empty_ssh_auth: str | None = None,
    332 ) -> str:
    333     cmd = f"{python_path} {app_path}"
    334     header = (
    335         f"# Instalado por runv.club setup_entre.py — auth_mode={auth_mode}\n"
    336         f"# Validar: sshd -t\n"
    337     )
    338     if auth_mode == AUTH_EMPTY:
    339         header += "# Onboarding tilde.town-style: PAM pam_succeed_if + conta especial entre.\n"
    340 
    341     lines = [
    342         header.rstrip(),
    343         f"Match User {ENTRE_USER}",
    344     ]
    345 
    346     if auth_mode == AUTH_SHARED:
    347         lines.extend(
    348             [
    349                 "    AuthenticationMethods password",
    350                 "    PasswordAuthentication yes",
    351                 "    KbdInteractiveAuthentication no",
    352                 "    PubkeyAuthentication no",
    353                 "    PermitEmptyPasswords no",
    354             ]
    355         )
    356     elif auth_mode == AUTH_KEY:
    357         lines.extend(
    358             [
    359                 "    AuthenticationMethods publickey",
    360                 "    PasswordAuthentication no",
    361                 "    KbdInteractiveAuthentication no",
    362                 "    PubkeyAuthentication yes",
    363                 "    PermitEmptyPasswords no",
    364             ]
    365         )
    366     elif auth_mode == AUTH_EMPTY:
    367         # Omissão: keyboard-interactive + PAM (compatível com OpenSSH Windows; sem senha vazia no wire).
    368         # tilde-password: como README tilde (password + PermitEmptyPasswords); Linux/Git Bash.
    369         if empty_ssh_auth == "password":
    370             lines.extend(
    371                 [
    372                     "    AuthenticationMethods password",
    373                     "    PasswordAuthentication yes",
    374                     "    KbdInteractiveAuthentication no",
    375                     "    PubkeyAuthentication no",
    376                     "    PermitEmptyPasswords yes",
    377                 ]
    378             )
    379         else:
    380             lines.extend(
    381                 [
    382                     "    AuthenticationMethods keyboard-interactive",
    383                     "    PasswordAuthentication no",
    384                     "    KbdInteractiveAuthentication yes",
    385                     "    PubkeyAuthentication no",
    386                     "    PermitEmptyPasswords no",
    387                 ]
    388             )
    389     else:
    390         raise ValueError(f"auth_mode desconhecido: {auth_mode!r}")
    391 
    392     lines.extend(
    393         [
    394             f"    ForceCommand {cmd}",
    395             "    PermitTTY yes",
    396             "    PermitUserRC no",
    397             "    X11Forwarding no",
    398             "    AllowAgentForwarding no",
    399             "    AllowTcpForwarding no",
    400             "    PermitTunnel no",
    401             "    DisableForwarding yes",
    402             "",
    403         ]
    404     )
    405     return "\n".join(lines)
    406 
    407 
    408 def parse_sshd_t(output: str) -> dict[str, str]:
    409     cfg: dict[str, str] = {}
    410     for raw in output.splitlines():
    411         line = raw.strip()
    412         if not line or line.startswith("#"):
    413             continue
    414         parts = line.split(None, 1)
    415         if len(parts) == 1:
    416             cfg[parts[0].lower()] = ""
    417         else:
    418             cfg[parts[0].lower()] = parts[1].strip()
    419     return cfg
    420 
    421 
    422 def _norm_ws(s: str) -> str:
    423     return " ".join(s.split())
    424 
    425 
    426 def validate_effective_sshd(
    427     *,
    428     conn: str,
    429     force_command: str,
    430     auth_mode: str,
    431     empty_ssh_auth: str | None = None,
    432 ) -> None:
    433     """Confirma opções efectivas para Match User entre via sshd -T -C."""
    434     try:
    435         out = run_capture(["sshd", "-T", "-C", conn], timeout=60)
    436     except RuntimeError as e:
    437         raise RuntimeError(
    438             "Validação sshd -T -C falhou (sshd inacessível ou -C inválido?). "
    439             f"Detalhe: {e}"
    440         ) from e
    441 
    442     cfg = parse_sshd_t(out)
    443     errs: list[str] = []
    444 
    445     fc_eff = _norm_ws(cfg.get("forcecommand", ""))
    446     fc_exp = _norm_ws(force_command)
    447     if not fc_eff or (fc_eff != fc_exp and fc_exp not in fc_eff and fc_eff not in fc_exp):
    448         errs.append(f"forcecommand: esperado «{fc_exp}», efectivo «{fc_eff}»")
    449 
    450     if cfg.get("permittty", "").lower() != "yes":
    451         errs.append(f"permittty: esperado yes, efectivo «{cfg.get('permittty', '')}»")
    452 
    453     if cfg.get("disableforwarding", "").lower() != "yes":
    454         errs.append(
    455             f"disableforwarding: esperado yes, efectivo «{cfg.get('disableforwarding', '')}»"
    456         )
    457 
    458     if "permituserrc" in cfg and cfg.get("permituserrc", "").lower() != "no":
    459         errs.append(f"permituserrc: esperado no, efectivo «{cfg.get('permituserrc', '')}»")
    460 
    461     am = cfg.get("authenticationmethods", "").lower().replace(",", " ")
    462     pw = cfg.get("passwordauthentication", "").lower()
    463     pk = cfg.get("pubkeyauthentication", "").lower()
    464     kbd = cfg.get("kbdinteractiveauthentication", "").lower()
    465     empty = cfg.get("permitemptypasswords", "").lower()
    466 
    467     if auth_mode == AUTH_SHARED:
    468         if "password" not in am.split():
    469             errs.append(f"authenticationmethods: esperado incluir password, efectivo «{am}»")
    470         if pw != "yes":
    471             errs.append(f"passwordauthentication: esperado yes, efectivo «{pw}»")
    472         if pk != "no":
    473             errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»")
    474         if kbd != "no":
    475             errs.append(f"kbdinteractiveauthentication: esperado no, efectivo «{kbd}»")
    476         if empty != "no":
    477             errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»")
    478     elif auth_mode == AUTH_KEY:
    479         if "publickey" not in am.split():
    480             errs.append(f"authenticationmethods: esperado incluir publickey, efectivo «{am}»")
    481         if pw != "no":
    482             errs.append(f"passwordauthentication: esperado no, efectivo «{pw}»")
    483         if pk != "yes":
    484             errs.append(f"pubkeyauthentication: esperado yes, efectivo «{pk}»")
    485         if empty != "no":
    486             errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»")
    487     elif auth_mode == AUTH_EMPTY:
    488         if empty_ssh_auth == "password":
    489             if "password" not in am.split():
    490                 errs.append(f"authenticationmethods: esperado incluir password, efectivo «{am}»")
    491             if pw != "yes":
    492                 errs.append(f"passwordauthentication: esperado yes, efectivo «{pw}»")
    493             if pk != "no":
    494                 errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»")
    495             if kbd != "no":
    496                 errs.append(f"kbdinteractiveauthentication: esperado no, efectivo «{kbd}»")
    497             if empty != "yes":
    498                 errs.append(f"permitemptypasswords: esperado yes, efectivo «{empty}»")
    499         else:
    500             if "keyboard-interactive" not in am.split():
    501                 errs.append(
    502                     f"authenticationmethods: esperado incluir keyboard-interactive, efectivo «{am}»"
    503                 )
    504             if pw != "no":
    505                 errs.append(f"passwordauthentication: esperado no, efectivo «{pw}»")
    506             if kbd != "yes":
    507                 errs.append(
    508                     f"kbdinteractiveauthentication: esperado yes, efectivo «{kbd}»"
    509                 )
    510             if pk != "no":
    511                 errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»")
    512             if empty != "no":
    513                 errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»")
    514 
    515     if errs:
    516         raise RuntimeError(
    517             "Validação pós-configuração (sshd -T -C) falhou:\n  - "
    518             + "\n  - ".join(errs)
    519         )
    520 
    521 
    522 def sshd_main_config_mentions_dropin() -> bool:
    523     main = Path("/etc/ssh/sshd_config")
    524     if not main.is_file():
    525         return False
    526     try:
    527         text = main.read_text(encoding="utf-8", errors="replace")
    528     except OSError:
    529         return False
    530     return "sshd_config.d" in text and "Include" in text
    531 
    532 
    533 def apply_sshd_configuration(
    534     python_path: str,
    535     app_path: Path,
    536     *,
    537     install_root: Path,
    538     auth_mode: str,
    539     sshd_test_connection: str,
    540     empty_ssh_auth: str | None,
    541     dry_run: bool,
    542     skip_sshd: bool,
    543     no_reload: bool,
    544 ) -> None:
    545     force_cmd = f"{python_path} {app_path}"
    546     content = build_sshd_dropin_content(
    547         python_path, app_path, auth_mode, empty_ssh_auth=empty_ssh_auth
    548     )
    549 
    550     if skip_sshd:
    551         print()
    552         print("== Modo --skip-sshd: configure o SSH manualmente ==")
    553         print(
    554             "1. Opcional: editar",
    555             install_root / "config.toml",
    556             "— admin_email pode ficar vazio se /etc/runv-email.json já tiver admin_email; From padrão noreply@runv.club.",
    557         )
    558         print("2. Criar /etc/ssh/sshd_config.d/… com o bloco abaixo.")
    559         print("3. sshd -t && systemctl reload ssh")
    560         print("4. empty-password: regra PAM por omissão (ou --skip-pam-empty-password-rule).")
    561         print("5. Testar conforme --auth-mode.")
    562         print()
    563         print(content)
    564         return
    565 
    566     if dry_run:
    567         print(f"[dry-run] escreveria {SSHD_DROPIN} e correria sshd -t + validação -T")
    568         print("--- conteúdo ---")
    569         print(content)
    570         return
    571 
    572     if not sshd_main_config_mentions_dropin():
    573         print(
    574             "AVISO: /etc/ssh/sshd_config pode não incluir /etc/ssh/sshd_config.d/*.conf.\n"
    575             "  Confirme uma linha «Include … sshd_config.d» ou o drop-in não será lido.",
    576             file=sys.stderr,
    577         )
    578 
    579     SSHD_DROPIN.parent.mkdir(parents=True, exist_ok=True)
    580     backup: Path | None = None
    581     if SSHD_DROPIN.is_file():
    582         backup = SSHD_DROPIN.with_name(f"{SSHD_DROPIN.name}.bak.{int(time.time())}")
    583         shutil.copy2(SSHD_DROPIN, backup)
    584         print(f"Backup do drop-in anterior: {backup}")
    585 
    586     SSHD_DROPIN.write_text(content, encoding="utf-8")
    587     SSHD_DROPIN.chmod(0o644)
    588     print(f"Escrito {SSHD_DROPIN}")
    589 
    590     def revert() -> None:
    591         if backup is not None:
    592             shutil.copy2(backup, SSHD_DROPIN)
    593             print(f"Revertido {SSHD_DROPIN} a partir de {backup}.", file=sys.stderr)
    594         else:
    595             try:
    596                 SSHD_DROPIN.unlink()
    597             except OSError:
    598                 pass
    599             print(f"Removido {SSHD_DROPIN}.", file=sys.stderr)
    600 
    601     try:
    602         run(["sshd", "-t"])
    603     except RuntimeError as e:
    604         revert()
    605         raise RuntimeError("sshd -t falhou após instalar drop-in; configuração revertida.") from e
    606 
    607     print("sshd -t: OK.")
    608 
    609     try:
    610         validate_effective_sshd(
    611             conn=sshd_test_connection,
    612             force_command=force_cmd,
    613             auth_mode=auth_mode,
    614             empty_ssh_auth=empty_ssh_auth,
    615         )
    616     except RuntimeError as e:
    617         revert()
    618         raise RuntimeError(
    619             f"{e}\nConfiguração revertida; corrija o Match User ou a string -C de teste."
    620         ) from e
    621 
    622     print(f"Validação efectiva sshd -T -C {sshd_test_connection!r}: OK.")
    623 
    624     if no_reload:
    625         print("Saltado reload (--no-reload). Execute: systemctl reload ssh")
    626         return
    627 
    628     try:
    629         run(["systemctl", "reload", "ssh"], timeout=60)
    630     except RuntimeError:
    631         try:
    632             run(["systemctl", "reload", "sshd"], timeout=60)
    633         except RuntimeError as e2:
    634             raise RuntimeError(
    635                 "sshd -t e validação passaram mas falhou systemctl reload ssh/sshd; "
    636                 "recarregue o serviço SSH manualmente."
    637             ) from e2
    638     print("Serviço SSH recarregado (reload).")
    639 
    640 
    641 def copy_module(dest: Path, *, dry_run: bool) -> None:
    642     files = [
    643         "entre_app.py",
    644         "entre_core.py",
    645         "closed_app.py",
    646         "close_entre.py",
    647         "config.example.toml",
    648         "gen_config_toml.py",
    649         "README.md",
    650     ]
    651     subdirs = ["templates", "docs", "systemd", "scripts", "data", "examples"]
    652     if dry_run:
    653         print(f"[dry-run] copiaria para {dest}")
    654         return
    655     dest.mkdir(parents=True, exist_ok=True)
    656     for name in files:
    657         src = MODULE_SRC / name
    658         if src.is_file():
    659             shutil.copy2(src, dest / name)
    660     for sd in subdirs:
    661         s = MODULE_SRC / sd
    662         if s.is_dir():
    663             d = dest / sd
    664             if d.exists():
    665                 shutil.rmtree(d)
    666             shutil.copytree(s, d)
    667     print(f"Módulo copiado para {dest}")
    668 
    669 
    670 def install_config(dest: Path, *, dry_run: bool, force: bool) -> None:
    671     cfg = dest / "config.toml"
    672     example = dest / "config.example.toml"
    673     if dry_run:
    674         print(f"[dry-run] config em {cfg} (gen_config_toml)")
    675         return
    676     if not example.is_file():
    677         eprint(f"Aviso: {example} não encontrado.")
    678         return
    679     try:
    680         result = write_terminal_config_toml(
    681             example=example, out=cfg, force=force, dry_run=False
    682         )
    683     except FileNotFoundError as e:
    684         eprint(str(e))
    685         return
    686     if result == "skipped":
    687         print(f"Mantido {cfg} existente (use --force-config para regenerar do example).")
    688     else:
    689         print(f"Instalado {cfg} (gen_config_toml a partir do example).")
    690 
    691 
    692 def ensure_install_tree_permissions(root: Path, *, gid: int) -> None:
    693     """Permissões determinísticas para o módulo usado pelo ForceCommand."""
    694     # /opt/runv precisa ser atravessável para o utilizador entre chegar ao módulo.
    695     parent = root.parent
    696     if parent.exists():
    697         try:
    698             parent.chmod(0o755)
    699         except OSError:
    700             pass
    701 
    702     for dirpath, dirs, files in os.walk(root, followlinks=False):
    703         current = Path(dirpath)
    704         try:
    705             os.chown(current, 0, gid)
    706             current.chmod(0o750)
    707         except OSError:
    708             pass
    709 
    710         for name in dirs:
    711             p = current / name
    712             try:
    713                 os.chown(p, 0, gid, follow_symlinks=False)
    714                 p.chmod(0o750)
    715             except OSError:
    716                 pass
    717 
    718         for name in files:
    719             p = current / name
    720             try:
    721                 os.chown(p, 0, gid, follow_symlinks=False)
    722                 p.chmod(0o640)
    723             except OSError:
    724                 pass
    725 
    726     # O ForceCommand executa /usr/bin/python3 entre_app.py; Python precisa ler este ficheiro,
    727     # e os módulos/templates adjacentes também precisam ser legíveis pelo utilizador entre.
    728     for name in (
    729         "entre_app.py",
    730         "entre_core.py",
    731         "closed_app.py",
    732         "close_entre.py",
    733         "gen_config_toml.py",
    734         "config.toml",
    735         "config.example.toml",
    736     ):
    737         p = root / name
    738         if p.is_file():
    739             p.chmod(0o640)
    740 
    741 
    742 def print_final_instructions(
    743     *,
    744     auth_mode: str,
    745     install_root: Path,
    746     empty_group: str,
    747     pam_installed: bool,
    748     empty_ssh_auth: str | None,
    749 ) -> None:
    750     print()
    751     print("== Concluído ==")
    752     print(
    753         f"1. Opcional: {install_root / 'config.toml'} — regenere com "
    754         f"python3 {install_root / 'gen_config_toml.py'} --install-root {install_root} "
    755         "(ou --force para repor o example). Com /etc/runv-email.json, admin_email pode ficar vazio no TOML."
    756     )
    757 
    758     if auth_mode == AUTH_SHARED:
    759         print("2. Acesso por palavra-passe Unix partilhada (definida só pelo root):")
    760         print(f"      sudo passwd {ENTRE_USER}")
    761         print("   ou: echo 'entre:A_SENHA' | sudo chpasswd")
    762         print("3. Testar:")
    763         print("      ssh entre@runv.club")
    764     elif auth_mode == AUTH_KEY:
    765         auth_keys = Path(pwd.getpwnam(ENTRE_USER).pw_dir) / ".ssh" / "authorized_keys"
    766         print("2. Colocar chaves públicas em (uma linha por chave):")
    767         print(f"      {auth_keys}")
    768         print("3. Testar:")
    769         print("      ssh entre@runv.club")
    770     elif auth_mode == AUTH_EMPTY:
    771         print(INSECURE_EMPTY_BANNER)
    772         print("2. Onboarding estilo join@tilde.town:")
    773         print(f"   - Conta {ENTRE_USER!r} sem palavra-passe utilizável (passwd -d; estado NP).")
    774         print(f"   - Grupo suplementar {empty_group!r} (para alinhar com PAM pam_succeed_if).")
    775         if pam_installed:
    776             print(f"   - PAM: linha ingroup {empty_group!r} em /etc/pam.d/sshd (com backup .bak.*).")
    777         else:
    778             print("   - PAM: saltado (--skip-pam-empty-password-rule). No Debian o login com")
    779             print("     senha vazia falha sem pam_succeed_if antes de common-auth; volte a correr")
    780             print("     o setup sem --skip-pam ou edite /etc/pam.d/sshd à mão.")
    781         if empty_ssh_auth == "password":
    782             print("3. Testar (Enter em branco no prompt de palavra-passe):")
    783             print("      ssh entre@runv.club")
    784             print("   Nota: OpenSSH do Windows em geral não envia palavra-passe vazia neste modo.")
    785             print("   Use WSL/Git Bash, ou volte a correr o setup sem --empty-password-tilde-password-auth")
    786             print("   (omissão: keyboard-interactive, mais compatível com Windows).")
    787         else:
    788             print("3. Testar (omissão: keyboard-interactive + PAM; pode não pedir palavra-passe):")
    789             print("      ssh entre@runv.club")
    790             print("   Se aparecer prompt, tente Enter em branco; em Windows este modo costuma funcionar.")
    791 
    792 
    793 def main() -> int:
    794     parser = argparse.ArgumentParser(
    795         description="Setup utilizador entre + /opt/runv/terminal + OpenSSH (automatizado).",
    796     )
    797     parser.add_argument("--dry-run", action="store_true")
    798     parser.add_argument(
    799         "-y",
    800         "--yes",
    801         action="store_true",
    802         help="não perguntar em reinstalação; combinar com --force-config para repor config.toml sem prompt",
    803     )
    804     parser.add_argument("--force-config", action="store_true", help="sobrescrever config.toml com example")
    805     parser.add_argument("--home", type=Path, default=Path(f"/home/{ENTRE_USER}"))
    806     parser.add_argument(
    807         "--shell",
    808         default="/bin/sh",
    809         help="shell em passwd (ForceCommand precisa de shell funcional; não use nologin)",
    810     )
    811     parser.add_argument(
    812         "--auth-mode",
    813         choices=[AUTH_SHARED, AUTH_KEY, AUTH_EMPTY],
    814         default=AUTH_EMPTY,
    815         help="método SSH para «entre» (default: empty-password; onboarding tilde.town-style)",
    816     )
    817     parser.add_argument(
    818         "--empty-password-group",
    819         default=ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT,
    820         metavar="GRUPO",
    821         help=f"grupo suplementar em empty-password + PAM ingroup (default: {ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT})",
    822     )
    823     parser.add_argument(
    824         "--empty-password-tilde-password-auth",
    825         action="store_true",
    826         help="empty-password: password + PermitEmptyPasswords (README tilde); omissão usa "
    827         "keyboard-interactive (melhor no OpenSSH do Windows)",
    828     )
    829     parser.add_argument(
    830         "--skip-pam-empty-password-rule",
    831         action="store_true",
    832         help="não alterar /etc/pam.d/sshd (empty-password: sem PAM, Debian costuma fechar a sessão)",
    833     )
    834     parser.add_argument(
    835         "--install-pam-empty-password-rule",
    836         action="store_true",
    837         help=argparse.SUPPRESS,
    838     )
    839     parser.add_argument(
    840         "--sshd-test-connection",
    841         default="user=entre,host=runv.club,addr=127.0.0.1",
    842         help="argumento -C para sshd -T na validação pós-config (user/host/addr do Match)",
    843     )
    844     parser.add_argument("--install-root", type=Path, default=INSTALL_ROOT)
    845     parser.add_argument("--queue-dir", type=Path, default=QUEUE_DIR)
    846     parser.add_argument("--skip-copy", action="store_true", help="não copiar ficheiros do módulo")
    847     parser.add_argument(
    848         "--skip-sshd",
    849         action="store_true",
    850         help="não escrever drop-in nem recarregar SSH; imprime bloco para cópia manual",
    851     )
    852     parser.add_argument(
    853         "--no-reload",
    854         action="store_true",
    855         help="após sshd -t e validação -T, não executar systemctl reload",
    856     )
    857     parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
    858     args = parser.parse_args()
    859     ensure_admin_cli(
    860         script_name=Path(__file__).name,
    861         dry_run=bool(args.dry_run),
    862     )
    863 
    864     if args.empty_password_tilde_password_auth and args.auth_mode != AUTH_EMPTY:
    865         eprint("--empty-password-tilde-password-auth só com --auth-mode empty-password.")
    866         return 2
    867 
    868     empty_ssh_auth: str | None
    869     if args.auth_mode == AUTH_EMPTY:
    870         empty_ssh_auth = (
    871             "password" if args.empty_password_tilde_password_auth else "keyboard-interactive"
    872         )
    873     else:
    874         empty_ssh_auth = None
    875 
    876     if args.auth_mode == AUTH_EMPTY:
    877         print(INSECURE_EMPTY_BANNER, file=sys.stderr)
    878         if args.skip_pam_empty_password_rule:
    879             eprint(
    880                 "AVISO: --skip-pam-empty-password-rule — em Debian/Ubuntu o stack PAM em "
    881                 "sshd recusa palavra-passe vazia sem pam_succeed_if; espere «Connection closed» "
    882                 "após o prompt se não configurar PAM à mão."
    883             )
    884 
    885     require_root()
    886 
    887     ir = args.install_root
    888     qd = args.queue_dir
    889     empty_group = args.empty_password_group.strip()
    890     if not empty_group:
    891         eprint("--empty-password-group não pode ser vazio.")
    892         return 2
    893 
    894     existing_module = (ir / "entre_app.py").is_file()
    895     if (
    896         existing_module
    897         and not args.skip_copy
    898         and not args.dry_run
    899         and not args.yes
    900     ):
    901         if sys.stdin.isatty():
    902             if not prompt_yes(
    903                 f"Já existe instalação em {ir} (ficheiros do módulo serão actualizados; "
    904                 f"config.toml só se pedir abaixo ou usar --force-config). Continuar? ",
    905                 default=True,
    906             ):
    907                 print("Operação cancelada.")
    908                 return 0
    909         else:
    910             print(
    911                 f"Aviso: instalação existente em {ir}; a actualizar sem prompt "
    912                 f"(TTY ausente). Use --dry-run para simular ou --yes para suprimir avisos."
    913             )
    914 
    915     pam_done = False
    916     apply_pam_empty = (
    917         args.auth_mode == AUTH_EMPTY
    918         and not args.skip_pam_empty_password_rule
    919     )
    920 
    921     if not args.skip_copy:
    922         copy_module(ir, dry_run=args.dry_run)
    923         force_cfg = bool(args.force_config)
    924         cfg_path = ir / "config.toml"
    925         if (
    926             cfg_path.is_file()
    927             and not force_cfg
    928             and not args.dry_run
    929             and not args.yes
    930             and sys.stdin.isatty()
    931         ):
    932             if prompt_yes(
    933                 f"Manter {cfg_path} com as suas definições (recomendado) ou substituir "
    934                 f"por config.example.toml (repor mail_from noreply@runv.club, etc.)? Substituir? ",
    935                 default=False,
    936             ):
    937                 force_cfg = True
    938         install_config(ir, dry_run=args.dry_run, force=force_cfg)
    939 
    940     if not args.dry_run:
    941         LOG_DIR.mkdir(parents=True, exist_ok=True)
    942         qd.mkdir(parents=True, mode=0o750, exist_ok=True)
    943         ensure_user_entre(home=args.home, shell=args.shell)
    944         ensure_entre_shell(args.shell, dry_run=False)
    945 
    946         pw = pwd.getpwnam(ENTRE_USER)
    947         uid, gid = pw.pw_uid, pw.pw_gid
    948         entre_home = Path(pw.pw_dir)
    949         ensure_entre_dot_ssh(entre_home, uid, gid, dry_run=False)
    950 
    951         if args.auth_mode == AUTH_EMPTY:
    952             ensure_onboarding_group(empty_group, dry_run=False)
    953             ensure_user_in_onboarding_group(empty_group, dry_run=False)
    954             clear_entre_password(dry_run=False)
    955             assert_entre_password_empty(dry_run=False)
    956             if apply_pam_empty:
    957                 install_pam_empty_password_rule(empty_group, dry_run=False)
    958                 pam_done = True
    959 
    960         os.chown(qd, uid, gid)
    961         qd.chmod(0o700)
    962 
    963         log_path = LOG_DIR / "entre.log"
    964         if not log_path.exists():
    965             log_path.touch(mode=0o640)
    966         os.chown(log_path, uid, gid)
    967         log_path.chmod(0o640)
    968 
    969         if ir.exists():
    970             ensure_install_tree_permissions(ir, gid=gid)
    971     else:
    972         print("[dry-run] utilizador entre, fila, log e .ssh seriam garantidos (sem alterar sistema).")
    973         if args.auth_mode == AUTH_EMPTY:
    974             ensure_onboarding_group(empty_group, dry_run=True)
    975             ensure_user_in_onboarding_group(empty_group, dry_run=True)
    976             clear_entre_password(dry_run=True)
    977             assert_entre_password_empty(dry_run=True)
    978             if apply_pam_empty:
    979                 install_pam_empty_password_rule(empty_group, dry_run=True)
    980 
    981     py = shutil.which("python3") or "/usr/bin/python3"
    982     app = ir / "entre_app.py"
    983 
    984     try:
    985         apply_sshd_configuration(
    986             py,
    987             app,
    988             install_root=ir,
    989             auth_mode=args.auth_mode,
    990             sshd_test_connection=args.sshd_test_connection,
    991             empty_ssh_auth=empty_ssh_auth,
    992             dry_run=args.dry_run,
    993             skip_sshd=args.skip_sshd,
    994             no_reload=args.no_reload,
    995         )
    996     except RuntimeError as e:
    997         eprint(str(e))
    998         return 1
    999 
   1000     if not args.skip_sshd and not args.dry_run:
   1001         print_final_instructions(
   1002             auth_mode=args.auth_mode,
   1003             install_root=ir,
   1004             empty_group=empty_group,
   1005             pam_installed=pam_done,
   1006             empty_ssh_auth=empty_ssh_auth,
   1007         )
   1008 
   1009     return 0
   1010 
   1011 
   1012 if __name__ == "__main__":
   1013     raise SystemExit(main())