runv-server

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

create_runv_user.py (85322B)


      1 #!/usr/bin/env python3
      2 """
      3 Ferramenta interna de administração: provisiona contas Unix no runv.club (Debian).
      4 
      5 Contrato de provisionamento (ordem garantida após validação):
      6 
      7 1. **Criar o usuário** — ``adduser --disabled-password``.
      8 2. **Instalar a chave** — ``~/.ssh/authorized_keys`` com modos ``700`` / ``600``.
      9 3. **Preparar public_html** — diretório ``755``, ``index.html`` estático ``644``.
     10 4. **Preparar public_gopher / public_gemini** — ``gophermap`` modelo (não sobrescreve sem
     11    ``--force-gopher``); ``index.gmi`` só é criado se ainda não existir (nunca substituído);
     12    bind mount ``/var/gemini/users/<user>`` <- ``~/public_gemini`` quando o directório global existir
     13    (``--force-gemini`` força migração de symlink / remount).
     14 5. **Skel Debian** — copiado no passo 1; o skel runv (``tools.py``) **não** inclui ``README.md`` por
     15    política. Opcionalmente ``--with-readme`` cria ``~/README.md`` (``--force-readme`` substitui se existir).
     16 6. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, sites públicos e, se existir,
     17    ``README.md``, antes de quota e verificação final.
     18 7. **Jail SSH** — legado/opt-in: use ``--with-jail`` para ``runv-jailed`` + ``/srv/jail/<user>``.
     19    Por omissão, membros entram sem chroot para poderem usar os comandos globais do servidor.
     20 
     21 Quota ext4, metadados JSON e logging seguem após estes passos.
     22 
     23 É a **fonte principal** da política de provisionamento — sem depender de ``adduser.local``,
     24 ``QUOTAUSER`` ou regras espalhadas em ``/etc/adduser.conf``.
     25 
     26 Garante na criação as permissões para **todos** os serviços runv expostos ao utilizador:
     27 **HTTP** (``public_html``), **Gopher** (``public_gopher``) e **Gemini** (``public_gemini``) —
     28 home ``755`` (atravessável por Apache, gophernicus e molly-brown), pastas públicas ``755``,
     29 ficheiros servidos ``644``, mais ``.ssh``/``authorized_keys`` e bind mount Gemini quando aplicável.
     30 Contas criadas **só** com ``adduser`` (sem este script) devem passar pelo backfill
     31 ``scripts/admin/setup_alt_protocols.py`` ou por nova execução deste script com as flags de reparo
     32 adequadas (``--force-*``).
     33 
     34 Não é signup público: executar manualmente como root/sudo no servidor.
     35 Requer Linux (Debian). Quota: ext4 com ``usrquota``/``usrjquota`` via ``setquota`` (não altera fstab).
     36 
     37 Versão 0.02 — desenvolvido por pmurad, 2026.
     38 """
     39 
     40 from __future__ import annotations
     41 
     42 import argparse
     43 import fcntl
     44 import getpass
     45 import json
     46 import logging
     47 import os
     48 import pwd
     49 import re
     50 import shutil
     51 import stat as statmod
     52 import subprocess
     53 import sys
     54 import tempfile
     55 from dataclasses import dataclass
     56 from datetime import datetime, timezone
     57 from pathlib import Path
     58 from typing import Any, Final, NoReturn
     59 
     60 # Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script não entra em sys.path;
     61 # necessário para «from runv_mount» dentro das funções de quota/mount.
     62 _SCRIPT_DIR = Path(__file__).resolve().parent
     63 _REPO_ROOT = _SCRIPT_DIR.parent.parent
     64 if str(_SCRIPT_DIR) not in sys.path:
     65     sys.path.insert(0, str(_SCRIPT_DIR))
     66 
     67 import runv_jail
     68 from runv_landing_sync import try_sync_landing_via_genlanding
     69 
     70 # constantes
     71 USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$")
     72 
     73 # Email pragmático (não RFC completo)
     74 EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile(
     75     r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
     76     r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$"
     77 )
     78 
     79 RESERVED_USERNAMES: Final[frozenset[str]] = frozenset(
     80     {
     81         "root",
     82         "daemon",
     83         "bin",
     84         "sys",
     85         "sync",
     86         "games",
     87         "man",
     88         "lp",
     89         "mail",
     90         "news",
     91         "uucp",
     92         "proxy",
     93         "www-data",
     94         "backup",
     95         "list",
     96         "irc",
     97         "_apt",
     98         "nobody",
     99         "admin",
    100         "postmaster",
    101     }
    102 )
    103 
    104 ALLOWED_KEY_TYPES: Final[tuple[str, ...]] = (
    105     "ssh-ed25519",
    106     "sk-ssh-ed25519@openssh.com",
    107     "ecdsa-sha2-nistp256",
    108     "ecdsa-sha2-nistp384",
    109     "ecdsa-sha2-nistp521",
    110     "ssh-rsa",
    111 )
    112 
    113 FINGERPRINT_SHA256_RE: Final[re.Pattern[str]] = re.compile(r"\b(SHA256:[+A-Za-z0-9/_=-]+)\b")
    114 
    115 DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json")
    116 DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock")
    117 DEFAULT_LOG_PATH: Final[Path] = Path("/var/log/runv-user-provision.log")
    118 DEFAULT_BASE_URL: Final[str] = "http://runv.club"
    119 DEFAULT_ENTRE_QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue")
    120 DEFAULT_GEMINI_HOST_PUBLIC: Final[str] = "runv.club"
    121 GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users")
    122 DEFAULT_ALLOWED_ADMIN_USERS: Final[tuple[str, ...]] = ("pmurad-admin",)
    123 REQUEST_ID_PATTERN: Final[re.Pattern[str]] = re.compile(
    124     r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
    125 )
    126 
    127 # Quota ext4 (valores padrão runv; limites em MiB = 1024² bytes → setquota usa kiB de 1024 B)
    128 DEFAULT_QUOTA_SOFT_MIB: Final[int] = 450
    129 DEFAULT_QUOTA_HARD_MIB: Final[int] = 500
    130 DEFAULT_QUOTA_INODE_SOFT: Final[int] = 10_000
    131 DEFAULT_QUOTA_INODE_HARD: Final[int] = 12_000
    132 
    133 VERSION: Final[str] = "0.02"
    134 AUTHOR: Final[str] = "pmurad"
    135 COPYRIGHT_YEAR: Final[str] = "2026"
    136 
    137 EXIT_OK: Final[int] = 0
    138 EXIT_VALIDATION: Final[int] = 1
    139 EXIT_SYSTEM: Final[int] = 2
    140 EXIT_INCONSISTENT: Final[int] = 3
    141 
    142 
    143 class ProvisionError(Exception):
    144     """Erro genérico de provisionamento."""
    145 
    146 
    147 class ValidationError(ProvisionError):
    148     """Entrada ou estado inválido (exit 1)."""
    149 
    150 
    151 class SystemProvisionError(ProvisionError):
    152     """Falha de sistema/subprocess (exit 2)."""
    153 
    154 
    155 class QuotaNotAvailableError(ValidationError):
    156     """Sistema de quotas não preparado (ext4 usrquota ausente, ferramentas, etc.)."""
    157 
    158 
    159 @dataclass(frozen=True)
    160 class QueueApprovalRequest:
    161     request_id: str
    162     username: str
    163     email: str
    164     public_key: str
    165     fingerprint: str
    166     queue_path: Path
    167     payload: dict[str, Any]
    168 
    169 
    170 def resolve_allowed_admin_users() -> set[str]:
    171     raw = os.environ.get("RUNV_ADMIN_USERS", "").strip()
    172     if not raw:
    173         return set(DEFAULT_ALLOWED_ADMIN_USERS)
    174     names = {part.strip() for part in raw.split(",") if part.strip()}
    175     return names or set(DEFAULT_ALLOWED_ADMIN_USERS)
    176 
    177 
    178 def resolve_operator_user() -> str:
    179     sudo_user = os.environ.get("SUDO_USER", "").strip()
    180     if sudo_user:
    181         return sudo_user
    182     return getpass.getuser().strip()
    183 
    184 
    185 def require_authorized_admin_operator(*, dry_run: bool) -> str:
    186     operator = resolve_operator_user()
    187     allowed = resolve_allowed_admin_users()
    188     if operator not in allowed:
    189         allowed_list = ", ".join(sorted(allowed))
    190         msg = (
    191             f"operação permitida apenas a administrador autorizado. "
    192             f"Operador detectado: {operator!r}. Permitidos: {allowed_list}."
    193         )
    194         if dry_run:
    195             raise ValidationError(msg)
    196         raise SystemProvisionError(msg)
    197     return operator
    198 
    199 
    200 # validação username / email
    201 def validate_username(username: str) -> str:
    202     """
    203     Valida username conservador; rejeita vazio, reservados e contas existentes.
    204     Retorna o username normalizado (sem espaços).
    205     """
    206     if username is None or username == "":
    207         raise ValidationError("username é obrigatório")
    208     if username != username.strip():
    209         raise ValidationError("username não pode ter espaços no início ou fim")
    210     u = username.strip()
    211     if not USERNAME_PATTERN.fullmatch(u):
    212         raise ValidationError(
    213             "username inválido: use apenas letras minúsculas, dígitos, _ e -; "
    214             "comece com letra; comprimento total 2–32 caracteres"
    215         )
    216     if u in RESERVED_USERNAMES:
    217         raise ValidationError(f"username reservado ou perigoso: {u!r}")
    218     try:
    219         pwd.getpwnam(u)
    220     except KeyError:
    221         pass
    222     else:
    223         raise ValidationError(f"usuário já existe no sistema: {u!r}")
    224     return u
    225 
    226 
    227 def validate_email(email: str) -> str:
    228     if email is None or email == "":
    229         raise ValidationError("email é obrigatório")
    230     if email != email.strip():
    231         raise ValidationError("email não pode ter espaços no início ou fim")
    232     e = email.strip()
    233     at = e.count("@")
    234     if at == 0:
    235         raise ValidationError(
    236             "indica um endereço com @, por exemplo nome@exemplo.org."
    237         )
    238     if at != 1:
    239         raise ValidationError("o email deve ter um único @.")
    240     if not EMAIL_PATTERN.fullmatch(e):
    241         raise ValidationError("formato de email inválido")
    242     return e
    243 
    244 
    245 # chave pública OpenSSH
    246 def normalize_public_key(raw: str) -> str:
    247     """
    248     Aceita uma única linha OpenSSH authorized_keys.
    249     Rejeita newlines internos e normaliza espaços internos de forma segura.
    250     """
    251     if raw is None or raw == "":
    252         raise ValidationError("public_key é obrigatória")
    253     if "\n" in raw or "\r" in raw:
    254         raise ValidationError("public_key deve ser uma única linha (sem quebras de linha)")
    255     if raw != raw.strip():
    256         raise ValidationError("public_key não pode ter espaços extras no início ou fim")
    257     line = raw.strip()
    258     if not line or line.isspace():
    259         raise ValidationError("public_key vazia")
    260     parts = line.split()
    261     if len(parts) < 2:
    262         raise ValidationError("public_key malformada (esperado: tipo, dados-base64, [comentário])")
    263     key_type = parts[0]
    264     if key_type not in ALLOWED_KEY_TYPES:
    265         raise ValidationError(
    266             f"tipo de chave não permitido: {key_type!r}; permitidos: {', '.join(ALLOWED_KEY_TYPES)}"
    267         )
    268     # Uma linha: tipo + blob base64 + comentário opcional (pode conter espaços)
    269     blob = parts[1]
    270     comment = parts[2:] if len(parts) > 2 else []
    271     if not re.fullmatch(r"[A-Za-z0-9+/]+=*", blob):
    272         raise ValidationError("dados da chave pública (base64) inválidos")
    273     normalized = key_type + " " + blob
    274     if comment:
    275         normalized += " " + " ".join(comment)
    276     return normalized
    277 
    278 
    279 def compute_public_key_fingerprint(public_key_line: str, tmp_dir: Path | None = None) -> str:
    280     """
    281     Calcula fingerprint no formato OpenSSH SHA256 (ex.: SHA256:...).
    282     Usa `ssh-keygen -lf -E sha256` (requer pacote openssh-client no Debian).
    283     """
    284     line = normalize_public_key(public_key_line)
    285     fd, tmppath = tempfile.mkstemp(prefix="runv-key-", suffix=".pub", dir=tmp_dir)
    286     path = Path(tmppath)
    287     try:
    288         with os.fdopen(fd, "w", encoding="utf-8") as f:
    289             f.write(line + "\n")
    290         proc = subprocess.run(
    291             ["ssh-keygen", "-l", "-E", "sha256", "-f", str(path)],
    292             capture_output=True,
    293             text=True,
    294             timeout=30,
    295         )
    296         if proc.returncode != 0:
    297             err = (proc.stderr or proc.stdout or "").strip()
    298             raise ValidationError(f"chave pública rejeitada pelo ssh-keygen: {err}")
    299         out = (proc.stdout or "").strip().splitlines()
    300         if not out:
    301             raise SystemProvisionError("ssh-keygen não devolveu saída")
    302         first = out[0]
    303         m = FINGERPRINT_SHA256_RE.search(first)
    304         if not m:
    305             raise SystemProvisionError(f"não foi possível extrair SHA256 da saída: {first!r}")
    306         return m.group(1)
    307     finally:
    308         try:
    309             path.unlink(missing_ok=True)
    310         except OSError:
    311             pass
    312 
    313 
    314 def validate_public_key(public_key_line: str, tmp_dir: Path | None = None) -> tuple[str, str]:
    315     """
    316     Valida e normaliza a chave; retorna (linha_normalizada, fingerprint_sha256).
    317     """
    318     normalized = normalize_public_key(public_key_line)
    319     fp = compute_public_key_fingerprint(normalized, tmp_dir=tmp_dir)
    320     return normalized, fp
    321 
    322 
    323 def load_queue_request_by_id(request_id: str, queue_dir: Path) -> QueueApprovalRequest:
    324     rid = request_id.strip().lower()
    325     if not REQUEST_ID_PATTERN.fullmatch(rid):
    326         raise ValidationError("request_id inválido: esperado UUID em minúsculas.")
    327     queue_path = queue_dir / f"{rid}.json"
    328     if not queue_path.is_file():
    329         raise ValidationError(f"pedido não encontrado na fila: {queue_path}")
    330     try:
    331         payload = json.loads(queue_path.read_text(encoding="utf-8"))
    332     except (OSError, json.JSONDecodeError) as e:
    333         raise ValidationError(f"não foi possível ler o pedido {rid!r}: {e}") from e
    334     if not isinstance(payload, dict):
    335         raise ValidationError(f"pedido {rid!r} inválido: esperado objeto JSON.")
    336 
    337     username = validate_username(str(payload.get("username", "")))
    338     email = validate_email(str(payload.get("email", "")))
    339     normalized_key, computed_fingerprint = validate_public_key(str(payload.get("public_key", "")))
    340     queued_fp = str(payload.get("public_key_fingerprint", "")).strip()
    341     if queued_fp and queued_fp != computed_fingerprint:
    342         raise ValidationError(
    343             f"fingerprint do pedido {rid!r} diverge da chave pública armazenada."
    344         )
    345     status = str(payload.get("status", "pending")).strip().lower()
    346     if status and status != "pending":
    347         raise ValidationError(f"pedido {rid!r} não está pendente (status={status!r}).")
    348 
    349     return QueueApprovalRequest(
    350         request_id=rid,
    351         username=username,
    352         email=email,
    353         public_key=normalized_key,
    354         fingerprint=computed_fingerprint,
    355         queue_path=queue_path,
    356         payload=payload,
    357     )
    358 
    359 
    360 def archive_approved_queue_request(
    361     approval: QueueApprovalRequest,
    362     *,
    363     operator: str,
    364     created_username: str,
    365     dry_run: bool,
    366     log: logging.Logger,
    367 ) -> None:
    368     approved_dir = approval.queue_path.parent / "approved"
    369     archived_payload = dict(approval.payload)
    370     archived_payload["status"] = "approved"
    371     archived_payload["approved_at"] = datetime.now(timezone.utc).isoformat()
    372     archived_payload["approved_by"] = operator
    373     archived_payload["provisioned_username"] = created_username
    374 
    375     if dry_run:
    376         log.info(
    377             "[dry-run] arquivaria pedido aprovado em %s",
    378             approved_dir / approval.queue_path.name,
    379         )
    380         return
    381 
    382     approved_dir.mkdir(parents=True, exist_ok=True)
    383     dest = approved_dir / approval.queue_path.name
    384     if dest.exists():
    385         ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    386         dest = approved_dir / f"{approval.request_id}.{ts}.json"
    387     dest.write_text(
    388         json.dumps(archived_payload, ensure_ascii=False, indent=2) + "\n",
    389         encoding="utf-8",
    390     )
    391     approval.queue_path.unlink(missing_ok=True)
    392     log.info("pedido %s arquivado em %s", approval.request_id, dest)
    393 
    394 
    395 def list_pending_queue_request_ids(queue_dir: Path) -> list[str]:
    396     if not queue_dir.is_dir():
    397         raise ValidationError(f"fila inexistente: {queue_dir}")
    398     items: list[tuple[float, str]] = []
    399     for path in queue_dir.glob("*.json"):
    400         if not path.is_file():
    401             continue
    402         rid = path.stem.strip().lower()
    403         if not REQUEST_ID_PATTERN.fullmatch(rid):
    404             continue
    405         try:
    406             mtime = path.stat().st_mtime
    407         except OSError:
    408             mtime = 0.0
    409         items.append((mtime, rid))
    410     items.sort(key=lambda item: (item[0], item[1]))
    411     return [rid for _mtime, rid in items]
    412 
    413 
    414 def process_all_pending_requests(args: argparse.Namespace) -> int:
    415     try:
    416         operator_user = require_authorized_admin_operator(dry_run=bool(args.dry_run))
    417         request_ids = list_pending_queue_request_ids(args.queue_dir)
    418     except (ValidationError, SystemProvisionError) as e:
    419         print(f"Acesso: {e}", file=sys.stderr)
    420         return EXIT_VALIDATION if isinstance(e, ValidationError) else EXIT_SYSTEM
    421 
    422     if not request_ids:
    423         print(f"Nenhum pedido pendente em {args.queue_dir}.")
    424         return EXIT_OK
    425 
    426     print(f"Processando {len(request_ids)} pedido(s) da fila em {args.queue_dir}")
    427     print(f"Operador autorizado: {operator_user}")
    428     print()
    429 
    430     base_cmd = [sys.executable, str(Path(__file__).resolve())]
    431     passthrough_flags: list[str] = []
    432 
    433     if args.dry_run:
    434         passthrough_flags.append("--dry-run")
    435     if args.verbose:
    436         passthrough_flags.append("--verbose")
    437     if args.force_index:
    438         passthrough_flags.append("--force-index")
    439     if args.with_readme:
    440         passthrough_flags.append("--with-readme")
    441     if args.force_readme:
    442         passthrough_flags.append("--force-readme")
    443     if getattr(args, "with_jail", False):
    444         passthrough_flags.append("--with-jail")
    445     if args.force_gopher:
    446         passthrough_flags.append("--force-gopher")
    447     if args.force_gemini:
    448         passthrough_flags.append("--force-gemini")
    449     if args.no_refresh_landing_members:
    450         passthrough_flags.append("--no-refresh-landing-members")
    451     if args.no_quota:
    452         passthrough_flags.append("--no-quota")
    453     if args.require_quota:
    454         passthrough_flags.append("--require-quota")
    455     if args.no_welcome_email:
    456         passthrough_flags.append("--no-welcome-email")
    457     if args.no_admin_create_email:
    458         passthrough_flags.append("--no-admin-create-email")
    459 
    460     value_flags: list[str] = [
    461         "--queue-dir",
    462         str(args.queue_dir),
    463         "--metadata-file",
    464         str(args.metadata_file),
    465         "--lock-file",
    466         str(args.lock_file),
    467         "--log-file",
    468         str(args.log_file),
    469         "--base-url",
    470         str(args.base_url),
    471         "--landing-document-root",
    472         str(args.landing_document_root),
    473         "--quota-soft-mb",
    474         str(args.quota_soft_mb),
    475         "--quota-hard-mb",
    476         str(args.quota_hard_mb),
    477         "--quota-inode-soft",
    478         str(args.quota_inode_soft),
    479         "--quota-inode-hard",
    480         str(args.quota_inode_hard),
    481     ]
    482     if args.members_homes_root is not None:
    483         value_flags.extend(["--members-homes-root", str(args.members_homes_root)])
    484     if args.welcome_ssh_host:
    485         value_flags.extend(["--welcome-ssh-host", str(args.welcome_ssh_host)])
    486 
    487     success = 0
    488     failures: list[tuple[str, int]] = []
    489     for rid in request_ids:
    490         cmd = [*base_cmd, "--request-id", rid, *passthrough_flags, *value_flags]
    491         print(f"==> {rid}")
    492         proc = subprocess.run(cmd, text=True)
    493         if proc.returncode == EXIT_OK:
    494             success += 1
    495         else:
    496             failures.append((rid, proc.returncode))
    497         print()
    498 
    499     print("========== create_runv_user.py — lote ==========")
    500     print(f"Pedidos totais: {len(request_ids)}")
    501     print(f"Sucessos: {success}")
    502     print(f"Falhas: {len(failures)}")
    503     if failures:
    504         print("Pedidos com falha:")
    505         for rid, code in failures:
    506             print(f"  - {rid} (exit {code})")
    507     print("===============================================")
    508     return EXIT_OK if not failures else EXIT_INCONSISTENT
    509 
    510 
    511 def read_public_key_from_args(pub: str | None, pub_file: Path | None) -> str:
    512     if pub and pub_file:
    513         raise ValidationError("use apenas --public-key ou --public-key-file, não ambos")
    514     if pub:
    515         return pub
    516     if pub_file:
    517         text = pub_file.read_text(encoding="utf-8")
    518         if len(text.splitlines()) > 1:
    519             raise ValidationError("arquivo de chave deve conter uma única linha")
    520         line = text.strip()
    521         return line
    522     raise ValidationError("forneça --public-key ou --public-key-file")
    523 
    524 
    525 # caminhos sob /home (sem sair da árvore)
    526 def home_directory(username: str) -> Path:
    527     p = Path(f"/home/{username}").resolve()
    528     home_root = Path("/home").resolve()
    529     try:
    530         p.relative_to(home_root)
    531     except ValueError as e:
    532         raise ValidationError("caminho home inválido") from e
    533     if p.name != username:
    534         raise ValidationError("inconsistência no nome do diretório home")
    535     return p
    536 
    537 
    538 # authorized_keys
    539 def install_authorized_keys(
    540     home: Path,
    541     uid: int,
    542     gid: int,
    543     public_key_line: str,
    544     log: logging.Logger,
    545 ) -> None:
    546     """Cria ~/.ssh/authorized_keys com permissões corretas."""
    547     ssh_dir = home / ".ssh"
    548     auth = ssh_dir / "authorized_keys"
    549     line = normalize_public_key(public_key_line)
    550 
    551     ssh_dir.mkdir(parents=True, exist_ok=True)
    552     os.chmod(ssh_dir, 0o700)
    553     try:
    554         os.chown(ssh_dir, uid, gid)
    555     except PermissionError as e:
    556         raise SystemProvisionError(f"não foi possível ajustar dono de {ssh_dir}: {e}") from e
    557 
    558     if auth.exists():
    559         existing = auth.read_text(encoding="utf-8")
    560         if line in existing.splitlines():
    561             log.info("authorized_keys já continha esta chave; nada a acrescentar")
    562         else:
    563             with open(auth, "a", encoding="utf-8") as f:
    564                 f.write(line + "\n")
    565     else:
    566         auth.write_text(line + "\n", encoding="utf-8")
    567 
    568     os.chmod(auth, 0o600)
    569     try:
    570         os.chown(auth, uid, gid)
    571     except PermissionError as e:
    572         raise SystemProvisionError(f"não foi possível ajustar dono de {auth}: {e}") from e
    573 
    574 
    575 # public_html
    576 def default_index_html(username: str) -> str:
    577     """HTML estático: boas-vindas inspiradoras, sem caminhos de sistema nem comandos (só marcação)."""
    578     return f"""<!DOCTYPE html>
    579 <html lang="pt-BR">
    580 <head>
    581   <meta charset="utf-8">
    582   <meta name="viewport" content="width=device-width, initial-scale=1">
    583   <title>~{username} — runv.club</title>
    584   <style>
    585     :root {{
    586       --bg: #0e0c12;
    587       --fg: #e8e4f0;
    588       --accent: #c4a1ff;
    589       --muted: #9a90b0;
    590     }}
    591     * {{ box-sizing: border-box; }}
    592     body {{
    593       margin: 0;
    594       min-height: 100vh;
    595       display: flex;
    596       align-items: center;
    597       justify-content: center;
    598       padding: 2rem;
    599       font-family: Georgia, "Times New Roman", serif;
    600       background: radial-gradient(ellipse 120% 80% at 50% 0%, #1a1428 0%, var(--bg) 55%);
    601       color: var(--fg);
    602       line-height: 1.65;
    603     }}
    604     main {{
    605       max-width: 36rem;
    606       text-align: center;
    607     }}
    608     h1 {{
    609       font-weight: 400;
    610       font-size: clamp(1.75rem, 4vw, 2.25rem);
    611       letter-spacing: 0.02em;
    612       margin-bottom: 1.25rem;
    613       color: var(--accent);
    614     }}
    615     p {{
    616       margin: 0 0 1.15rem;
    617       font-size: 1.05rem;
    618     }}
    619     .lead {{
    620       font-size: 1.15rem;
    621       color: #f0ecf8;
    622     }}
    623     .soft {{
    624       color: var(--muted);
    625       font-size: 0.98rem;
    626     }}
    627   </style>
    628 </head>
    629 <body>
    630   <main>
    631     <h1>Bem-vindo ao runv.club</h1>
    632     <p class="lead">Este é o espaço de <strong>~{username}</strong> na nossa pubnix — um canto da rede para publicar ideias, texto e silêncio com intenção.</p>
    633     <p>A web ainda pode ser leve. Aqui vale experimentar, aprender em público e deixar a página crescer com o tempo, sem pressa de plataforma fechada.</p>
    634     <p class="soft">Faça deste sítio o que quiser: um blog, um cartão de visitas, um arquivo. O runv.club é o que cada pessoa constrói em conjunto.</p>
    635   </main>
    636 </body>
    637 </html>
    638 """
    639 
    640 
    641 def default_readme_md(username: str, base_url: str) -> str:
    642     """Texto de ajuda inicial em português (política runv.club)."""
    643     base = base_url.rstrip("/")
    644     user_url = f"{base}/~{username}/"
    645     return f"""# Bem-vindo(a) ao runv.club
    646 
    647 O **runv.club** é um servidor partilhado (pubnix): tens acesso por **SSH com chave**
    648 e uma **página web pessoal** servida pelo Apache com `mod_userdir`.
    649 
    650 ## A tua página pessoal
    651 
    652 - Ficheiros públicos ficam em **`~/public_html/`**.
    653 - A página principal é **`~/public_html/index.html`** (HTML estático; sem PHP obrigatório nesta fase).
    654 - A URL pública é:
    655 
    656   **{user_url}**
    657 
    658 Edita o HTML com o teu editor na shell (ex.: `nano ~/public_html/index.html`).
    659 
    660 ## Permissões recomendadas
    661 
    662 | Local | Modo | Notas |
    663 |-------|------|--------|
    664 | A tua home (`~`) | `755` | O Apache precisa de atravessar a home para chegar a `public_html`. |
    665 | `~/public_html` | `755` | Diretório listável pelo servidor web. |
    666 | Ficheiros do site | `644` | Ficheiros normais dentro de `public_html`. |
    667 | `~/.ssh` | `700` | Só o teu utilizador deve aceder. |
    668 | `~/.ssh/authorized_keys` | `600` | Chaves SSH autorizadas. |
    669 
    670 Se alterares permissões e o site deixar de abrir, volta a `755` na home e em `public_html`,
    671 e `644` nos ficheiros servidos.
    672 
    673 ## Ficheiros públicos
    674 
    675 Tudo o que colocares em **`public_html`** pode ser lido pelo mundo via HTTP no endereço
    676 `~{username}/...`. Não coloques aí segredos, chaves privadas nem dados sensíveis.
    677 
    678 ## Gopher e Gemini (protocolos alternativos)
    679 
    680 - **Gopher:** edita `~/public_gopher/gophermap` (e outros ficheiros nessa pasta). URL típica:
    681   `gopher://{DEFAULT_GEMINI_HOST_PUBLIC}/1/~{username}` (o caminho exacto depende do servidor).
    682 - **Gemini:** edita `~/public_gemini/index.gmi`. URL canónica: `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/` (path **`/~{username}/`**, tilde colado ao nome); `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~/{username}/` redirecciona no servidor (v0.11+). `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/{username}` **não** é o teu capsule.
    683 - Mantém **755** nas pastas públicas e **644** nos ficheiros, para o servidor conseguir ler.
    684 
    685 ## Comandos úteis na shell
    686 
    687 ```bash
    688 pwd                  # diretório atual
    689 ls -la               # listar com detalhes
    690 cd ~/public_html     # ir à pasta do site
    691 mkdir -p ~/public_html/img   # criar subpastas
    692 chmod 755 ~ ~/public_html
    693 chmod 644 ~/public_html/index.html
    694 ```
    695 
    696 Documentação do projeto (admin): repositório **runv-server**, script `create_runv_user.py`.
    697 
    698 — Equipe runv.club
    699 """
    700 
    701 
    702 def prepare_public_html(
    703     home: Path,
    704     username: str,
    705     uid: int,
    706     gid: int,
    707     force_index: bool,
    708     log: logging.Logger,
    709 ) -> None:
    710     pub = home / "public_html"
    711     pub.mkdir(parents=True, exist_ok=True)
    712     os.chmod(pub, 0o755)
    713     try:
    714         os.chown(pub, uid, gid)
    715     except PermissionError as e:
    716         raise SystemProvisionError(f"não foi possível ajustar dono de {pub}: {e}") from e
    717 
    718     index = pub / "index.html"
    719     if index.exists() and not force_index:
    720         log.info("%s já existe; não sobrescrevendo (use --force-index)", index)
    721         return
    722     if index.exists() and force_index:
    723         log.warning("sobrescrevendo %s (--force-index)", index)
    724     index.write_text(default_index_html(username), encoding="utf-8")
    725     os.chmod(index, 0o644)
    726     try:
    727         os.chown(index, uid, gid)
    728     except PermissionError as e:
    729         raise SystemProvisionError(f"não foi possível ajustar dono de {index}: {e}") from e
    730 
    731 
    732 def default_gophermap_text(username: str) -> str:
    733     return f"""iBem-vindo ao runv.club — espaço Gopher de ~{username}.	fake	NULL	0
    734 iGopher é linha a linha, menu e curiosidade: um protocolo simples para quem gosta de ir devagar.	fake	NULL	0
    735 iExplore, publique texto e deixe este buraco crescer ao seu ritmo.	fake	NULL	0
    736 """
    737 
    738 
    739 def default_gemini_index_gmi(username: str) -> str:
    740     return f"""# ~{username} — runv.club
    741 
    742 Bem-vindo ao **Gemini**: um espaço em texto puro, sem rastreio nem barulho de anúncios.
    743 
    744 Esta cápsula é sua. Pode contar histórias, listar leituras, partilhar notas — tudo em páginas leves que abrem com calma.
    745 
    746 O runv.club acredita em protocolos abertos e em quem ainda gosta de ler no próprio ritmo. Boa estadia.
    747 """
    748 
    749 
    750 def prepare_public_gopher(
    751     home: Path,
    752     username: str,
    753     uid: int,
    754     gid: int,
    755     force_gopher: bool,
    756     log: logging.Logger,
    757 ) -> None:
    758     d = home / "public_gopher"
    759     d.mkdir(parents=True, exist_ok=True)
    760     os.chmod(d, 0o755)
    761     try:
    762         os.chown(d, uid, gid)
    763     except PermissionError as e:
    764         raise SystemProvisionError(f"não foi possível ajustar dono de {d}: {e}") from e
    765     gmap = d / "gophermap"
    766     if gmap.exists() and not force_gopher:
    767         log.info("%s já existe; não sobrescrevendo (use --force-gopher)", gmap)
    768         return
    769     if gmap.exists() and force_gopher:
    770         log.warning("sobrescrevendo %s (--force-gopher)", gmap)
    771     gmap.write_text(default_gophermap_text(username), encoding="utf-8")
    772     os.chmod(gmap, 0o644)
    773     try:
    774         os.chown(gmap, uid, gid)
    775     except PermissionError as e:
    776         raise SystemProvisionError(f"não foi possível ajustar dono de {gmap}: {e}") from e
    777 
    778 
    779 def prepare_public_gemini(
    780     home: Path,
    781     username: str,
    782     uid: int,
    783     gid: int,
    784     log: logging.Logger,
    785 ) -> None:
    786     d = home / "public_gemini"
    787     d.mkdir(parents=True, exist_ok=True)
    788     os.chmod(d, 0o755)
    789     try:
    790         os.chown(d, uid, gid)
    791     except PermissionError as e:
    792         raise SystemProvisionError(f"não foi possível ajustar dono de {d}: {e}") from e
    793     idx = d / "index.gmi"
    794     if idx.exists():
    795         log.info("%s já existe; modelo não aplicado", idx)
    796         return
    797     idx.write_text(default_gemini_index_gmi(username), encoding="utf-8")
    798     os.chmod(idx, 0o644)
    799     try:
    800         os.chown(idx, uid, gid)
    801     except PermissionError as e:
    802         raise SystemProvisionError(f"não foi possível ajustar dono de {idx}: {e}") from e
    803 
    804 
    805 def ensure_gemini_user_symlink(
    806     username: str,
    807     home: Path,
    808     log: logging.Logger,
    809     *,
    810     force: bool,
    811 ) -> None:
    812     """
    813     Garante bind mount /var/gemini/users/<user> <- <home>/public_gemini (Molly Debian;
    814     symlinks fora do DocBase são rejeitados). Delega em setup_alt_protocols.
    815     """
    816     import setup_alt_protocols as alt
    817 
    818     if not GEMINI_USERS_DIR.is_dir():
    819         log.warning(
    820             "diretório %s inexistente — bind Gemini não aplicado. "
    821             "Execute scripts/admin/setup_alt_protocols.py no servidor.",
    822             GEMINI_USERS_DIR,
    823         )
    824         return
    825     if username in alt.irc_patch_skip_users(log):
    826         log.info("bind Gemini omitido (IRC_PATCH_SKIP_USERS): %s", username)
    827         return
    828     alt.ensure_gemini_bind_mount(
    829         username,
    830         home.parent,
    831         force=force,
    832         dry_run=False,
    833         log=log,
    834     )
    835 
    836 
    837 def prepare_user_readme(
    838     home: Path,
    839     username: str,
    840     uid: int,
    841     gid: int,
    842     base_url: str,
    843     force_readme: bool,
    844     log: logging.Logger,
    845 ) -> None:
    846     """Garante ~/README.md com texto de ajuda em português (não sobrescreve sem --force-readme)."""
    847     readme = home / "README.md"
    848     if readme.exists() and not force_readme:
    849         log.info("%s já existe; não sobrescrevendo (use --force-readme)", readme)
    850         return
    851     if readme.exists() and force_readme:
    852         log.warning("sobrescrevendo %s (--force-readme)", readme)
    853     readme.write_text(default_readme_md(username, base_url), encoding="utf-8")
    854     os.chmod(readme, 0o644)
    855     try:
    856         os.chown(readme, uid, gid)
    857     except PermissionError as e:
    858         raise SystemProvisionError(f"não foi possível ajustar dono de {readme}: {e}") from e
    859 
    860 
    861 # metadados JSON
    862 @dataclass
    863 class UserRecord:
    864     username: str
    865     email: str
    866     public_key_fingerprint: str
    867     created_at: str
    868     created_by: str
    869     home_directory: str
    870     status: str
    871     quota_enabled: bool
    872     quota_soft_mb: int | None
    873     quota_hard_mb: int | None
    874     quota_inode_soft: int | None
    875     quota_inode_hard: int | None
    876     quota_filesystem: str | None
    877     quota_mountpoint: str | None
    878     quota_applied_at: str | None
    879     quota_status: str
    880 
    881     def to_dict(self) -> dict[str, Any]:
    882         return {
    883             "username": self.username,
    884             "email": self.email,
    885             "public_key_fingerprint": self.public_key_fingerprint,
    886             "created_at": self.created_at,
    887             "created_by": self.created_by,
    888             "home_directory": self.home_directory,
    889             "status": self.status,
    890             "quota_enabled": self.quota_enabled,
    891             "quota_soft_mb": self.quota_soft_mb,
    892             "quota_hard_mb": self.quota_hard_mb,
    893             "quota_inode_soft": self.quota_inode_soft,
    894             "quota_inode_hard": self.quota_inode_hard,
    895             "quota_filesystem": self.quota_filesystem,
    896             "quota_mountpoint": self.quota_mountpoint,
    897             "quota_applied_at": self.quota_applied_at,
    898             "quota_status": self.quota_status,
    899         }
    900 
    901 
    902 def append_user_metadata(
    903     metadata_path: Path,
    904     lock_path: Path,
    905     record: UserRecord,
    906     log: logging.Logger,
    907 ) -> None:
    908     """
    909     Acrescenta registro a uma lista JSON com lock (flock) e escrita atômica.
    910     """
    911     metadata_path.parent.mkdir(parents=True, exist_ok=True)
    912     lock_path.parent.mkdir(parents=True, exist_ok=True)
    913 
    914     lock_f = open(lock_path, "a+", encoding="utf-8")
    915     try:
    916         fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX)
    917         data: list[dict[str, Any]]
    918         if metadata_path.exists():
    919             raw = metadata_path.read_text(encoding="utf-8").strip()
    920             if not raw:
    921                 data = []
    922             else:
    923                 parsed = json.loads(raw)
    924                 if not isinstance(parsed, list):
    925                     raise SystemProvisionError(f"formato inválido em {metadata_path}: esperado lista JSON")
    926                 data = parsed
    927         else:
    928             data = []
    929         for item in data:
    930             if isinstance(item, dict) and item.get("username") == record.username:
    931                 raise ValidationError(f"username já registrado em metadados: {record.username!r}")
    932         data.append(record.to_dict())
    933         tmp_fd, tmp_name = tempfile.mkstemp(
    934             prefix="users.",
    935             suffix=".tmp",
    936             dir=str(metadata_path.parent),
    937         )
    938         tmp_path = Path(tmp_name)
    939         try:
    940             with os.fdopen(tmp_fd, "w", encoding="utf-8") as out:
    941                 json.dump(data, out, indent=2, ensure_ascii=False)
    942                 out.flush()
    943                 os.fsync(out.fileno())
    944             os.replace(tmp_path, metadata_path)
    945         except Exception:
    946             tmp_path.unlink(missing_ok=True)
    947             raise
    948         log.info("metadados gravados em %s", metadata_path)
    949     finally:
    950         fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN)
    951         lock_f.close()
    952 
    953 
    954 # adduser e rollback
    955 def run_adduser(username: str, log: logging.Logger) -> None:
    956     env = os.environ.copy()
    957     env["DEBIAN_FRONTEND"] = "noninteractive"
    958     env["LC_ALL"] = "C"
    959     log.info("executando adduser --disabled-password para %r", username)
    960     try:
    961         proc = subprocess.run(
    962             ["adduser", "--disabled-password", "--gecos", "", username],
    963             capture_output=True,
    964             text=True,
    965             env=env,
    966             timeout=120,
    967         )
    968     except FileNotFoundError as e:
    969         raise SystemProvisionError("comando adduser não encontrado (instale o pacote adduser)") from e
    970     if proc.returncode != 0:
    971         err = (proc.stderr or proc.stdout or "").strip()
    972         detail = f": {err}" if err else ""
    973         log.error("adduser stderr/stdout: %s", err or "(vazio)")
    974         raise SystemProvisionError(f"adduser falhou (código {proc.returncode}){detail}")
    975 
    976 
    977 def run_deluser_remove_home(username: str, log: logging.Logger) -> bool:
    978     """Remove usuário e home. Retorna True se sucesso."""
    979     log.warning("rollback: removendo usuário %r com deluser --remove-home", username)
    980     try:
    981         r = subprocess.run(
    982             ["deluser", "--remove-home", username],
    983             capture_output=True,
    984             text=True,
    985             timeout=120,
    986         )
    987         if r.returncode != 0:
    988             log.error("deluser stderr: %s", r.stderr)
    989             return False
    990         return True
    991     except FileNotFoundError:
    992         log.error("deluser não encontrado")
    993         return False
    994 
    995 
    996 def apply_runv_permissions(home: Path, uid: int, gid: int) -> None:
    997     """
    998     Aplica modos e donos esperados na home e nos artefactos runv.
    999 
   1000     Deve ser chamado após criar o utilizador, chave SSH, ``public_html`` e opcionalmente ``README.md``,
   1001     para garantir home ``755`` (Apache, Gophernicus e Molly-Brown atravessam até
   1002     ``public_html`` / ``public_gopher`` / ``public_gemini``), ``.ssh`` ``700``,
   1003     ``authorized_keys`` ``600``, site ``755``/``644``.
   1004     """
   1005     try:
   1006         os.chmod(home, 0o755)
   1007         os.chown(home, uid, gid)
   1008     except PermissionError as e:
   1009         raise SystemProvisionError(f"não foi possível ajustar permissões de {home}: {e}") from e
   1010 
   1011     ssh_dir = home / ".ssh"
   1012     if ssh_dir.is_dir():
   1013         try:
   1014             os.chmod(ssh_dir, 0o700)
   1015             os.chown(ssh_dir, uid, gid)
   1016         except PermissionError as e:
   1017             raise SystemProvisionError(f"não foi possível ajustar permissões de {ssh_dir}: {e}") from e
   1018         auth = ssh_dir / "authorized_keys"
   1019         if auth.is_file():
   1020             try:
   1021                 os.chmod(auth, 0o600)
   1022                 os.chown(auth, uid, gid)
   1023             except PermissionError as e:
   1024                 raise SystemProvisionError(f"não foi possível ajustar permissões de {auth}: {e}") from e
   1025 
   1026     pub = home / "public_html"
   1027     if pub.is_dir():
   1028         try:
   1029             os.chmod(pub, 0o755)
   1030             os.chown(pub, uid, gid)
   1031         except PermissionError as e:
   1032             raise SystemProvisionError(f"não foi possível ajustar permissões de {pub}: {e}") from e
   1033         index = pub / "index.html"
   1034         if index.is_file():
   1035             try:
   1036                 os.chmod(index, 0o644)
   1037                 os.chown(index, uid, gid)
   1038             except PermissionError as e:
   1039                 raise SystemProvisionError(f"não foi possível ajustar permissões de {index}: {e}") from e
   1040 
   1041     readme = home / "README.md"
   1042     if readme.is_file():
   1043         try:
   1044             os.chmod(readme, 0o644)
   1045             os.chown(readme, uid, gid)
   1046         except PermissionError as e:
   1047             raise SystemProvisionError(f"não foi possível ajustar permissões de {readme}: {e}") from e
   1048 
   1049     for label, path in (
   1050         ("public_gopher", home / "public_gopher"),
   1051         ("public_gemini", home / "public_gemini"),
   1052     ):
   1053         if path.is_dir():
   1054             try:
   1055                 os.chmod(path, 0o755)
   1056                 os.chown(path, uid, gid)
   1057             except PermissionError as e:
   1058                 raise SystemProvisionError(f"não foi possível ajustar permissões de {path}: {e}") from e
   1059     gmap = home / "public_gopher" / "gophermap"
   1060     if gmap.is_file():
   1061         try:
   1062             os.chmod(gmap, 0o644)
   1063             os.chown(gmap, uid, gid)
   1064         except PermissionError as e:
   1065             raise SystemProvisionError(f"não foi possível ajustar permissões de {gmap}: {e}") from e
   1066     gmi = home / "public_gemini" / "index.gmi"
   1067     if gmi.is_file():
   1068         try:
   1069             os.chmod(gmi, 0o644)
   1070             os.chown(gmi, uid, gid)
   1071         except PermissionError as e:
   1072             raise SystemProvisionError(f"não foi possível ajustar permissões de {gmi}: {e}") from e
   1073 
   1074 
   1075 def verify_user_artifact_permissions(
   1076     home: Path,
   1077     uid: int,
   1078     gid: int,
   1079     *,
   1080     expect_readme: bool,
   1081 ) -> None:
   1082     checks: list[tuple[Path, int, str]] = [
   1083         (home, 0o755, "home"),
   1084         (home / ".ssh", 0o700, ".ssh"),
   1085         (home / ".ssh" / "authorized_keys", 0o600, "authorized_keys"),
   1086         (home / "public_html", 0o755, "public_html"),
   1087         (home / "public_html" / "index.html", 0o644, "index.html"),
   1088         (home / "public_gopher", 0o755, "public_gopher"),
   1089         (home / "public_gopher" / "gophermap", 0o644, "gophermap"),
   1090         (home / "public_gemini", 0o755, "public_gemini"),
   1091         (home / "public_gemini" / "index.gmi", 0o644, "index.gmi"),
   1092     ]
   1093     if expect_readme:
   1094         checks.append((home / "README.md", 0o644, "README.md"))
   1095     for path, want_mode, label in checks:
   1096         if not path.exists():
   1097             raise SystemProvisionError(f"em falta após provisionamento ({label}): {path}")
   1098         st = path.stat()
   1099         if st.st_uid != uid or st.st_gid != gid:
   1100             raise SystemProvisionError(
   1101                 f"donos incorretos em {path} ({label}): esperado uid/gid {uid}/{gid}, "
   1102                 f"obtido {st.st_uid}/{st.st_gid}"
   1103             )
   1104         got = statmod.S_IMODE(st.st_mode)
   1105         if got != want_mode:
   1106             raise SystemProvisionError(
   1107                 f"permissões incorretas em {path} ({label}): {oct(got)} (esperado {oct(want_mode)})"
   1108             )
   1109 
   1110 
   1111 # quota ext4 (setquota / usrquota)
   1112 def quota_probe_path(home: Path) -> Path:
   1113     """
   1114     Caminho existente no disco para descobrir o mount (antes de adduser, /home/user pode não existir).
   1115     Sobe até encontrar um diretório existente (tipicamente /home ou /).
   1116     """
   1117     p = home
   1118     while True:
   1119         try:
   1120             if p.exists():
   1121                 return p.resolve()
   1122         except OSError:
   1123             pass
   1124         if p == p.parent:
   1125             return Path("/").resolve()
   1126         p = p.parent
   1127 
   1128 
   1129 def mib_to_setquota_kib(mib: int) -> int:
   1130     """
   1131     Converte **MiB** (mebibytes = 1024² bytes) para as unidades de **blocos** do setquota
   1132     em filesystems ext4 (vfsv0): cada unidade conta **1024 bytes** (1 KiB).
   1133 
   1134     Ex.: 450 MiB → 450 × 1024 = 460_800 (KiB de espaço contabilizado pelo quota).
   1135     """
   1136     if mib < 0:
   1137         raise ValidationError("quota em MiB não pode ser negativa")
   1138     return mib * 1024
   1139 
   1140 
   1141 def validate_quota_limits(
   1142     soft_mib: int,
   1143     hard_mib: int,
   1144     inode_soft: int,
   1145     inode_hard: int,
   1146 ) -> None:
   1147     if soft_mib > hard_mib:
   1148         raise ValidationError(
   1149             f"quota blocos: soft ({soft_mib} MiB) não pode exceder hard ({hard_mib} MiB)"
   1150         )
   1151     if inode_soft > inode_hard:
   1152         raise ValidationError(
   1153             f"quota inodes: soft ({inode_soft}) não pode exceder hard ({inode_hard})"
   1154         )
   1155 
   1156 
   1157 def find_mount_for_path(path: Path) -> tuple[str, str, str]:
   1158     """
   1159     Retorna (target_canonical, fstype, options_csv) para o filesystem que contém path.
   1160     Implementação partilhada: ``runv_mount.find_mount_triple``.
   1161     """
   1162     from runv_mount import MountLookupError, find_mount_triple
   1163 
   1164     try:
   1165         return find_mount_triple(path)
   1166     except MountLookupError as e:
   1167         raise SystemProvisionError(str(e)) from e
   1168 
   1169 
   1170 def mount_options_allow_user_quota(options: str) -> bool:
   1171     """True se usrquota ou usrjquota= (ext4 com quota em journal) está ativo."""
   1172     from runv_mount import quota_opts_allow_user
   1173 
   1174     return quota_opts_allow_user(options)
   1175 
   1176 
   1177 def ensure_setquota_available() -> str:
   1178     """Caminho do executável setquota ou levanta SystemProvisionError."""
   1179     p = shutil.which("setquota")
   1180     if not p:
   1181         raise QuotaNotAvailableError(
   1182             "comando 'setquota' não encontrado — instale o pacote Debian 'quota' "
   1183             "(ex.: apt install quota)"
   1184         )
   1185     return p
   1186 
   1187 
   1188 def preflight_quota_for_home(
   1189     home: Path,
   1190     log: logging.Logger,
   1191 ) -> tuple[str, str, str]:
   1192     """
   1193     Verifica ext4 + usrquota no mount da home (ou ascendente).
   1194     Retorna (mountpoint, fstype, options).
   1195     """
   1196     log.info("quota: início da verificação (pré-voo)")
   1197     probe = quota_probe_path(home)
   1198     log.info("quota: path de sonda para findmnt: %s", probe)
   1199     target, fstype, opts = find_mount_for_path(probe)
   1200     log.info("quota: mountpoint=%s fstype=%s options=%s", target, fstype, opts)
   1201     if fstype != "ext4":
   1202         raise QuotaNotAvailableError(
   1203             f"quota runv: só ext4 com quota tradicional é suportado neste script; "
   1204             f"encontrado fstype={fstype!r} em {target!r}"
   1205         )
   1206     if not mount_options_allow_user_quota(opts):
   1207         raise QuotaNotAvailableError(
   1208             f"quota de utilizador não está ativa no mount {target!r}: "
   1209             f"opções atuais não incluem usrquota nem usrjquota=. "
   1210             f"Ajuste /etc/fstab (usrquota), remonte, quotacheck e quotaon — "
   1211             f"o script não altera fstab nem montagens."
   1212         )
   1213     ensure_setquota_available()
   1214     log.info("quota: pré-voo OK (ext4 + usrquota/usrjquota + setquota)")
   1215     return target, fstype, opts
   1216 
   1217 
   1218 def run_setquota_user(
   1219     username: str,
   1220     mountpoint: str,
   1221     block_soft_kib: int,
   1222     block_hard_kib: int,
   1223     inode_soft: int,
   1224     inode_hard: int,
   1225     log: logging.Logger,
   1226 ) -> None:
   1227     """Aplica limites com setquota -u (lista de argumentos, sem shell)."""
   1228     cmd = [
   1229         "setquota",
   1230         "-u",
   1231         username,
   1232         str(block_soft_kib),
   1233         str(block_hard_kib),
   1234         str(inode_soft),
   1235         str(inode_hard),
   1236         mountpoint,
   1237     ]
   1238     log.info("quota: executando %s", " ".join(cmd))
   1239     try:
   1240         r = subprocess.run(
   1241             cmd,
   1242             capture_output=True,
   1243             text=True,
   1244             timeout=120,
   1245         )
   1246     except FileNotFoundError as e:
   1247         raise SystemProvisionError("setquota desapareceu do PATH durante a execução") from e
   1248 
   1249     if r.returncode != 0:
   1250         err = (r.stderr or r.stdout or "").strip()
   1251         raise SystemProvisionError(
   1252             f"setquota falhou (código {r.returncode})" + (f": {err}" if err else "")
   1253         )
   1254     log.info("quota: setquota concluído com sucesso para %r em %r", username, mountpoint)
   1255 
   1256 
   1257 @dataclass
   1258 class QuotaResult:
   1259     """Estado da etapa de quota para metadados e saída."""
   1260 
   1261     enabled: bool
   1262     soft_mib: int | None
   1263     hard_mib: int | None
   1264     inode_soft: int | None
   1265     inode_hard: int | None
   1266     filesystem: str | None
   1267     mountpoint: str | None
   1268     applied_at: str | None
   1269     status: str  # skipped | applied | failed | not_configured
   1270 
   1271 
   1272 def try_apply_quota(
   1273     username: str,
   1274     home: Path,
   1275     soft_mib: int,
   1276     hard_mib: int,
   1277     inode_soft: int,
   1278     inode_hard: int,
   1279     log: logging.Logger,
   1280 ) -> QuotaResult:
   1281     """
   1282     Tenta aplicar quota após o utilizador existir. Não remove o utilizador em caso de falha.
   1283     """
   1284     try:
   1285         target, fstype, _opts = preflight_quota_for_home(home, log)
   1286     except QuotaNotAvailableError as e:
   1287         log.error("quota indisponível: %s", e)
   1288         return QuotaResult(
   1289             enabled=True,
   1290             soft_mib=soft_mib,
   1291             hard_mib=hard_mib,
   1292             inode_soft=inode_soft,
   1293             inode_hard=inode_hard,
   1294             filesystem=None,
   1295             mountpoint=None,
   1296             applied_at=None,
   1297             status="not_configured",
   1298         )
   1299 
   1300     try:
   1301         bs = mib_to_setquota_kib(soft_mib)
   1302         bh = mib_to_setquota_kib(hard_mib)
   1303         run_setquota_user(username, target, bs, bh, inode_soft, inode_hard, log)
   1304     except (SystemProvisionError, ValidationError) as e:
   1305         log.error("quota falhou ao aplicar: %s", e)
   1306         return QuotaResult(
   1307             enabled=True,
   1308             soft_mib=soft_mib,
   1309             hard_mib=hard_mib,
   1310             inode_soft=inode_soft,
   1311             inode_hard=inode_hard,
   1312             filesystem=fstype,
   1313             mountpoint=target,
   1314             applied_at=None,
   1315             status="failed",
   1316         )
   1317 
   1318     now = datetime.now(timezone.utc).isoformat()
   1319     return QuotaResult(
   1320         enabled=True,
   1321         soft_mib=soft_mib,
   1322         hard_mib=hard_mib,
   1323         inode_soft=inode_soft,
   1324         inode_hard=inode_hard,
   1325         filesystem=fstype,
   1326         mountpoint=target,
   1327         applied_at=now,
   1328         status="applied",
   1329     )
   1330 
   1331 
   1332 # CLI
   1333 def print_banner() -> None:
   1334     print()
   1335     print("  create_runv_user — provisionamento runv.club")
   1336     print(f"  versão {VERSION}")
   1337     print(f"  desenvolvido por {AUTHOR} — {COPYRIGHT_YEAR}")
   1338     print()
   1339 
   1340 
   1341 def prompt_yes_no(pergunta: str, default_no: bool = True) -> bool:
   1342     suf = " [s/N]: " if default_no else " [S/n]: "
   1343     r = input(pergunta + suf).strip().lower()
   1344     if not r:
   1345         return not default_no
   1346     return r in ("s", "sim", "y", "yes")
   1347 
   1348 
   1349 def interactive_fill(args: argparse.Namespace) -> None:
   1350     """Preenche args a partir de perguntas no terminal."""
   1351     print_banner()
   1352     print("Modo interativo — responda às perguntas (Ctrl+C para cancelar).\n")
   1353 
   1354     while True:
   1355         u = input("Nome de usuário Unix (minúsculas, ex.: maria): ").strip()
   1356         if u:
   1357             args.username = u
   1358             break
   1359         print("  (obrigatório)")
   1360 
   1361     while True:
   1362         e = input("Email do utilizador (ex.: maria@example.com): ").strip()
   1363         if e:
   1364             args.email = e
   1365             break
   1366         print("  (obrigatório)")
   1367 
   1368     print()
   1369     print("Chave pública SSH (OpenSSH, uma linha).")
   1370     modo = input("  (1) colar a linha agora  (2) ler de arquivo .pub [1]: ").strip() or "1"
   1371     if modo == "2":
   1372         while True:
   1373             caminho = input("  Caminho absoluto do arquivo .pub: ").strip()
   1374             if not caminho:
   1375                 print("  (obrigatório)")
   1376                 continue
   1377             p = Path(caminho).expanduser()
   1378             if not p.is_file():
   1379                 print(f"  Arquivo não encontrado: {p}")
   1380                 continue
   1381             args.public_key = None
   1382             args.public_key_file = p
   1383             break
   1384     else:
   1385         while True:
   1386             print("  Cole a linha completa (ssh-ed25519 AAAA... ou ssh-rsa ...):")
   1387             linha = input("  > ").strip()
   1388             if linha:
   1389                 args.public_key = linha
   1390                 args.public_key_file = None
   1391                 break
   1392             print("  (obrigatório)")
   1393 
   1394     print()
   1395     args.dry_run = prompt_yes_no("Apenas validar (dry-run), sem criar usuário?", default_no=True)
   1396     if not args.dry_run:
   1397         args.force_index = prompt_yes_no(
   1398             "Se já existir ~/public_html/index.html, sobrescrever (--force-index)?",
   1399             default_no=True,
   1400         )
   1401         args.force_gopher = prompt_yes_no(
   1402             "Se já existir ~/public_gopher/gophermap, sobrescrever (--force-gopher)?",
   1403             default_no=True,
   1404         )
   1405         args.force_gemini = prompt_yes_no(
   1406             "Forçar correção do bind mount Gemini (/var/gemini/users) se estiver errado ou em conflito (--force-gemini)?",
   1407             default_no=True,
   1408         )
   1409         args.with_readme = prompt_yes_no(
   1410             "Criar ~/README.md com texto runv (--with-readme)?",
   1411             default_no=True,
   1412         )
   1413         if args.with_readme:
   1414             args.force_readme = prompt_yes_no(
   1415                 "Se já existir ~/README.md, sobrescrever (--force-readme)?",
   1416                 default_no=True,
   1417             )
   1418         else:
   1419             args.force_readme = False
   1420         args.with_jail = prompt_yes_no(
   1421             "Criar jail SSH legada (runv-jailed /srv/jail) (--with-jail)?",
   1422             default_no=True,
   1423         )
   1424         args.no_jail = not args.with_jail
   1425     else:
   1426         args.force_index = False
   1427         args.force_gopher = False
   1428         args.force_gemini = False
   1429         args.force_readme = False
   1430         args.with_jail = False
   1431         args.no_jail = True
   1432 
   1433     args.verbose = prompt_yes_no("Log verboso no terminal?", default_no=True)
   1434 
   1435     if not args.dry_run:
   1436         if prompt_yes_no("Criar utilizador sem quota de disco (--no-quota)?", default_no=True):
   1437             args.no_quota = True
   1438         if not args.no_quota:
   1439             if prompt_yes_no(
   1440                 "Abortar se quota ext4 não estiver pronta antes de criar (--require-quota)?",
   1441                 default_no=True,
   1442             ):
   1443                 args.require_quota = True
   1444 
   1445     print()
   1446     conf = input("Confirmar e continuar? [S/n]: ").strip().lower()
   1447     if conf in ("n", "nao", "não", "no"):
   1448         print("Cancelado.")
   1449         raise SystemExit(EXIT_VALIDATION)
   1450 
   1451 
   1452 def setup_logging(log_path: Path, verbose: bool) -> logging.Logger:
   1453     logger = logging.getLogger("runv")
   1454     logger.setLevel(logging.DEBUG if verbose else logging.INFO)
   1455     logger.handlers.clear()
   1456     fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
   1457     try:
   1458         log_path.parent.mkdir(parents=True, exist_ok=True)
   1459         fh = logging.FileHandler(log_path, encoding="utf-8")
   1460         fh.setLevel(logging.DEBUG)
   1461         fh.setFormatter(fmt)
   1462         logger.addHandler(fh)
   1463     except OSError as e:
   1464         print(f"Aviso: não foi possível gravar log em {log_path}: {e}", file=sys.stderr)
   1465     sh = logging.StreamHandler(sys.stderr)
   1466     sh.setLevel(logging.DEBUG if verbose else logging.WARNING)
   1467     sh.setFormatter(fmt)
   1468     logger.addHandler(sh)
   1469     return logger
   1470 
   1471 
   1472 def _resolve_email_package_root(state: dict[str, Any] | None) -> Path | None:
   1473     """Pasta ``email/`` do repositório para importar ``lib.mailer``."""
   1474     env = os.environ.get("RUNV_EMAIL_ROOT", "").strip()
   1475     if env:
   1476         p = Path(env)
   1477         return p if p.is_dir() else None
   1478     if state:
   1479         er = str(state.get("email_package_root", "")).strip()
   1480         if er:
   1481             p = Path(er)
   1482             if p.is_dir():
   1483                 return p
   1484     cand = _REPO_ROOT / "email"
   1485     return cand if cand.is_dir() else None
   1486 
   1487 
   1488 def try_patch_irc_for_new_user(
   1489     username: str,
   1490     *,
   1491     dry_run: bool,
   1492     log: logging.Logger,
   1493 ) -> None:
   1494     """
   1495     Executa ``patches/patch_irc.py --user`` (WeeChat headless: servidor «runv», irc.tilde.chat, TLS, #runv).
   1496     Não aborta o provisionamento se o patch falhar; contas em ``IRC_PATCH_SKIP_USERS`` são ignoradas.
   1497     """
   1498     if dry_run:
   1499         return
   1500     patch_path = _REPO_ROOT / "patches" / "patch_irc.py"
   1501     if not patch_path.is_file():
   1502         log.warning("patch IRC: ficheiro ausente %s — corra o patch manualmente no servidor.", patch_path)
   1503         return
   1504     try:
   1505         import importlib.util
   1506 
   1507         spec = importlib.util.spec_from_file_location("patch_irc_embed", patch_path)
   1508         if spec is None or spec.loader is None:
   1509             log.warning("patch IRC: não foi possível carregar %s", patch_path)
   1510             return
   1511         pim = importlib.util.module_from_spec(spec)
   1512         spec.loader.exec_module(pim)
   1513         if username in pim.IRC_PATCH_SKIP_USERS:
   1514             log.info("patch IRC omitido (lista reservada / serviço): %s", username)
   1515             return
   1516     except Exception as e:
   1517         log.warning("patch IRC: verificação de skip falhou (%s); tento subprocess mesmo assim.", e)
   1518     cmd = [sys.executable, str(patch_path), "--user", username]
   1519     log.info("patch IRC: %s", " ".join(cmd))
   1520     try:
   1521         r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
   1522     except (OSError, subprocess.TimeoutExpired) as e:
   1523         log.warning("patch IRC: execução falhou: %s", e)
   1524         return
   1525     if r.returncode != 0:
   1526         log.warning(
   1527             "patch IRC terminou com código %s para %s: %s",
   1528             r.returncode,
   1529             username,
   1530             ((r.stderr or "") + (r.stdout or "")).strip()[:2000] or "(sem saída)",
   1531         )
   1532     else:
   1533         log.info("patch IRC concluído para %s (comando «chat», rede runv / #runv).", username)
   1534 
   1535 
   1536 def try_send_welcome_email(
   1537     *,
   1538     username: str,
   1539     user_email: str,
   1540     fingerprint: str,
   1541     request_id: str | None,
   1542     base_url: str,
   1543     welcome_ssh_host: str | None,
   1544     no_welcome_email: bool,
   1545     dry_run: bool,
   1546     log: logging.Logger,
   1547 ) -> None:
   1548     """
   1549     Envia ``user_account_created`` ao email do utilizador se existir configuração global
   1550     (``/etc/runv-email.json``) e módulo ``email/`` acessível. Falhas são só registadas
   1551     em log — a conta já foi criada.
   1552     """
   1553     if no_welcome_email:
   1554         log.info("email de boas-vindas: omitido (--no-welcome-email)")
   1555         return
   1556     if dry_run:
   1557         log.info("email de boas-vindas: omitido (--dry-run)")
   1558         return
   1559 
   1560     state_file = Path("/etc/runv-email.json")
   1561     if not state_file.is_file():
   1562         log.info(
   1563             "email de boas-vindas: %s ausente — defina email ou use --no-welcome-email",
   1564             state_file,
   1565         )
   1566         return
   1567     try:
   1568         state = json.loads(state_file.read_text(encoding="utf-8"))
   1569     except (OSError, json.JSONDecodeError) as e:
   1570         log.warning("email de boas-vindas: estado inválido (%s): %s", state_file, e)
   1571         return
   1572 
   1573     email_root = _resolve_email_package_root(state)
   1574     if email_root is None:
   1575         log.warning(
   1576             "email de boas-vindas: pasta email/ não encontrada "
   1577             "(RUNV_EMAIL_ROOT, email_package_root no JSON ou repositório em %s)",
   1578             _REPO_ROOT / "email",
   1579         )
   1580         return
   1581 
   1582     root_s = str(email_root.resolve())
   1583     if root_s not in sys.path:
   1584         sys.path.insert(0, root_s)
   1585 
   1586     try:
   1587         from lib.mailer import send_user_notice
   1588         from lib.templates import USER_ACCOUNT_CREATED
   1589     except ImportError as e:
   1590         log.warning("email de boas-vindas: import lib.mailer falhou: %s", e)
   1591         return
   1592 
   1593     from_addr = str(state.get("default_from", "")).strip()
   1594     if not from_addr:
   1595         log.warning("email de boas-vindas: default_from ausente em %s", state_file)
   1596         return
   1597 
   1598     member_url = f"{base_url.rstrip('/')}/~{username}/"
   1599     host = (welcome_ssh_host or "").strip()
   1600     if host:
   1601         ssh_instructions = (
   1602             f"Comando sugerido: ssh {username}@{host}\n"
   1603             "Confirme no cliente SSH que está a usar a chave privada correta "
   1604             "(a que corresponde à impressão digital acima)."
   1605         )
   1606     else:
   1607         ssh_instructions = (
   1608             f"Comando típico: ssh {username}@<hostname>\n"
   1609             "Substitua <hostname> pelo endereço do servidor que o administrador lhe indicar. "
   1610             "No cliente SSH, seleccione a **chave privada** que corresponde à chave pública registada."
   1611         )
   1612 
   1613     try:
   1614         send_user_notice(
   1615             USER_ACCOUNT_CREATED,
   1616             user_email,
   1617             subject="[runv.club] Bem-vindo(a) — a sua conta foi criada",
   1618             from_addr=from_addr,
   1619             _state=state,
   1620             username=username,
   1621             email=user_email,
   1622             fingerprint=fingerprint,
   1623             request_reference=(
   1624                 f"Referência do seu pedido: {request_id}"
   1625                 if request_id
   1626                 else "Referência do seu pedido: não aplicável"
   1627             ),
   1628             member_url=member_url,
   1629             ssh_instructions=ssh_instructions,
   1630         )
   1631         log.info("email de boas-vindas enviado para %s", user_email)
   1632         print(f"  boas-vindas:        email enviado para {user_email}")
   1633     except Exception as e:
   1634         log.warning("email de boas-vindas falhou (conta já criada): %s", e)
   1635 
   1636 
   1637 def try_send_admin_user_created_email(
   1638     *,
   1639     username: str,
   1640     user_email: str,
   1641     operator_info: str,
   1642     timestamp: str,
   1643     request_id: str | None,
   1644     no_admin_create_email: bool,
   1645     dry_run: bool,
   1646     log: logging.Logger,
   1647 ) -> None:
   1648     """
   1649     Envia ``admin_user_created`` para ``admin_email`` em ``/etc/runv-email.json``.
   1650     Falhas só em log — a conta já foi criada.
   1651     """
   1652     if no_admin_create_email:
   1653         log.info("email admin (conta criada): omitido (--no-admin-create-email)")
   1654         return
   1655     if dry_run:
   1656         log.info("email admin (conta criada): omitido (--dry-run)")
   1657         return
   1658 
   1659     state_file = Path("/etc/runv-email.json")
   1660     if not state_file.is_file():
   1661         log.info(
   1662             "email admin (conta criada): %s ausente — omitido",
   1663             state_file,
   1664         )
   1665         return
   1666     try:
   1667         state = json.loads(state_file.read_text(encoding="utf-8"))
   1668     except (OSError, json.JSONDecodeError) as e:
   1669         log.warning("email admin (conta criada): estado inválido (%s): %s", state_file, e)
   1670         return
   1671 
   1672     admin = str(state.get("admin_email", "")).strip()
   1673     if not admin:
   1674         log.info(
   1675             "email admin (conta criada): admin_email vazio em %s — omitido",
   1676             state_file,
   1677         )
   1678         return
   1679 
   1680     email_root = _resolve_email_package_root(state)
   1681     if email_root is None:
   1682         log.warning(
   1683             "email admin (conta criada): pasta email/ não encontrada "
   1684             "(RUNV_EMAIL_ROOT, email_package_root no JSON ou repositório em %s)",
   1685             _REPO_ROOT / "email",
   1686         )
   1687         return
   1688 
   1689     root_s = str(email_root.resolve())
   1690     if root_s not in sys.path:
   1691         sys.path.insert(0, root_s)
   1692 
   1693     try:
   1694         from lib.mailer import send_admin_notice
   1695         from lib.templates import ADMIN_USER_CREATED
   1696     except ImportError as e:
   1697         log.warning("email admin (conta criada): import lib.mailer falhou: %s", e)
   1698         return
   1699 
   1700     from_addr = str(state.get("default_from", "")).strip()
   1701     if not from_addr:
   1702         log.warning("email admin (conta criada): default_from ausente em %s", state_file)
   1703         return
   1704 
   1705     try:
   1706         send_admin_notice(
   1707             ADMIN_USER_CREATED,
   1708             admin,
   1709             subject=f"[runv.club] Conta criada — {username}",
   1710             from_addr=from_addr,
   1711             _state=state,
   1712             username=username,
   1713             email=user_email,
   1714             operator_info=operator_info,
   1715             timestamp=timestamp,
   1716             request_reference=request_id or "manual",
   1717         )
   1718         log.info("email admin (conta criada) enviado para %s", admin)
   1719         print(f"  admin (conta):     email enviado para {admin}")
   1720     except Exception as e:
   1721         log.warning("email admin (conta criada) falhou (conta já criada): %s", e)
   1722 
   1723 
   1724 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
   1725     p = argparse.ArgumentParser(
   1726         description=(
   1727             "Provisiona conta Unix interna (runv.club). Executar como root no servidor. "
   1728             "Aplica permissões completas para HTTP, Gopher e Gemini (home e public_*); "
   1729             "contas só adduser precisam de setup_alt_protocols ou reparo aqui. "
   1730             f"Versão {VERSION} — {AUTHOR} {COPYRIGHT_YEAR}."
   1731         ),
   1732     )
   1733     p.add_argument(
   1734         "-i",
   1735         "--interactive",
   1736         action="store_true",
   1737         help="modo interativo (perguntas no terminal); também é o padrão se não passar nenhum argumento",
   1738     )
   1739     p.add_argument(
   1740         "--request-id",
   1741         "--user",
   1742         dest="request_id",
   1743         default=None,
   1744         help="aprova automaticamente um pedido pendente da fila entre-queue pelo UUID",
   1745     )
   1746     p.add_argument(
   1747         "--all-pending",
   1748         action="store_true",
   1749         help="aprova e processa todos os pedidos pendentes da entre-queue, em sequência",
   1750     )
   1751     p.add_argument("--username", default=None, help="nome de usuário Unix (minúsculas)")
   1752     p.add_argument("--email", default=None, help="email do utilizador (também em users.json)")
   1753     g = p.add_mutually_exclusive_group(required=False)
   1754     g.add_argument("--public-key", dest="public_key", default=None, help="linha authorized_keys (OpenSSH)")
   1755     g.add_argument(
   1756         "--public-key-file",
   1757         type=Path,
   1758         dest="public_key_file",
   1759         default=None,
   1760         help="arquivo com uma linha .pub",
   1761     )
   1762     p.add_argument("--dry-run", action="store_true", help="valida e mostra o plano sem alterar o sistema")
   1763     p.add_argument("--verbose", action="store_true", help="log detalhado no stderr")
   1764     p.add_argument(
   1765         "--force-index",
   1766         action="store_true",
   1767         help="sobrescrever ~/public_html/index.html se já existir",
   1768     )
   1769     p.add_argument(
   1770         "--with-readme",
   1771         action="store_true",
   1772         help="criar ~/README.md com texto runv (por omissão não cria)",
   1773     )
   1774     p.add_argument(
   1775         "--force-readme",
   1776         action="store_true",
   1777         help="com --with-readme: sobrescrever ~/README.md se já existir",
   1778     )
   1779     p.add_argument(
   1780         "--no-jail",
   1781         action="store_true",
   1782         help="compatibilidade: não adicionar a runv-jailed nem criar jail em /srv/jail (padrão atual)",
   1783     )
   1784     p.add_argument(
   1785         "--with-jail",
   1786         action="store_true",
   1787         help="legado/opt-in: adicionar a runv-jailed e criar jail em /srv/jail",
   1788     )
   1789     p.add_argument(
   1790         "--force-gopher",
   1791         action="store_true",
   1792         help="sobrescrever ~/public_gopher/gophermap se já existir",
   1793     )
   1794     p.add_argument(
   1795         "--force-gemini",
   1796         action="store_true",
   1797         help="corrigir bind mount em /var/gemini/users (migra symlink; remount se necessário); não sobrescreve index.gmi existente",
   1798     )
   1799     p.add_argument(
   1800         "--metadata-file",
   1801         type=Path,
   1802         default=DEFAULT_METADATA_PATH,
   1803         help=f"caminho do JSON de metadados (padrão: {DEFAULT_METADATA_PATH})",
   1804     )
   1805     p.add_argument(
   1806         "--lock-file",
   1807         type=Path,
   1808         default=DEFAULT_LOCK_PATH,
   1809         help=f"arquivo de lock flock (padrão: {DEFAULT_LOCK_PATH})",
   1810     )
   1811     p.add_argument(
   1812         "--log-file",
   1813         type=Path,
   1814         default=DEFAULT_LOG_PATH,
   1815         help=f"log local (padrão: {DEFAULT_LOG_PATH})",
   1816     )
   1817     p.add_argument(
   1818         "--queue-dir",
   1819         type=Path,
   1820         default=DEFAULT_ENTRE_QUEUE_DIR,
   1821         help=f"fila do entre para aprovar por request_id (padrão: {DEFAULT_ENTRE_QUEUE_DIR})",
   1822     )
   1823     p.add_argument(
   1824         "--base-url",
   1825         default=DEFAULT_BASE_URL,
   1826         help=f"URL base para o resumo (padrão: {DEFAULT_BASE_URL})",
   1827     )
   1828     p.add_argument(
   1829         "--landing-document-root",
   1830         type=Path,
   1831         default=Path("/var/www/runv.club/html"),
   1832         help=(
   1833             "DocumentRoot da landing Apache (directório existente); após criar o utilizador, "
   1834             "executa site/genlanding.py --sync-public-only (copia site/public + data/members.json). "
   1835             "Se não existir, a sincronização é omitida e é impresso um AVISO com o comando sugerido."
   1836         ),
   1837     )
   1838     p.add_argument(
   1839         "--no-refresh-landing-members",
   1840         action="store_true",
   1841         help=(
   1842             "não sincronizar site/public → DocumentRoot nem regenerar data/members.json após gravar metadados"
   1843         ),
   1844     )
   1845     p.add_argument(
   1846         "--members-homes-root",
   1847         type=Path,
   1848         default=None,
   1849         help="se definido (ex. /home), passa --members-homes-root a genlanding (homepage_mtime em members.json)",
   1850     )
   1851     p.add_argument(
   1852         "--no-quota",
   1853         action="store_true",
   1854         help="não aplica quota de disco (ignora setquota)",
   1855     )
   1856     p.add_argument(
   1857         "--require-quota",
   1858         action="store_true",
   1859         help=(
   1860             "exige sistema de quotas pronto (ext4 + usrquota + setquota) antes de criar o utilizador; "
   1861             "aborta sem adduser se não estiver configurado"
   1862         ),
   1863     )
   1864     p.add_argument(
   1865         "--quota-soft-mb",
   1866         type=int,
   1867         default=DEFAULT_QUOTA_SOFT_MIB,
   1868         metavar="MiB",
   1869         help=f"limite soft de blocos em MiB (1024² B); padrão {DEFAULT_QUOTA_SOFT_MIB}",
   1870     )
   1871     p.add_argument(
   1872         "--quota-hard-mb",
   1873         type=int,
   1874         default=DEFAULT_QUOTA_HARD_MIB,
   1875         metavar="MiB",
   1876         help=f"limite hard de blocos em MiB; padrão {DEFAULT_QUOTA_HARD_MIB}",
   1877     )
   1878     p.add_argument(
   1879         "--quota-inode-soft",
   1880         type=int,
   1881         default=DEFAULT_QUOTA_INODE_SOFT,
   1882         metavar="N",
   1883         help=f"limite soft de inodes; padrão {DEFAULT_QUOTA_INODE_SOFT}",
   1884     )
   1885     p.add_argument(
   1886         "--quota-inode-hard",
   1887         type=int,
   1888         default=DEFAULT_QUOTA_INODE_HARD,
   1889         metavar="N",
   1890         help=f"limite hard de inodes; padrão {DEFAULT_QUOTA_INODE_HARD}",
   1891     )
   1892     p.add_argument(
   1893         "--version",
   1894         action="version",
   1895         version=f"%(prog)s {VERSION} — desenvolvido por {AUTHOR}, {COPYRIGHT_YEAR}",
   1896     )
   1897     p.add_argument(
   1898         "--no-welcome-email",
   1899         action="store_true",
   1900         help="não enviar email de boas-vindas ao utilizador após criar a conta",
   1901     )
   1902     p.add_argument(
   1903         "--no-admin-create-email",
   1904         action="store_true",
   1905         help="não enviar email ao admin (template admin_user_created) após criar a conta",
   1906     )
   1907     p.add_argument(
   1908         "--welcome-ssh-host",
   1909         default=None,
   1910         metavar="HOST",
   1911         help=(
   1912             "hostname SSH para incluir no email de boas-vindas (ex.: runv.club); "
   1913             "alternativa: variável de ambiente RUNV_WELCOME_SSH_HOST"
   1914         ),
   1915     )
   1916     return p.parse_args(argv)
   1917 
   1918 
   1919 def main(argv: list[str] | None = None) -> int:
   1920     if argv is None:
   1921         argv = sys.argv[1:]
   1922     if not argv:
   1923         argv = ["--interactive"]
   1924 
   1925     args = parse_args(argv)
   1926     args.no_jail = not getattr(args, "with_jail", False)
   1927     if args.interactive:
   1928         try:
   1929             interactive_fill(args)
   1930         except KeyboardInterrupt:
   1931             print("\nInterrompido (Ctrl+C).", file=sys.stderr)
   1932             return EXIT_VALIDATION
   1933         except SystemExit as e:
   1934             code = e.code
   1935             if code is None:
   1936                 return EXIT_VALIDATION
   1937             if isinstance(code, int):
   1938                 return code
   1939             return EXIT_VALIDATION
   1940 
   1941     if args.all_pending:
   1942         if args.request_id or args.username or args.email or args.public_key or args.public_key_file:
   1943             print(
   1944                 "Erro: --all-pending não deve ser combinado com --request-id/--user, --username, --email ou chave manual.",
   1945                 file=sys.stderr,
   1946             )
   1947             return EXIT_VALIDATION
   1948         return process_all_pending_requests(args)
   1949 
   1950     queue_request: QueueApprovalRequest | None = None
   1951     if args.request_id:
   1952         if args.username or args.email or args.public_key or args.public_key_file:
   1953             print(
   1954                 "Erro: --request-id/--user não deve ser combinado com --username, --email, --public-key ou --public-key-file.",
   1955                 file=sys.stderr,
   1956             )
   1957             return EXIT_VALIDATION
   1958         try:
   1959             queue_request = load_queue_request_by_id(args.request_id, args.queue_dir)
   1960         except ValidationError as e:
   1961             print(f"Validação: {e}", file=sys.stderr)
   1962             return EXIT_VALIDATION
   1963         args.username = queue_request.username
   1964         args.email = queue_request.email
   1965         args.public_key = queue_request.public_key
   1966         args.public_key_file = None
   1967 
   1968     if not args.username or not args.email:
   1969         print(
   1970             "Erro: informe --username e --email, ou use --interactive / execute sem argumentos.",
   1971             file=sys.stderr,
   1972         )
   1973         return EXIT_VALIDATION
   1974     if not args.public_key and not args.public_key_file:
   1975         print(
   1976             "Erro: informe --public-key ou --public-key-file, ou use modo interativo.",
   1977             file=sys.stderr,
   1978         )
   1979         return EXIT_VALIDATION
   1980 
   1981     log = setup_logging(args.log_file, args.verbose)
   1982     log.info(
   1983         "=== início operação create_runv_user (versão %s) dry_run=%s interactive=%s",
   1984         VERSION,
   1985         args.dry_run,
   1986         args.interactive,
   1987     )
   1988 
   1989     try:
   1990         operator_user = require_authorized_admin_operator(dry_run=bool(args.dry_run))
   1991     except (ValidationError, SystemProvisionError) as e:
   1992         print(f"Acesso: {e}", file=sys.stderr)
   1993         return EXIT_VALIDATION if isinstance(e, ValidationError) else EXIT_SYSTEM
   1994 
   1995     if os.geteuid() != 0 and not args.dry_run:
   1996         print("Erro: execute como root (ou sudo) para criar usuários.", file=sys.stderr)
   1997         log.error("recusado: euid != 0 e não é dry-run")
   1998         return EXIT_SYSTEM
   1999 
   2000     try:
   2001         log.info("=== fase: validação de entrada (username, email, chave SSH)")
   2002         raw_key = read_public_key_from_args(args.public_key, args.public_key_file)
   2003         user = validate_username(args.username)
   2004         email = validate_email(args.email)
   2005         normalized_key, fingerprint = validate_public_key(raw_key)
   2006         log.info(
   2007             "=== validação OK: user=%s email=%s fingerprint=%s",
   2008             user,
   2009             email,
   2010             fingerprint,
   2011         )
   2012     except ValidationError as e:
   2013         log.error("validação falhou: %s", e)
   2014         print(f"Validação: {e}", file=sys.stderr)
   2015         return EXIT_VALIDATION
   2016 
   2017     home = home_directory(user)
   2018 
   2019     if args.no_quota and args.require_quota:
   2020         print(
   2021             "Erro: --no-quota e --require-quota não podem ser usados em conjunto.",
   2022             file=sys.stderr,
   2023         )
   2024         return EXIT_VALIDATION
   2025 
   2026     if not args.no_quota:
   2027         try:
   2028             validate_quota_limits(
   2029                 args.quota_soft_mb,
   2030                 args.quota_hard_mb,
   2031                 args.quota_inode_soft,
   2032                 args.quota_inode_hard,
   2033             )
   2034         except ValidationError as e:
   2035             print(f"Validação: {e}", file=sys.stderr)
   2036             return EXIT_VALIDATION
   2037 
   2038     if args.dry_run:
   2039         print("[dry-run] Nenhuma alteração será feita.")
   2040         print(f"  username:     {user}")
   2041         print(f"  email:        {email}")
   2042         print(f"  home:         {home}")
   2043         print(f"  fingerprint:  {fingerprint}")
   2044         print(f"  operador:     {operator_user}")
   2045         if queue_request is not None:
   2046             print(f"  pedido fila:  {queue_request.request_id} ({queue_request.queue_path})")
   2047         print(
   2048             "  ações: (1) adduser + skel  (2) authorized_keys  (3) public_html  "
   2049             "(4) public_gopher + public_gemini + bind Gemini  (5) README só com --with-readme  "
   2050             "(6) permissões  (7) jail SSH só com --with-jail  "
   2051             "(8) quota  (9) verificação + patch IRC (chat)  (10) metadados JSON"
   2052         )
   2053         print(
   2054             f"  with-readme: {getattr(args, 'with_readme', False)}  "
   2055             f"with-jail: {getattr(args, 'with_jail', False)}"
   2056         )
   2057         if args.no_quota:
   2058             print("  quota:        desativada (--no-quota)")
   2059         else:
   2060             print(
   2061                 f"  quota:        MiB soft/hard {args.quota_soft_mb}/{args.quota_hard_mb}; "
   2062                 f"inodes {args.quota_inode_soft}/{args.quota_inode_hard}"
   2063             )
   2064             print(
   2065                 "  quota:        tentará setquota após criar utilizador (ext4 + usrquota/usrjquota + pacote quota)"
   2066             )
   2067         if args.require_quota and not args.no_quota:
   2068             print(
   2069                 "  quota:        --require-quota — aborta antes de adduser se o sistema de quotas não estiver pronto"
   2070             )
   2071         return EXIT_OK
   2072 
   2073     created_user = False
   2074     try:
   2075         if args.require_quota and not args.no_quota:
   2076             log.info("=== fase: pré-voo de quota (require-quota)")
   2077             preflight_quota_for_home(home, log)
   2078 
   2079         log.info("=== fase 1: criação de conta Unix (adduser; /etc/skel copiado pelo Debian)")
   2080         run_adduser(user, log)
   2081         created_user = True
   2082         pw = pwd.getpwnam(user)
   2083         uid, gid = pw.pw_uid, pw.pw_gid
   2084         log.info("=== adduser concluído: uid=%s gid=%s home=%s", uid, gid, home)
   2085 
   2086         log.info("=== fase 2: SSH authorized_keys (~/.ssh 700, arquivo 600)")
   2087         install_authorized_keys(home, uid, gid, normalized_key, log)
   2088 
   2089         log.info("=== fase 3: public_html e index.html estático")
   2090         prepare_public_html(home, user, uid, gid, args.force_index, log)
   2091 
   2092         log.info("=== fase 3b: public_gopher (gophermap) e public_gemini (index.gmi)")
   2093         prepare_public_gopher(home, user, uid, gid, args.force_gopher, log)
   2094         prepare_public_gemini(home, user, uid, gid, log)
   2095         ensure_gemini_user_symlink(user, home, log, force=args.force_gemini)
   2096 
   2097         if args.with_readme:
   2098             log.info("=== fase 4: README.md runv (--with-readme)")
   2099             prepare_user_readme(home, user, uid, gid, args.base_url, args.force_readme, log)
   2100         else:
   2101             log.info("=== fase 4: README.md omitido (use --with-readme para criar)")
   2102 
   2103         log.info("=== fase 5: permissões consolidadas (home, .ssh, sites públicos, README se existir)")
   2104         apply_runv_permissions(home, uid, gid)
   2105 
   2106         log.info("=== fase 6: jail SSH legada (só com --with-jail)")
   2107         try:
   2108             runv_jail.ensure_runv_jail_for_user(
   2109                 user,
   2110                 home,
   2111                 no_jail=bool(args.no_jail),
   2112                 log=log,
   2113             )
   2114         except RuntimeError as e:
   2115             raise SystemProvisionError(str(e)) from e
   2116 
   2117         log.info("=== fase: quota (setquota em ext4 com usrquota)")
   2118         if args.no_quota:
   2119             qr = QuotaResult(
   2120                 enabled=False,
   2121                 soft_mib=None,
   2122                 hard_mib=None,
   2123                 inode_soft=None,
   2124                 inode_hard=None,
   2125                 filesystem=None,
   2126                 mountpoint=None,
   2127                 applied_at=None,
   2128                 status="skipped",
   2129             )
   2130             log.info("quota: ignorada (--no-quota)")
   2131         else:
   2132             qr = try_apply_quota(
   2133                 user,
   2134                 home,
   2135                 args.quota_soft_mb,
   2136                 args.quota_hard_mb,
   2137                 args.quota_inode_soft,
   2138                 args.quota_inode_hard,
   2139                 log,
   2140             )
   2141             log.info(
   2142                 "quota: estado final status=%s mount=%s fs=%s",
   2143                 qr.status,
   2144                 qr.mountpoint,
   2145                 qr.filesystem,
   2146             )
   2147 
   2148         overall_status = "active"
   2149         if not args.no_quota and qr.status in ("failed", "not_configured"):
   2150             overall_status = "partial_quota"
   2151 
   2152         log.info("=== fase: verificação final de permissões e artefactos")
   2153         verify_user_artifact_permissions(
   2154             home,
   2155             uid,
   2156             gid,
   2157             expect_readme=bool(args.with_readme),
   2158         )
   2159 
   2160         log.info("=== fase: IRC WeeChat (patches/patch_irc.py — comando chat, runv / #runv)")
   2161         try_patch_irc_for_new_user(user, dry_run=False, log=log)
   2162 
   2163         record = UserRecord(
   2164             username=user,
   2165             email=email,
   2166             public_key_fingerprint=fingerprint,
   2167             created_at=datetime.now(timezone.utc).isoformat(),
   2168             created_by=operator_user,
   2169             home_directory=str(home),
   2170             status=overall_status,
   2171             quota_enabled=qr.enabled,
   2172             quota_soft_mb=qr.soft_mib,
   2173             quota_hard_mb=qr.hard_mib,
   2174             quota_inode_soft=qr.inode_soft,
   2175             quota_inode_hard=qr.inode_hard,
   2176             quota_filesystem=qr.filesystem,
   2177             quota_mountpoint=qr.mountpoint,
   2178             quota_applied_at=qr.applied_at,
   2179             quota_status=qr.status,
   2180         )
   2181         log.info("=== fase: gravação de metadados JSON (%s)", args.metadata_file)
   2182         append_user_metadata(args.metadata_file, args.lock_file, record, log)
   2183 
   2184         members_refreshed = False
   2185         members_public_count: int | None = None
   2186         if not args.no_refresh_landing_members and args.landing_document_root:
   2187             root = args.landing_document_root.resolve()
   2188             if root.is_dir():
   2189                 log.info("=== fase: sincronizar landing (public + members) (%s)", root)
   2190                 members_refreshed, members_public_count = try_sync_landing_via_genlanding(
   2191                     document_root=root,
   2192                     users_json=args.metadata_file,
   2193                     homes_root=args.members_homes_root.resolve()
   2194                     if args.members_homes_root
   2195                     else None,
   2196                     log=log,
   2197                 )
   2198             else:
   2199                 log.warning(
   2200                     "DocumentRoot da landing inexistente (%s); constelação/bolhas não actualizadas "
   2201                     "(corra site/genlanding.py antes ou aponte --landing-document-root para o DocumentRoot real).",
   2202                     root,
   2203                 )
   2204 
   2205         log.info(
   2206             "=== resultado final: status=%s quota_status=%s (operação concluída)",
   2207             overall_status,
   2208             qr.status,
   2209         )
   2210         print("Usuário criado com sucesso.")
   2211         print(f"  home:              {home}")
   2212         print("  ssh:               authorized_keys instalado")
   2213         print("  public_html:       pronto (index.html estático)")
   2214         print("  public_gopher:     pronto (gophermap)")
   2215         print("  public_gemini:     pronto (index.gmi)")
   2216         print("  bind Gemini:       /var/gemini/users/<user> <- ~/public_gemini (se o diretório existir)")
   2217         print("  IRC:               comando «chat» → irc.tilde.chat (TLS) #runv (patch_irc.py)")
   2218         if args.with_readme:
   2219             print("  README.md:         criado em ~/README.md (pt-BR)")
   2220         else:
   2221             print("  README.md:         omitido (use --with-readme para criar)")
   2222         if args.no_jail:
   2223             print("  jail SSH:          omitido (padrão; use --with-jail para legado)")
   2224         else:
   2225             print("  jail SSH:          runv-jailed + /srv/jail/<user> (bind home)")
   2226         print(f"  URL prevista:      {args.base_url.rstrip('/')}/~{user}/")
   2227         print(f"  fingerprint:       {fingerprint}")
   2228         print(f"  metadados:         {args.metadata_file}")
   2229         if queue_request is not None:
   2230             print(f"  pedido aprovado:   {queue_request.request_id}")
   2231         dr_resolved = (
   2232             args.landing_document_root.resolve() if args.landing_document_root else None
   2233         )
   2234         out_members = (dr_resolved / "data" / "members.json") if dr_resolved else None
   2235         homes_opt = ""
   2236         if args.members_homes_root:
   2237             homes_opt = f" --members-homes-root {args.members_homes_root.resolve()}"
   2238         if args.no_refresh_landing_members:
   2239             print("  landing (public + bolhas): omitida (--no-refresh-landing-members)")
   2240         elif dr_resolved is not None:
   2241             if not dr_resolved.is_dir():
   2242                 print(
   2243                     f"  AVISO landing: DocumentRoot inexistente ({dr_resolved}) — "
   2244                     "public/members não actualizados. Primeiro: site/genlanding.py (Apache); depois: "
   2245                     f"python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only "
   2246                     f"--document-root {dr_resolved} --members-users-json {args.metadata_file}"
   2247                     f"{homes_opt}",
   2248                     file=sys.stderr,
   2249                 )
   2250             elif members_refreshed:
   2251                 cnt = (
   2252                     f", {members_public_count} membro(s) público(s)"
   2253                     if members_public_count is not None
   2254                     else ""
   2255                 )
   2256                 print(f"  landing (public + bolhas): sincronizado{cnt} → {out_members}")
   2257             else:
   2258                 print(
   2259                     f"  AVISO landing: falha ao sincronizar (ver log). "
   2260                     f"Manual: python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only "
   2261                     f"--document-root {dr_resolved} --members-users-json {args.metadata_file}"
   2262                     f"{homes_opt}",
   2263                     file=sys.stderr,
   2264                 )
   2265         if args.no_quota:
   2266             print("  quota:             omitida (--no-quota)")
   2267         else:
   2268             print(
   2269                 f"  quota:             status={qr.status} "
   2270                 f"(MiB {args.quota_soft_mb}/{args.quota_hard_mb}, "
   2271                 f"inodes {args.quota_inode_soft}/{args.quota_inode_hard})"
   2272             )
   2273             if qr.mountpoint:
   2274                 print(f"  quota mount:       {qr.mountpoint} ({qr.filesystem or '?'})")
   2275 
   2276         welcome_host = (args.welcome_ssh_host or os.environ.get("RUNV_WELCOME_SSH_HOST") or "").strip()
   2277         welcome_host_opt: str | None = welcome_host if welcome_host else None
   2278         try_send_welcome_email(
   2279             username=user,
   2280             user_email=email,
   2281             fingerprint=fingerprint,
   2282             request_id=queue_request.request_id if queue_request else None,
   2283             base_url=args.base_url,
   2284             welcome_ssh_host=welcome_host_opt,
   2285             no_welcome_email=bool(args.no_welcome_email),
   2286             dry_run=bool(args.dry_run),
   2287             log=log,
   2288         )
   2289         try_send_admin_user_created_email(
   2290             username=user,
   2291             user_email=email,
   2292             operator_info=record.created_by,
   2293             timestamp=record.created_at,
   2294             request_id=queue_request.request_id if queue_request else None,
   2295             no_admin_create_email=bool(args.no_admin_create_email),
   2296             dry_run=bool(args.dry_run),
   2297             log=log,
   2298         )
   2299         if queue_request is not None:
   2300             archive_approved_queue_request(
   2301                 queue_request,
   2302                 operator=operator_user,
   2303                 created_username=user,
   2304                 dry_run=bool(args.dry_run),
   2305                 log=log,
   2306             )
   2307 
   2308         if not args.no_quota and qr.status in ("failed", "not_configured"):
   2309             print(
   2310                 "\n*** AVISO: conta criada mas quota NÃO aplicada ou sistema não configurado. "
   2311                 "Estado em metadados: partial_quota / quota_status. "
   2312                 "Corrija usrquota+quotaon e aplique setquota manualmente ou remova o utilizador se foi engano.",
   2313                 file=sys.stderr,
   2314             )
   2315             return EXIT_INCONSISTENT
   2316 
   2317         return EXIT_OK
   2318 
   2319     except ValidationError as e:
   2320         log.error("validação: %s", e)
   2321         print(f"Validação: {e}", file=sys.stderr)
   2322         if created_user:
   2323             if run_deluser_remove_home(user, log):
   2324                 print("Rollback: usuário removido após falha de validação tardia.", file=sys.stderr)
   2325             else:
   2326                 print(
   2327                     f"ERRO: estado parcial — usuário {user!r} pode existir; remova manualmente se necessário.",
   2328                     file=sys.stderr,
   2329                 )
   2330                 return EXIT_INCONSISTENT
   2331         return EXIT_VALIDATION
   2332 
   2333     except SystemProvisionError as e:
   2334         log.exception("falha de sistema: %s", e)
   2335         print(f"Erro de sistema: {e}", file=sys.stderr)
   2336         if created_user:
   2337             if run_deluser_remove_home(user, log):
   2338                 print("Rollback: usuário e home removidos.", file=sys.stderr)
   2339             else:
   2340                 print(
   2341                     f"FALHA NO ROLLBACK: revise o usuário {user!r} e o home em {home} manualmente.",
   2342                     file=sys.stderr,
   2343                 )
   2344                 return EXIT_INCONSISTENT
   2345         return EXIT_SYSTEM
   2346 
   2347     except Exception as e:
   2348         log.exception("erro inesperado: %s", e)
   2349         print(f"Erro inesperado: {e}", file=sys.stderr)
   2350         if created_user:
   2351             if run_deluser_remove_home(user, log):
   2352                 print("Rollback: usuário removido.", file=sys.stderr)
   2353             else:
   2354                 print(
   2355                     f"FALHA NO ROLLBACK: revise o usuário {user!r} manualmente.",
   2356                     file=sys.stderr,
   2357                 )
   2358                 return EXIT_INCONSISTENT
   2359         return EXIT_SYSTEM
   2360 
   2361 
   2362 def run() -> NoReturn:
   2363     raise SystemExit(main())
   2364 
   2365 
   2366 if __name__ == "__main__":
   2367     run()