runv-server

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

setup_alt_protocols.py (46312B)


      1 #!/usr/bin/env python3
      2 """
      3 Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club.
      4 
      5 - Gopher: raiz em /var/gopher, espaços de utilizador em ~/public_gopher (gophermap).
      6 - Gemini: DocBase /var/gemini; **bind mount** ``/var/gemini/users/<user>`` <- ``~/public_gemini``
      7   (o Molly Debian recusa symlinks cujo destino fica fora do DocBase).
      8 
      9 Idempotente, dry-run, subprocess sem shell. Executar como root no Debian.
     10 
     11 Versão 0.14 — runv.club
     12 """
     13 
     14 from __future__ import annotations
     15 
     16 import argparse
     17 import grp
     18 import importlib.util
     19 import logging
     20 import shutil
     21 import os
     22 import pwd
     23 import re
     24 import stat
     25 import subprocess
     26 import sys
     27 import time
     28 from datetime import datetime, timezone
     29 from pathlib import Path
     30 from typing import Any, Final
     31 
     32 _SCRIPT_DIR = Path(__file__).resolve().parent
     33 if str(_SCRIPT_DIR) not in sys.path:
     34     sys.path.insert(0, str(_SCRIPT_DIR))
     35 
     36 from admin_guard import ensure_admin_cli
     37 
     38 # constantes
     39 VERSION: Final[str] = "0.14"
     40 
     41 LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live")
     42 LETSENCRYPT_ARCHIVE: Final[Path] = Path("/etc/letsencrypt/archive")
     43 SSL_CERT_GROUP: Final[str] = "ssl-cert"
     44 
     45 DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json")
     46 DEFAULT_HOMES_ROOT: Final[Path] = Path("/home")
     47 DEFAULT_GEMINI_HOSTNAME: Final[str] = "runv.club"
     48 DEFAULT_LE_CERT: Final[Path] = Path("/etc/letsencrypt/live/runv.club/fullchain.pem")
     49 DEFAULT_LE_KEY: Final[Path] = Path("/etc/letsencrypt/live/runv.club/privkey.pem")
     50 
     51 GOPHER_ROOT: Final[Path] = Path("/var/gopher")
     52 GEMINI_ROOT: Final[Path] = Path("/var/gemini")
     53 GEMINI_USERS: Final[Path] = GEMINI_ROOT / "users"
     54 FSTAB_PATH: Final[Path] = Path("/etc/fstab")
     55 
     56 # Linha fstab: <source> <mountpoint> none bind 0 0 (paths sem espaços no 2.º campo)
     57 _GEMINI_BIND_FSTAB_RE: Final[re.Pattern[str]] = re.compile(
     58     r"^(.+)\s+(/var/gemini/users/\S+)\s+none\s+bind\s+0\s+0\s*\Z"
     59 )
     60 
     61 GOPHER_DEFAULT_PATH: Final[Path] = Path("/etc/default/gophernicus")
     62 GOPHER_SYSTEMD_SERVICE: Final[Path] = Path("/lib/systemd/system/gophernicus@.service")
     63 MOLLY_CONF_DIR: Final[Path] = Path("/etc/molly-brown")
     64 MOLLY_INSTANCE: Final[str] = "runv.club"  # molly-brown@runv.club.service
     65 # StateDirectory=molly-brown no unit Debian — systemd cria /var/lib/molly-brown
     66 # com o dono correcto (DynamicUser) antes do ExecStart; evita conflitos com
     67 # LogsDirectory + directório pré-existente em /var/log.
     68 MOLLY_LOG_DIR: Final[Path] = Path("/var/lib/molly-brown")
     69 MOLLY_LOGS_DROPIN_PATH: Final[Path] = Path(
     70     "/etc/systemd/system/molly-brown@.service.d/50-runv-logs.conf"
     71 )
     72 
     73 PACKAGES_GOPHER: Final[tuple[str, ...]] = ("gophernicus",)
     74 PACKAGES_GEMINI: Final[tuple[str, ...]] = ("molly-brown",)
     75 
     76 DEFAULT_USER_GOPHERMAP: Final[str] = """iBem-vindo ao teu espaço Gopher no runv.club.	fake	NULL	0
     77 iEdita este ficheiro em ~/public_gopher/gophermap.	fake	NULL	0
     78 """
     79 
     80 DEFAULT_USER_INDEX_GMI: Final[str] = """# ~{username} — runv.club
     81 
     82 Bem-vindo ao runv.club no **Gemini**. Este é o teu espaço — escreve em `.gmi`, cria subpáginas e liga-as como quiseres.
     83 
     84 `gemini://runv.club/~{username}/`
     85 """
     86 
     87 
     88 # utilitários
     89 def _path_resolved(p: Path) -> Path:
     90     """Resolve o caminho; com symlinks (ex. Let's Encrypt) alinha com o canónico."""
     91     try:
     92         return p.resolve(strict=False)
     93     except TypeError:
     94         return p.resolve()
     95 
     96 
     97 def setup_logging(verbose: bool) -> logging.Logger:
     98     logging.basicConfig(
     99         level=logging.DEBUG if verbose else logging.INFO,
    100         format="%(levelname)s: %(message)s",
    101     )
    102     return logging.getLogger("setup_alt_protocols")
    103 
    104 
    105 def run_cmd(
    106     cmd: list[str],
    107     *,
    108     dry_run: bool,
    109     log: logging.Logger,
    110     timeout: int = 600,
    111 ) -> subprocess.CompletedProcess[str] | None:
    112     log.debug("exec: %s", " ".join(cmd))
    113     if dry_run:
    114         log.info("[dry-run] %s", " ".join(cmd))
    115         return None
    116     return subprocess.run(
    117         cmd,
    118         check=False,
    119         capture_output=True,
    120         text=True,
    121         timeout=timeout,
    122     )
    123 
    124 
    125 def backup_if_exists(path: Path, log: logging.Logger, dry_run: bool) -> None:
    126     if not path.is_file():
    127         return
    128     ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    129     bak = path.with_suffix(path.suffix + f".bak.{ts}")
    130     if dry_run:
    131         log.info("[dry-run] faria backup %s -> %s", path, bak)
    132         return
    133     shutil.copy2(path, bak)
    134     log.info("backup: %s -> %s", path, bak)
    135 
    136 
    137 def infer_gopher_env_key(service_path: Path) -> str:
    138     if not service_path.is_file():
    139         return "OPTIONS"
    140     text = service_path.read_text(encoding="utf-8", errors="replace")
    141     m = re.search(r"ExecStart=.*?\$(\w+)", text, re.MULTILINE | re.DOTALL)
    142     if m:
    143         return m.group(1)
    144     return "OPTIONS"
    145 
    146 
    147 def default_gopher_options(hostname: str) -> str:
    148     return f'-h {hostname} -r {GOPHER_ROOT} -u public_gopher -o UTF-8'
    149 
    150 
    151 def infer_gophernicus_runtime_user(log: logging.Logger) -> str:
    152     """Lê User= do unit gophernicus@.service; fallback ``gophernicus``."""
    153     path = GOPHER_SYSTEMD_SERVICE
    154     if not path.is_file():
    155         log.debug("unit gophernicus inexistente (%s) — fallback User=gophernicus", path)
    156         return "gophernicus"
    157     try:
    158         text = path.read_text(encoding="utf-8", errors="replace")
    159     except OSError as e:
    160         log.debug("ler %s: %s — fallback User=gophernicus", path, e)
    161         return "gophernicus"
    162     m = re.search(r"^User=(\S+)", text, re.MULTILINE)
    163     if not m:
    164         log.debug("User= não encontrado em %s — fallback gophernicus", path)
    165         return "gophernicus"
    166     u = m.group(1)
    167     if u.startswith("%") or "${" in u:
    168         log.debug("User= dinâmico (%s) em %s — fallback gophernicus", u, path)
    169         return "gophernicus"
    170     return u
    171 
    172 
    173 def write_gophernicus_default(
    174     path: Path,
    175     options_value: str,
    176     *,
    177     env_key: str,
    178     dry_run: bool,
    179     log: logging.Logger,
    180     force: bool,
    181 ) -> None:
    182     lines: list[str] = []
    183     if path.is_file() and not force:
    184         raw = path.read_text(encoding="utf-8", errors="replace").splitlines()
    185         replaced = False
    186         opt_re = re.compile(rf"^{re.escape(env_key)}=")
    187         for line in raw:
    188             if opt_re.match(line.strip()):
    189                 lines.append(f'{env_key}="{options_value}"')
    190                 replaced = True
    191             else:
    192                 lines.append(line)
    193         if not replaced:
    194             lines.append(f'{env_key}="{options_value}"')
    195         content = "\n".join(lines).rstrip() + "\n"
    196     else:
    197         content = (
    198             f"# runv.club — gerido por setup_alt_protocols.py\n"
    199             f"# Ver: man gophernicus (8)\n\n"
    200             f'{env_key}="{options_value}"\n'
    201         )
    202     if dry_run:
    203         log.info("[dry-run] gravaria %s (%s=...)", path, env_key)
    204         return
    205     path.parent.mkdir(parents=True, exist_ok=True)
    206     path.write_text(content, encoding="utf-8")
    207     os.chmod(path, 0o644)
    208     log.info("atualizado: %s", path)
    209 
    210 
    211 def molly_log_paths(instance: str) -> tuple[Path, Path]:
    212     """Caminhos de access / error log para a instância (ex. runv.club)."""
    213     return (
    214         MOLLY_LOG_DIR / f"{instance}-access.log",
    215         MOLLY_LOG_DIR / f"{instance}-error.log",
    216     )
    217 
    218 
    219 def retire_molly_brown_logs_dropin(
    220     *,
    221     dry_run: bool,
    222     log: logging.Logger,
    223     force: bool,
    224 ) -> None:
    225     """
    226     Remove 50-runv-logs.conf (LogsDirectory=molly-brown) se existir.
    227 
    228     Esse drop-in + directório /var/log/molly-brown criado antes do arranque faz o
    229     systemd migrar para /var/log/private/ e pode deixar o Molly a falhar. Os
    230     logs passam a usar só StateDirectory em /var/lib/molly-brown.
    231     """
    232     if not MOLLY_LOGS_DROPIN_PATH.is_file():
    233         return
    234     if dry_run:
    235         log.info("[dry-run] removeria drop-in obsoleto: %s", MOLLY_LOGS_DROPIN_PATH)
    236         return
    237     if force:
    238         backup_if_exists(MOLLY_LOGS_DROPIN_PATH, log, dry_run=False)
    239     MOLLY_LOGS_DROPIN_PATH.unlink()
    240     log.info("removido drop-in obsoleto (logs em StateDirectory): %s", MOLLY_LOGS_DROPIN_PATH)
    241 
    242 
    243 def ensure_molly_log_files(
    244     instance: str,
    245     *,
    246     dry_run: bool,
    247     log: logging.Logger,
    248 ) -> tuple[Path, Path]:
    249     """
    250     Devolve caminhos AccessLog/ErrorLog sob StateDirectory (/var/lib/molly-brown).
    251 
    252     Não cria directório nem ficheiros: o unit Debian já define StateDirectory e o
    253     systemd prepara /var/lib/molly-brown antes do ExecStart. Molly-brown não
    254     aceita AccessLog/ErrorLog = \"-\" (interpreta como path /- e falha).
    255     """
    256     access_p, error_p = molly_log_paths(instance)
    257     if dry_run:
    258         log.info(
    259             "[dry-run] AccessLog/ErrorLog seriam %s, %s (StateDirectory systemd)",
    260             access_p,
    261             error_p,
    262         )
    263         return access_p, error_p
    264 
    265     log.info(
    266         "logs Molly (StateDirectory): %s, %s",
    267         access_p,
    268         error_p,
    269     )
    270     return access_p, error_p
    271 
    272 
    273 def molly_brown_conf_text(
    274     *,
    275     hostname: str,
    276     cert: Path,
    277     key: Path,
    278     access_log: Path,
    279     error_log: Path,
    280 ) -> str:
    281     return f"""# runv.club — gerido por setup_alt_protocols.py
    282 Hostname = "{hostname}"
    283 Port = 1965
    284 DocBase = "{GEMINI_ROOT.as_posix()}"
    285 HomeDocBase = "users"
    286 CertPath = "{cert.as_posix()}"
    287 KeyPath = "{key.as_posix()}"
    288 AccessLog = "{access_log.as_posix()}"
    289 ErrorLog = "{error_log.as_posix()}"
    290 GeminiExt = "gmi"
    291 ReadMollyFiles = true
    292 
    293 # Molly Brown (Go): resolvePath usa o *primeiro* segmento após / como ~NOME — ou seja
    294 # path canónico /~username/… (tilde colado ao utilizador). O formato /~/username/
    295 # deixa o nome vazio e devolve 51; redireccionamos /~/… -> /~… antes do Stat.
    296 #
    297 # Conteúdo por utilizador: bind mount (não symlink) de DocBase/users/<user> para
    298 # ~/public_gemini — o pacote Debian recusa symlinks fora do DocBase.
    299 [TempRedirects]
    300 "^/~/([^/]+)(/.*)?$" = "/~$1$2"
    301 """
    302 
    303 
    304 def repo_root() -> Path:
    305     """Raiz do repositório runv-server (scripts/admin/ → …/runv-server)."""
    306     return Path(__file__).resolve().parent.parent.parent
    307 
    308 
    309 def load_patch_irc_module(log: logging.Logger) -> Any:
    310     path = repo_root() / "patches" / "patch_irc.py"
    311     if not path.is_file():
    312         log.error(
    313             "Ficheiro em falta: %s — clone completo do repo ou copie patches/patch_irc.py.",
    314             path,
    315         )
    316         raise FileNotFoundError(str(path))
    317     spec = importlib.util.spec_from_file_location("patch_irc_setup_alt", path)
    318     if spec is None or spec.loader is None:
    319         raise ImportError(f"não foi possível carregar {path}")
    320     mod = importlib.util.module_from_spec(spec)
    321     spec.loader.exec_module(mod)
    322     return mod
    323 
    324 
    325 _IRC_PATCH_SKIP_USERS_CACHE: frozenset[str] | None = None
    326 
    327 
    328 def irc_patch_skip_users(log: logging.Logger) -> frozenset[str]:
    329     """Contas em ``IRC_PATCH_SKIP_USERS`` (sem IRC / sem bind Gemini / fora dos índices raiz)."""
    330     global _IRC_PATCH_SKIP_USERS_CACHE
    331     if _IRC_PATCH_SKIP_USERS_CACHE is None:
    332         _IRC_PATCH_SKIP_USERS_CACHE = load_patch_irc_module(log).IRC_PATCH_SKIP_USERS
    333     return _IRC_PATCH_SKIP_USERS_CACHE
    334 
    335 
    336 def resolve_backfill_users(
    337     users_json: Path,
    338     homes_root: Path,
    339     log: logging.Logger,
    340 ) -> list[str]:
    341     """União users.json + /home, mesma política que patches/patch_irc.py."""
    342     patch_irc = load_patch_irc_module(log)
    343     return patch_irc.resolve_all_users(users_json, homes_root, log)
    344 
    345 
    346 def wait_for_unit_active(
    347     unit: str,
    348     *,
    349     log: logging.Logger,
    350     dry_run: bool,
    351     attempts: int = 5,
    352     delay_s: float = 1.0,
    353 ) -> bool:
    354     if dry_run:
    355         return True
    356     for i in range(attempts):
    357         r = subprocess.run(
    358             ["systemctl", "is-active", unit],
    359             capture_output=True,
    360             text=True,
    361             timeout=30,
    362         )
    363         state = (r.stdout or "").strip()
    364         if state == "active":
    365             log.info("%s: active", unit)
    366             return True
    367         log.debug("%s: %s (tentativa %d/%d)", unit, state or r.returncode, i + 1, attempts)
    368         if i + 1 < attempts:
    369             time.sleep(delay_s)
    370     log.warning(
    371         "%s não ficou «active» após %d tentativas — veja: sudo journalctl -u %s -b --no-pager",
    372         unit,
    373         attempts,
    374         unit,
    375     )
    376     log_systemd_unit_failed_hint(unit, log)
    377     return False
    378 
    379 
    380 def ensure_le_tls_readable_for_molly(
    381     cert_path: Path,
    382     key_path: Path,
    383     *,
    384     dry_run: bool,
    385     log: logging.Logger,
    386 ) -> None:
    387     """
    388     Ajusta /etc/letsencrypt/live, archive, live/<domínio>, archive/<domínio> para 755 e
    389     archive/<domínio>/privkey*.pem para grupo ssl-cert + 640, para o molly-brown ler a chave.
    390 
    391     Usa caminhos lógicos (sem resolver fullchain.pem → archive), porque o symlink típico do
    392     Let's Encrypt fazia falhar a detecção quando se aplicava resolve() ao certificado.
    393     """
    394     cert_p = Path(cert_path)
    395     key_p = Path(key_path)
    396 
    397     try:
    398         cert_rel = cert_p.relative_to(LETSENCRYPT_LIVE)
    399     except ValueError:
    400         log.debug(
    401             "LE TLS: cert_path não está sob %s (%s) — salto",
    402             LETSENCRYPT_LIVE,
    403             cert_p,
    404         )
    405         return
    406 
    407     cparts = cert_rel.parts
    408     if len(cparts) < 2:
    409         log.debug(
    410             "LE TLS: esperado %s/<domínio>/<ficheiro> — salto (%s)",
    411             LETSENCRYPT_LIVE,
    412             cert_p,
    413         )
    414         return
    415     domain = cparts[0]
    416 
    417     try:
    418         key_rel = key_p.relative_to(LETSENCRYPT_LIVE)
    419     except ValueError:
    420         log.debug(
    421             "LE TLS: key_path não está sob %s (%s) — salto",
    422             LETSENCRYPT_LIVE,
    423             key_p,
    424         )
    425         return
    426     if not key_rel.parts or key_rel.parts[0] != domain:
    427         log.debug(
    428             "LE TLS: key_path não está sob %s/%s/ — salto (%s)",
    429             LETSENCRYPT_LIVE,
    430             domain,
    431             key_p,
    432         )
    433         return
    434 
    435     live_domain_dir = LETSENCRYPT_LIVE / domain
    436     archive_domain_dir = LETSENCRYPT_ARCHIVE / domain
    437 
    438     try:
    439         ssl_gid = grp.getgrnam(SSL_CERT_GROUP).gr_gid
    440     except KeyError:
    441         log.warning(
    442             "LE TLS: grupo %r inexistente — não ajusto privkey*.pem (instale openssl/ssl-cert)",
    443             SSL_CERT_GROUP,
    444         )
    445         ssl_gid = None
    446 
    447     dirs_755: list[Path] = [
    448         LETSENCRYPT_LIVE,
    449         LETSENCRYPT_ARCHIVE,
    450         live_domain_dir,
    451     ]
    452     if archive_domain_dir.is_dir():
    453         dirs_755.append(archive_domain_dir)
    454 
    455     for d in dirs_755:
    456         if not d.is_dir():
    457             log.info("LE TLS: omito chmod 755 (não existe): %s", d)
    458             continue
    459         if dry_run:
    460             log.info("[dry-run] chmod 755 %s", d)
    461             continue
    462         try:
    463             before = stat.S_IMODE(os.stat(d).st_mode)
    464             os.chmod(d, 0o755)
    465             if before != 0o755:
    466                 log.info("LE TLS: %s modo %04o -> 0755", d, before)
    467         except OSError as e:
    468             log.warning("LE TLS: chmod %s: %s", d, e)
    469 
    470     if not archive_domain_dir.is_dir():
    471         log.info("LE TLS: %s inexistente — sem privkey*.pem", archive_domain_dir)
    472         return
    473 
    474     privkeys = sorted(archive_domain_dir.glob("privkey*.pem"))
    475     if not privkeys:
    476         log.info("LE TLS: sem privkey*.pem em %s", archive_domain_dir)
    477         return
    478 
    479     if ssl_gid is None:
    480         log.warning("LE TLS: sem grupo ssl-cert — não altero privkey em %s", archive_domain_dir)
    481         return
    482 
    483     for pk in privkeys:
    484         if not pk.is_file():
    485             continue
    486         if dry_run:
    487             log.info("[dry-run] chgrp %s %s && chmod 640 %s", SSL_CERT_GROUP, pk, pk)
    488             continue
    489         try:
    490             st = os.stat(pk)
    491             os.chown(pk, st.st_uid, ssl_gid)
    492             before_m = stat.S_IMODE(st.st_mode)
    493             os.chmod(pk, 0o640)
    494             if before_m != 0o640:
    495                 log.info("LE TLS: %s modo %04o -> 0640, grupo %s", pk, before_m, SSL_CERT_GROUP)
    496         except OSError as e:
    497             log.warning("LE TLS: ajuste %s: %s", pk, e)
    498 
    499 
    500 def ensure_user_public_dirs(
    501     username: str,
    502     homes_root: Path,
    503     *,
    504     force: bool,
    505     dry_run: bool,
    506     log: logging.Logger,
    507 ) -> None:
    508     try:
    509         pw = pwd.getpwnam(username)
    510     except KeyError:
    511         log.warning("utilizador %s não existe no sistema — salto backfill", username)
    512         return
    513     home = Path(pw.pw_dir)
    514     uid, gid = pw.pw_uid, pw.pw_gid
    515     gdir = home / "public_gopher"
    516     gmap = gdir / "gophermap"
    517     xdir = home / "public_gemini"
    518     xidx = xdir / "index.gmi"
    519 
    520     if dry_run:
    521         log.info("[dry-run] garantiria ~/public_gopher e ~/public_gemini para %s", username)
    522         if home.is_dir():
    523             try:
    524                 cur = stat.S_IMODE(os.stat(home).st_mode)
    525             except OSError as e:
    526                 log.debug("[dry-run] stat home %s: %s", home, e)
    527             else:
    528                 if cur != 0o755:
    529                     log.info("[dry-run] chmod 755 %s (era %04o)", home, cur)
    530         return
    531 
    532     gdir.mkdir(parents=True, exist_ok=True)
    533     xdir.mkdir(parents=True, exist_ok=True)
    534     os.chmod(gdir, 0o755)
    535     os.chmod(xdir, 0o755)
    536     os.chown(gdir, uid, gid)
    537     os.chown(xdir, uid, gid)
    538 
    539     if not gmap.exists() or force:
    540         if gmap.exists() and force:
    541             backup_if_exists(gmap, log, dry_run=False)
    542         gmap.write_text(DEFAULT_USER_GOPHERMAP, encoding="utf-8")
    543         os.chmod(gmap, 0o644)
    544         os.chown(gmap, uid, gid)
    545         log.info("gophermap: %s", gmap)
    546     else:
    547         log.debug("gophermap já existe, mantido: %s", gmap)
    548 
    549     # index.gmi: nunca sobrescrever se já existir (--force não aplica ao modelo Gemini).
    550     if not xidx.exists():
    551         xidx.write_text(
    552             DEFAULT_USER_INDEX_GMI.format(username=username),
    553             encoding="utf-8",
    554         )
    555         os.chmod(xidx, 0o644)
    556         os.chown(xidx, uid, gid)
    557         log.info("index.gmi: %s", xidx)
    558     else:
    559         log.debug("index.gmi já existe, mantido: %s", xidx)
    560 
    561     if home.is_dir():
    562         try:
    563             cur = stat.S_IMODE(os.stat(home).st_mode)
    564         except OSError as e:
    565             log.warning("stat home %s: %s", home, e)
    566         else:
    567             if cur != 0o755:
    568                 os.chmod(home, 0o755)
    569                 log.info("home %s: modo %04o -> 0755 (atravessável por serviços)", home, cur)
    570 
    571 
    572 def _escape_fstab_path(s: str) -> str:
    573     return s.replace(" ", "\\040")
    574 
    575 
    576 def _unescape_fstab_path(s: str) -> str:
    577     return s.replace("\\040", " ")
    578 
    579 
    580 def _is_dir_mountpoint(path: Path) -> bool:
    581     r = subprocess.run(
    582         ["mountpoint", "-q", str(path)],
    583         capture_output=True,
    584         timeout=30,
    585     )
    586     return r.returncode == 0
    587 
    588 
    589 def _bind_mount_source_resolved(mountpoint: Path) -> Path | None:
    590     r = subprocess.run(
    591         ["findmnt", "-n", "-o", "SOURCE", "--target", str(mountpoint)],
    592         capture_output=True,
    593         text=True,
    594         timeout=30,
    595     )
    596     if r.returncode != 0:
    597         return None
    598     raw = (r.stdout or "").strip()
    599     if not raw:
    600         return None
    601     src = raw.split()[0]
    602     if src.startswith("[") and "]" in src:
    603         src = src[1 : src.index("]")]
    604     try:
    605         return Path(src).resolve(strict=False)
    606     except OSError:
    607         return Path(src)
    608 
    609 
    610 def _ensure_gemini_fstab_line(
    611     source: Path,
    612     mountpoint: Path,
    613     *,
    614     dry_run: bool,
    615     log: logging.Logger,
    616 ) -> None:
    617     src_s = str(_path_resolved(source))
    618     mp_s = str(_path_resolved(mountpoint))
    619     desired_line = f"{_escape_fstab_path(src_s)} {_escape_fstab_path(mp_s)} none bind 0 0\n"
    620     if dry_run:
    621         log.info("[dry-run] fstab (se necessário): %s", desired_line.rstrip())
    622         return
    623     if not FSTAB_PATH.is_file():
    624         log.warning("%s inexistente — bind não persistido após reboot", FSTAB_PATH)
    625         return
    626     try:
    627         text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace")
    628     except OSError as e:
    629         log.warning("ler fstab: %s", e)
    630         return
    631     mp_path = mountpoint
    632     src_res = Path(src_s).resolve()
    633     kept: list[str] = []
    634     found_exact = False
    635     for line in text.splitlines(keepends=True):
    636         stripped = line.strip()
    637         if stripped.startswith("#") or not stripped:
    638             kept.append(line)
    639             continue
    640         m = _GEMINI_BIND_FSTAB_RE.match(stripped)
    641         if not m:
    642             kept.append(line)
    643             continue
    644         f2 = Path(_unescape_fstab_path(m.group(2)))
    645         if f2 != mp_path:
    646             kept.append(line)
    647             continue
    648         f1 = Path(_unescape_fstab_path(m.group(1))).resolve()
    649         if f1 == src_res:
    650             if not found_exact:
    651                 found_exact = True
    652                 kept.append(line)
    653             else:
    654                 log.debug("fstab: duplicado ignorado para %s", mountpoint)
    655         else:
    656             log.debug("fstab: removida linha antiga para %s (origem diferente)", mountpoint)
    657     if not found_exact:
    658         if kept and not kept[-1].endswith("\n"):
    659             kept[-1] += "\n"
    660         kept.append(desired_line)
    661     new_content = "".join(kept)
    662     if new_content == text:
    663         log.debug("fstab inalterado para %s", mountpoint)
    664         return
    665     backup_if_exists(FSTAB_PATH, log, dry_run=False)
    666     FSTAB_PATH.write_text(new_content, encoding="utf-8")
    667     log.info("fstab: bind persistido %s -> %s", src_s, mp_s)
    668 
    669 
    670 def _remove_gemini_fstab_lines_for_mountpoint(mountpoint: Path, log: logging.Logger) -> None:
    671     """Remove todas as linhas ``bind`` do fstab cujo segundo campo é ``mountpoint``."""
    672     if not FSTAB_PATH.is_file():
    673         return
    674     try:
    675         text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace")
    676     except OSError as e:
    677         log.warning("ler fstab: %s", e)
    678         return
    679     new_lines: list[str] = []
    680     removed = False
    681     for line in text.splitlines(keepends=True):
    682         stripped = line.strip()
    683         if stripped.startswith("#") or not stripped:
    684             new_lines.append(line)
    685             continue
    686         m = _GEMINI_BIND_FSTAB_RE.match(stripped)
    687         if m and Path(_unescape_fstab_path(m.group(2))) == mountpoint:
    688             removed = True
    689             continue
    690         new_lines.append(line)
    691     if not removed:
    692         return
    693     new_content = "".join(new_lines)
    694     backup_if_exists(FSTAB_PATH, log, dry_run=False)
    695     FSTAB_PATH.write_text(new_content, encoding="utf-8")
    696     log.info("fstab: removida(s) linha(s) bind para %s", mountpoint)
    697 
    698 
    699 def remove_gemini_bind_mount(
    700     username: str,
    701     *,
    702     dry_run: bool,
    703     log: logging.Logger,
    704 ) -> None:
    705     """Desmonta ``/var/gemini/users/<user>``, limpa fstab, symlink ou directório vazio."""
    706     mountpoint = GEMINI_USERS / username
    707     if dry_run:
    708         log.info("[dry-run] removeria bind Gemini / fstab em %s", mountpoint)
    709         return
    710     if _is_dir_mountpoint(mountpoint):
    711         ru = run_cmd(["umount", str(mountpoint)], dry_run=False, log=log)
    712         if ru is not None and ru.returncode != 0:
    713             log.warning(
    714                 "umount %s: %s",
    715                 mountpoint,
    716                 (ru.stderr or ru.stdout or "").strip(),
    717             )
    718     _remove_gemini_fstab_lines_for_mountpoint(mountpoint, log)
    719     if mountpoint.is_symlink():
    720         try:
    721             mountpoint.unlink()
    722             log.info("symlink Gemini removido: %s", mountpoint)
    723         except OSError as e:
    724             log.warning("unlink %s: %s", mountpoint, e)
    725     if mountpoint.is_dir():
    726         try:
    727             if not any(mountpoint.iterdir()):
    728                 mountpoint.rmdir()
    729                 log.info("directório Gemini vazio removido: %s", mountpoint)
    730         except OSError as e:
    731             log.warning("%s: %s", mountpoint, e)
    732 
    733 
    734 def build_root_gophermap_text(
    735     hostname: str,
    736     homes_root: Path,
    737     users: list[str],
    738 ) -> str:
    739     """Menu raiz com links ``1~user`` só para contas com ``~/public_gopher`` (exclui IRC_PATCH_SKIP)."""
    740     tab = "\t"
    741     lines: list[str] = [
    742         "!runv.club — Gopher",
    743         f"iBem-vindo ao Gopher em {hostname} — pubnix.{tab}fake{tab}NULL{tab}0",
    744         f"iMembros com espaço público (selector ~utilizador/).{tab}fake{tab}NULL{tab}0",
    745         "#",
    746     ]
    747     for u in sorted(users):
    748         if not (homes_root / u / "public_gopher").is_dir():
    749             continue
    750         lines.append(f"1~{u}{tab}~{u}/{tab}{hostname}{tab}70")
    751     return "\n".join(lines) + "\n"
    752 
    753 
    754 def build_root_gemini_index_gmi(
    755     hostname: str,
    756     homes_root: Path,
    757     users: list[str],
    758 ) -> str:
    759     """Índice Gemtext na raiz do DocBase; mesmos membros que no menu Gopher raiz."""
    760     lines: list[str] = [
    761         f"# {hostname} — Gemini",
    762         "",
    763         f"Bem-vindo ao **Gemini** do **{hostname}**.",
    764         "",
    765         "## Capsules dos membros",
    766         "",
    767     ]
    768     for u in sorted(users):
    769         if not (homes_root / u / "public_gopher").is_dir():
    770             continue
    771         lines.append(f"=> gemini://{hostname}/~{u}/ Capsule ~{u}")
    772     lines.append("")
    773     return "\n".join(lines)
    774 
    775 
    776 def ensure_gemini_bind_mount(
    777     username: str,
    778     homes_root: Path,
    779     *,
    780     force: bool,
    781     dry_run: bool,
    782     log: logging.Logger,
    783 ) -> None:
    784     """
    785     Expõe ~/public_gemini em /var/gemini/users/<user> com mount --bind + fstab.
    786     O Molly Debian recusa symlinks cujo destino fica fora de DocBase (/var/gemini).
    787     Contas em IRC_PATCH_SKIP_USERS não recebem bind; com force remove-se mount/fstab.
    788     """
    789     _ = homes_root  # API compatível com o backfill (getpwnam fornece a home)
    790     try:
    791         pw = pwd.getpwnam(username)
    792     except KeyError:
    793         return
    794 
    795     sk = irc_patch_skip_users(log)
    796     if username in sk:
    797         if dry_run:
    798             log.info("[dry-run] %s em IRC_PATCH_SKIP_USERS — bind Gemini omitido", username)
    799             return
    800         if force:
    801             remove_gemini_bind_mount(username, dry_run=False, log=log)
    802         else:
    803             mp = GEMINI_USERS / username
    804             if _is_dir_mountpoint(mp) or mp.is_symlink():
    805                 log.warning(
    806                     "%s está em IRC_PATCH_SKIP_USERS mas há mount ou symlink em %s — "
    807                     "use --force para remover",
    808                     username,
    809                     mp,
    810                 )
    811             else:
    812                 log.debug("skip bind Gemini (IRC_PATCH_SKIP_USERS): %s", username)
    813         return
    814 
    815     home = Path(pw.pw_dir)
    816     target = home / "public_gemini"
    817     if not target.is_dir():
    818         log.debug("public_gemini inexistente para %s — bind não aplicado", username)
    819         return
    820     target_resolved = _path_resolved(target)
    821     mountpoint = GEMINI_USERS / username
    822 
    823     if not GEMINI_USERS.is_dir():
    824         log.warning("GEMINI_USERS inexistente: %s — bind não aplicado", GEMINI_USERS)
    825         return
    826 
    827     if dry_run:
    828         log.info("[dry-run] mount --bind %s %s + fstab", target_resolved, mountpoint)
    829         _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=True, log=log)
    830         return
    831 
    832     if mountpoint.is_symlink():
    833         if not force:
    834             log.warning(
    835                 "symlink %s -> %s: Molly Debian recusa symlinks fora do DocBase; "
    836                 "corra com --force para substituir por bind mount",
    837                 mountpoint,
    838                 mountpoint.resolve(),
    839             )
    840             return
    841         mountpoint.unlink()
    842         log.info("symlink removido (migração bind): %s", mountpoint)
    843 
    844     if mountpoint.exists() and mountpoint.is_file():
    845         log.warning("%s é ficheiro; não aplico bind", mountpoint)
    846         return
    847 
    848     if _is_dir_mountpoint(mountpoint):
    849         src_now = _bind_mount_source_resolved(mountpoint)
    850         if src_now == target_resolved:
    851             log.debug("bind mount OK: %s <- %s", mountpoint, target_resolved)
    852             _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=False, log=log)
    853             return
    854         if not force:
    855             log.warning(
    856                 "mountpoint %s montado de %s; esperado %s — use --force",
    857                 mountpoint,
    858                 src_now,
    859                 target_resolved,
    860             )
    861             return
    862         ru = run_cmd(["umount", str(mountpoint)], dry_run=False, log=log)
    863         if ru is not None and ru.returncode != 0:
    864             log.error(
    865                 "umount %s falhou: %s",
    866                 mountpoint,
    867                 (ru.stderr or ru.stdout or "").strip(),
    868             )
    869             return
    870         log.info("umount antes de remount: %s", mountpoint)
    871 
    872     if mountpoint.exists() and mountpoint.is_dir():
    873         try:
    874             if any(mountpoint.iterdir()):
    875                 log.warning(
    876                     "%s é directório com conteúdo (não é mountpoint); não aplico bind",
    877                     mountpoint,
    878                 )
    879                 return
    880         except OSError as e:
    881             log.warning("listar %s: %s", mountpoint, e)
    882             return
    883 
    884     mountpoint.mkdir(parents=True, exist_ok=True)
    885     os.chmod(mountpoint, 0o755)
    886     try:
    887         os.chown(mountpoint, 0, 0)
    888     except OSError as e:
    889         log.warning("chown %s: %s", mountpoint, e)
    890 
    891     rm = run_cmd(
    892         ["mount", "--bind", str(target_resolved), str(mountpoint)],
    893         dry_run=False,
    894         log=log,
    895     )
    896     if rm is None or rm.returncode != 0:
    897         log.error(
    898             "mount --bind falhou: %s -> %s (%s)",
    899             target_resolved,
    900             mountpoint,
    901             (rm.stderr or rm.stdout or "").strip() if rm else "",
    902         )
    903         return
    904     log.info("bind mount: %s -> %s", target_resolved, mountpoint)
    905     _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=False, log=log)
    906 
    907 
    908 # Alias legado (patches/yetgg.py e referências antigas)
    909 ensure_gemini_symlink = ensure_gemini_bind_mount
    910 
    911 
    912 def apt_install(
    913     packages: tuple[str, ...],
    914     *,
    915     dry_run: bool,
    916     log: logging.Logger,
    917 ) -> bool:
    918     env = {"DEBIAN_FRONTEND": "noninteractive", "LC_ALL": "C"}
    919     r1 = run_cmd(["apt-get", "update", "-qq"], dry_run=dry_run, log=log)
    920     if not dry_run and r1 is not None and r1.returncode != 0:
    921         log.error("apt-get update falhou: %s", (r1.stderr or r1.stdout or "").strip())
    922         return False
    923     cmd = ["apt-get", "install", "-y", "--no-install-recommends", *packages]
    924     r2 = run_cmd(cmd, dry_run=dry_run, log=log)
    925     if dry_run:
    926         return True
    927     if r2 is None or r2.returncode != 0:
    928         log.error("apt-get install falhou: %s", (r2.stderr or r2.stdout or "").strip() if r2 else "")
    929         return False
    930     return True
    931 
    932 
    933 def log_ufw_suggested_commands(log: logging.Logger) -> None:
    934     """Comandos para copiar quando o script não aplicou regras UFW automaticamente."""
    935     log.info(
    936         "Se usar UFW, depois de «sudo ufw enable» (se ainda não estiver activo), execute:\n"
    937         "  sudo ufw allow 70/tcp comment 'gopher'\n"
    938         "  sudo ufw allow 1965/tcp comment 'gemini'\n"
    939         "  sudo ufw reload"
    940     )
    941 
    942 
    943 def log_systemd_unit_failed_hint(unit: str, log: logging.Logger) -> None:
    944     """Se o unit estiver em estado failed, regista ERROR com ponteiro para journalctl."""
    945     r = subprocess.run(
    946         ["systemctl", "is-failed", unit],
    947         capture_output=True,
    948         text=True,
    949         timeout=30,
    950     )
    951     if r.returncode != 0:
    952         return
    953     log.error(
    954         "%s está em estado «failed» — diagnóstico: sudo journalctl -u %s -b --no-pager -n 80",
    955         unit,
    956         unit,
    957     )
    958 
    959 
    960 def dpkg_installed(package: str) -> bool:
    961     r = subprocess.run(
    962         ["dpkg", "-s", package],
    963         capture_output=True,
    964         text=True,
    965         timeout=30,
    966     )
    967     return r.returncode == 0 and "Status: install ok installed" in (r.stdout or "")
    968 
    969 
    970 def ufw_maybe_allow(
    971     ports: list[tuple[int, str]],
    972     *,
    973     dry_run: bool,
    974     log: logging.Logger,
    975     skip_firewall: bool,
    976 ) -> None:
    977     if skip_firewall:
    978         log.info("firewall ignorado (--skip-firewall)")
    979         log_ufw_suggested_commands(log)
    980         return
    981     r = subprocess.run(
    982         ["ufw", "status"],
    983         capture_output=True,
    984         text=True,
    985         timeout=30,
    986     )
    987     out = (r.stdout or "").lower()
    988     if r.returncode != 0 or "status: active" not in out:
    989         log.warning(
    990             "UFW não está ativo (ou comando falhou). Não abro portas automaticamente. "
    991             "Abra 70/tcp (Gopher) e 1965/tcp (Gemini) se usar firewall."
    992         )
    993         log_ufw_suggested_commands(log)
    994         return
    995     for port, label in ports:
    996         cmd = ["ufw", "allow", f"{port}/tcp"]
    997         run_cmd(cmd, dry_run=dry_run, log=log)
    998         log.info("UFW: permitido %s/tcp (%s)", port, label)
    999 
   1000 
   1001 def _runuser_can_read(
   1002     path: Path,
   1003     run_as: str,
   1004     *,
   1005     dry_run: bool,
   1006     log: logging.Logger,
   1007 ) -> bool | None:
   1008     """None = skip (sem runuser), True/False = resultado de ``test -r`` como *run_as*."""
   1009     if dry_run:
   1010         if shutil.which("runuser"):
   1011             log.info("[dry-run] runuser -u %s -- test -r %s", run_as, path)
   1012         else:
   1013             log.info("[dry-run] (runuser ausente) test -r como %s em %s", run_as, path)
   1014         return None
   1015     if not shutil.which("runuser"):
   1016         log.debug("validação runuser: binário não encontrado — salto test -r")
   1017         return None
   1018     r = subprocess.run(
   1019         ["runuser", "-u", run_as, "--", "test", "-r", str(path)],
   1020         capture_output=True,
   1021         text=True,
   1022         timeout=30,
   1023     )
   1024     return r.returncode == 0
   1025 
   1026 
   1027 def _www_data_can_read(path: Path, *, dry_run: bool, log: logging.Logger) -> bool | None:
   1028     """Heurística de leitura como ``www-data`` (ACL POSIX pode afectar o UID real do Molly)."""
   1029     return _runuser_can_read(path, "www-data", dry_run=dry_run, log=log)
   1030 
   1031 
   1032 def validate_final(
   1033     usernames: list[str],
   1034     log: logging.Logger,
   1035     *,
   1036     dry_run: bool = False,
   1037 ) -> None:
   1038     log.info("--- validação final ---")
   1039     for pkg, label in (("gophernicus", "Gopher"), ("molly-brown", "Gemini")):
   1040         ok = dpkg_installed(pkg)
   1041         log.info("pacote %s (%s): %s", pkg, label, "OK" if ok else "AUSENTE")
   1042 
   1043     r = subprocess.run(
   1044         ["systemctl", "is-active", "gophernicus.socket"],
   1045         capture_output=True,
   1046         text=True,
   1047         timeout=30,
   1048     )
   1049     gopher_state = (r.stdout or "").strip()
   1050     log.info("gophernicus.socket: %s", gopher_state or r.returncode)
   1051 
   1052     molly_unit = f"molly-brown@{MOLLY_INSTANCE}.service"
   1053     r2 = subprocess.run(
   1054         ["systemctl", "is-active", molly_unit],
   1055         capture_output=True,
   1056         text=True,
   1057         timeout=30,
   1058     )
   1059     molly_state = (r2.stdout or "").strip() or str(r2.returncode)
   1060     log.info("molly-brown@%s: %s", MOLLY_INSTANCE, molly_state)
   1061     if molly_state != "active":
   1062         log.warning(
   1063             "molly-brown não está «active» (estado reportado: %s). "
   1064             "«activating» durante o script não significa sucesso — confirme com "
   1065             "«systemctl is-active %s» e «sudo ss -tlnp | grep 1965».",
   1066             molly_state,
   1067             molly_unit,
   1068         )
   1069         log_systemd_unit_failed_hint(molly_unit, log)
   1070 
   1071     if usernames:
   1072         sk = irc_patch_skip_users(log)
   1073         visible = [u for u in usernames if u not in sk]
   1074         sample = visible[0] if visible else usernames[0]
   1075         try:
   1076             pw = pwd.getpwnam(sample)
   1077             home = Path(pw.pw_dir)
   1078             for p, label in (
   1079                 (home / "public_gopher" / "gophermap", "gophermap"),
   1080                 (home / "public_gemini" / "index.gmi", "index.gmi"),
   1081             ):
   1082                 log.info("amostra %s %s: %s", sample, label, "OK" if p.is_file() else "FALTA")
   1083             mp = GEMINI_USERS / sample
   1084             pg = (home / "public_gemini").resolve()
   1085             ok_mount = False
   1086             if _is_dir_mountpoint(mp):
   1087                 src = _bind_mount_source_resolved(mp)
   1088                 ok_mount = src is not None and src == pg
   1089             elif mp.is_symlink():
   1090                 log.warning(
   1091                     "amostra %s: %s ainda é symlink (Molly Debian rejeita); "
   1092                     "corra setup_alt_protocols com --force para bind mount",
   1093                     sample,
   1094                     mp,
   1095                 )
   1096             log.info("amostra mount Gemini: %s", "OK" if ok_mount else "FALTA/INCORRETO")
   1097             gophermap = home / "public_gopher" / "gophermap"
   1098             if gopher_state == "active" and gophermap.is_file():
   1099                 guser = infer_gophernicus_runtime_user(log)
   1100                 gcan = _runuser_can_read(gophermap, guser, dry_run=dry_run, log=log)
   1101                 if gcan is False:
   1102                     log.warning(
   1103                         "amostra %s: utilizador %s (gophernicus) não consegue ler %s "
   1104                         "(runuser … test -r falhou). Confirme home 755 (ou o+x), "
   1105                         "public_gopher 755, gophermap 644.",
   1106                         sample,
   1107                         guser,
   1108                         gophermap,
   1109                     )
   1110                 elif gcan is True:
   1111                     log.info(
   1112                         "amostra %s: gophermap legível pelo utilizador do serviço (%s, test -r): OK",
   1113                         sample,
   1114                         guser,
   1115                     )
   1116             index_gmi = home / "public_gemini" / "index.gmi"
   1117             if molly_state == "active" and index_gmi.is_file():
   1118                 can = _www_data_can_read(index_gmi, dry_run=dry_run, log=log)
   1119                 if can is False:
   1120                     log.warning(
   1121                         "amostra %s: www-data não consegue ler %s (runuser … test -r falhou). "
   1122                         "Confirme home 755 (ou o+x), public_gemini 755, index.gmi 644, bind %s; "
   1123                         "se `ls -l` mostrar +, veja getfacl no path (ACL).",
   1124                         sample,
   1125                         index_gmi,
   1126                         mp,
   1127                     )
   1128                 elif can is True:
   1129                     log.info("amostra %s: index.gmi legível por www-data (test -r): OK", sample)
   1130         except KeyError:
   1131             log.info("amostra %s: utilizador não existe neste sistema", sample)
   1132 
   1133 
   1134 def parse_args(argv: list[str] | None) -> argparse.Namespace:
   1135     p = argparse.ArgumentParser(
   1136         description="Instala/configura Gopher (gophernicus) e Gemini (molly-brown) para runv.club.",
   1137     )
   1138     p.add_argument("--dry-run", action="store_true")
   1139     p.add_argument("--verbose", action="store_true")
   1140     p.add_argument(
   1141         "--force",
   1142         action="store_true",
   1143         help="sobrescreve configs e ficheiros modelo com backup (index.gmi existente nunca é substituído)",
   1144     )
   1145     p.add_argument("--skip-install", action="store_true")
   1146     p.add_argument("--skip-gopher", action="store_true")
   1147     p.add_argument("--skip-gemini", action="store_true")
   1148     p.add_argument("--skip-firewall", action="store_true")
   1149     p.add_argument("--skip-backfill", action="store_true")
   1150     p.add_argument("--skip-services", action="store_true")
   1151     p.add_argument("--skip-system-config", action="store_true")
   1152     p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON)
   1153     p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT)
   1154     p.add_argument("--gemini-hostname", default=DEFAULT_GEMINI_HOSTNAME)
   1155     p.add_argument("--gemini-cert", type=Path, default=None)
   1156     p.add_argument("--gemini-key", type=Path, default=None)
   1157     p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
   1158     return p.parse_args(argv)
   1159 
   1160 
   1161 def main(argv: list[str] | None = None) -> int:
   1162     args = parse_args(argv)
   1163     ensure_admin_cli(
   1164         script_name=Path(__file__).name,
   1165         dry_run=bool(args.dry_run),
   1166     )
   1167     log = setup_logging(args.verbose)
   1168 
   1169     if os.geteuid() != 0 and not args.dry_run:
   1170         log.error("Execute como root (sudo).")
   1171         return 1
   1172 
   1173     cert = args.gemini_cert or DEFAULT_LE_CERT
   1174     key = args.gemini_key or DEFAULT_LE_KEY
   1175 
   1176     try:
   1177         backfill_users = resolve_backfill_users(args.users_json, args.homes_root, log)
   1178     except (FileNotFoundError, ImportError) as e:
   1179         log.error("%s", e)
   1180         return 1
   1181 
   1182     if not args.skip_gemini:
   1183         ensure_le_tls_readable_for_molly(cert, key, dry_run=args.dry_run, log=log)
   1184 
   1185     pkgs: list[str] = []
   1186     if not args.skip_install:
   1187         if not args.skip_gopher:
   1188             pkgs.extend(PACKAGES_GOPHER)
   1189         if not args.skip_gemini:
   1190             pkgs.extend(PACKAGES_GEMINI)
   1191         pkgs = sorted(set(pkgs))
   1192         if pkgs:
   1193             log.info("instalação apt: %s", ", ".join(pkgs))
   1194             if not apt_install(tuple(pkgs), dry_run=args.dry_run, log=log):
   1195                 return 1
   1196 
   1197     if not args.skip_system_config:
   1198         env_key = infer_gopher_env_key(GOPHER_SYSTEMD_SERVICE)
   1199         opts = default_gopher_options(args.gemini_hostname)
   1200 
   1201         if not args.skip_gopher:
   1202             if args.force and GOPHER_DEFAULT_PATH.is_file():
   1203                 backup_if_exists(GOPHER_DEFAULT_PATH, log, args.dry_run)
   1204             write_gophernicus_default(
   1205                 GOPHER_DEFAULT_PATH,
   1206                 opts,
   1207                 env_key=env_key,
   1208                 dry_run=args.dry_run,
   1209                 log=log,
   1210                 force=args.force,
   1211             )
   1212             if not args.dry_run:
   1213                 GOPHER_ROOT.mkdir(parents=True, exist_ok=True)
   1214                 os.chmod(GOPHER_ROOT, 0o755)
   1215                 root_map = GOPHER_ROOT / "gophermap"
   1216                 gmap_body = build_root_gophermap_text(
   1217                     args.gemini_hostname,
   1218                     args.homes_root,
   1219                     backfill_users,
   1220                 )
   1221                 if not root_map.exists() or args.force:
   1222                     if root_map.exists() and args.force:
   1223                         backup_if_exists(root_map, log, dry_run=False)
   1224                     root_map.write_text(gmap_body, encoding="utf-8")
   1225                     os.chmod(root_map, 0o644)
   1226                     n_menu = sum(1 for ln in gmap_body.splitlines() if ln.startswith("1~"))
   1227                     log.info("gophermap raiz: %s (%d entradas ~user)", root_map, n_menu)
   1228 
   1229         if not args.dry_run:
   1230             GEMINI_ROOT.mkdir(parents=True, exist_ok=True)
   1231             GEMINI_USERS.mkdir(parents=True, exist_ok=True)
   1232             os.chmod(GEMINI_ROOT, 0o755)
   1233             os.chmod(GEMINI_USERS, 0o755)
   1234             try:
   1235                 os.chown(GEMINI_ROOT, 0, 0)
   1236                 os.chown(GEMINI_USERS, 0, 0)
   1237             except OSError as e:
   1238                 log.warning("chown /var/gemini: %s", e)
   1239 
   1240             if not args.skip_gemini:
   1241                 gemi_root = GEMINI_ROOT / "index.gmi"
   1242                 gemi_body = build_root_gemini_index_gmi(
   1243                     args.gemini_hostname,
   1244                     args.homes_root,
   1245                     backfill_users,
   1246                 )
   1247                 if not gemi_root.exists() or args.force:
   1248                     if gemi_root.exists() and args.force:
   1249                         backup_if_exists(gemi_root, log, dry_run=False)
   1250                     gemi_root.write_text(gemi_body, encoding="utf-8")
   1251                     os.chmod(gemi_root, 0o644)
   1252                     try:
   1253                         os.chown(gemi_root, 0, 0)
   1254                     except OSError as e:
   1255                         log.warning("chown %s: %s", gemi_root, e)
   1256                     log.info("index.gmi DocBase raiz: %s", gemi_root)
   1257 
   1258         if not args.skip_gemini:
   1259             if not cert.is_file() or not key.is_file():
   1260                 log.error(
   1261                     "Certificado ou chave TLS inexistentes (Gemini/molly-brown). "
   1262                     "cert=%s key=%s — defina --gemini-cert / --gemini-key ou instale Let's Encrypt. "
   1263                     "Pastas /var/gemini foram criadas; serviço Gemini não será ativado.",
   1264                     cert,
   1265                     key,
   1266                 )
   1267             else:
   1268                 retire_molly_brown_logs_dropin(
   1269                     dry_run=args.dry_run,
   1270                     log=log,
   1271                     force=args.force,
   1272                 )
   1273                 access_p, error_p = ensure_molly_log_files(
   1274                     MOLLY_INSTANCE,
   1275                     dry_run=args.dry_run,
   1276                     log=log,
   1277                 )
   1278                 conf_path = MOLLY_CONF_DIR / f"{MOLLY_INSTANCE}.conf"
   1279                 body = molly_brown_conf_text(
   1280                     hostname=args.gemini_hostname,
   1281                     cert=cert,
   1282                     key=key,
   1283                     access_log=access_p,
   1284                     error_log=error_p,
   1285                 )
   1286                 if args.dry_run:
   1287                     log.info("[dry-run] gravaria %s", conf_path)
   1288                 else:
   1289                     MOLLY_CONF_DIR.mkdir(parents=True, exist_ok=True)
   1290                     if conf_path.is_file() and args.force:
   1291                         backup_if_exists(conf_path, log, dry_run=False)
   1292                     if not conf_path.is_file() or args.force:
   1293                         conf_path.write_text(body, encoding="utf-8")
   1294                         os.chmod(conf_path, 0o644)
   1295                         log.info("molly-brown: %s", conf_path)
   1296 
   1297     ufw_maybe_allow(
   1298         [(70, "gopher"), (1965, "gemini")],
   1299         dry_run=args.dry_run,
   1300         log=log,
   1301         skip_firewall=args.skip_firewall,
   1302     )
   1303 
   1304     if not args.skip_backfill:
   1305         for u in backfill_users:
   1306             ensure_user_public_dirs(
   1307                 u,
   1308                 args.homes_root,
   1309                 force=args.force,
   1310                 dry_run=args.dry_run,
   1311                 log=log,
   1312             )
   1313             ensure_gemini_bind_mount(
   1314                 u,
   1315                 args.homes_root,
   1316                 force=args.force,
   1317                 dry_run=args.dry_run,
   1318                 log=log,
   1319             )
   1320 
   1321     if not args.skip_services:
   1322         if not args.dry_run:
   1323             run_cmd(["systemctl", "daemon-reload"], dry_run=False, log=log)
   1324         if not args.skip_gopher:
   1325             run_cmd(
   1326                 ["systemctl", "enable", "--now", "gophernicus.socket"],
   1327                 dry_run=args.dry_run,
   1328                 log=log,
   1329             )
   1330         if not args.skip_gemini and cert.is_file() and key.is_file():
   1331             molly_unit = f"molly-brown@{MOLLY_INSTANCE}.service"
   1332             run_cmd(
   1333                 ["systemctl", "enable", "--now", molly_unit],
   1334                 dry_run=args.dry_run,
   1335                 log=log,
   1336             )
   1337             wait_for_unit_active(
   1338                 molly_unit,
   1339                 log=log,
   1340                 dry_run=args.dry_run,
   1341                 attempts=15,
   1342                 delay_s=1.0,
   1343             )
   1344 
   1345     validate_final(backfill_users, log, dry_run=args.dry_run)
   1346     log.info("Concluído.")
   1347     return 0
   1348 
   1349 
   1350 if __name__ == "__main__":
   1351     raise SystemExit(main())