runv-server

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

commit 969e97c171b8d09d307e95071d89988642d53c18
parent cf5d39610e70f08e25a1ba7f24237a6c870bda73
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 16:12:26 -0300

fix(admin): Resolve LE paths for symlinks and validate Gemini as www-data

setup_alt_protocols v0.08: _path_resolved(strict=False) for live/archive and cert; ensure_le uses live_root/archive_root; validate_final runs runuser www-data test -r on sample index.gmi when molly is active. create_runv_user: document full HTTP/Gopher/Gemini permission contract at creation. alt_protocols: LE symlinks, www-data check, creation vs backfill.

Made-with: Cursor

Diffstat:
Mscripts/admin/create_runv_user.py | 10++++++++++
Mscripts/admin/setup_alt_protocols.py | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mscripts/docs/alt_protocols.md | 17+++++++++++------
3 files changed, 88 insertions(+), 18 deletions(-)

diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -22,6 +22,14 @@ Quota ext4, metadados JSON e logging seguem após estes passos. É a **fonte principal** da política de provisionamento — sem depender de ``adduser.local``, ``QUOTAUSER`` ou regras espalhadas em ``/etc/adduser.conf``. +Garante na criação as permissões para **todos** os serviços runv expostos ao utilizador: +**HTTP** (``public_html``), **Gopher** (``public_gopher``) e **Gemini** (``public_gemini``) — +home ``755`` (atravessável por Apache, gophernicus e molly-brown), pastas públicas ``755``, +ficheiros servidos ``644``, mais ``.ssh``/``authorized_keys`` e symlink Gemini quando aplicável. +Contas criadas **só** com ``adduser`` (sem este script) devem passar pelo backfill +``scripts/admin/setup_alt_protocols.py`` ou por nova execução deste script com as flags de reparo +adequadas (``--force-*``). + Não é signup público: executar manualmente como root/sudo no servidor. Requer Linux (Debian). Quota: ext4 com ``usrquota``/``usrjquota`` via ``setquota`` (não altera fstab). @@ -1220,6 +1228,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( description=( "Provisiona conta Unix interna (runv.club). Executar como root no servidor. " + "Aplica permissões completas para HTTP, Gopher e Gemini (home e public_*); " + "contas só adduser precisam de setup_alt_protocols ou reparo aqui. " f"Versão {VERSION} — {AUTHOR} {COPYRIGHT_YEAR}." ), ) diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -7,7 +7,7 @@ Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club. Idempotente, dry-run, subprocess sem shell. Executar como root no Debian. -Versão 0.07 — runv.club +Versão 0.08 — runv.club """ from __future__ import annotations @@ -16,6 +16,7 @@ import argparse import grp import importlib.util import logging +import shutil import os import pwd import re @@ -32,7 +33,7 @@ from typing import Any, Final # Constantes # --------------------------------------------------------------------------- -VERSION: Final[str] = "0.07" +VERSION: Final[str] = "0.08" LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live") LETSENCRYPT_ARCHIVE: Final[Path] = Path("/etc/letsencrypt/archive") @@ -89,6 +90,14 @@ Edita este ficheiro em `~/public_gemini/index.gmi`. Mantém pastas **755** e fic # --------------------------------------------------------------------------- +def _path_resolved(p: Path) -> Path: + """Resolve o caminho; com symlinks (ex. Let's Encrypt) alinha com o canónico.""" + try: + return p.resolve(strict=False) + except TypeError: + return p.resolve() + + def setup_logging(verbose: bool) -> logging.Logger: logging.basicConfig( level=logging.DEBUG if verbose else logging.INFO, @@ -340,28 +349,39 @@ def ensure_le_tls_readable_for_molly( Ajusta /etc/letsencrypt/live e archive (e o directório do certificado) para 755, e archive/<domínio>/privkey*.pem para grupo ssl-cert + 640, para o molly-brown ler a chave. Só actua se cert_path estiver sob .../live/<domínio>/ (Let's Encrypt típico). + Usa raízes resolvidas para não saltar quando /etc/letsencrypt/live é symlink. """ try: - cert_resolved = cert_path.resolve() + cert_resolved = _path_resolved(cert_path) except OSError as e: log.debug("LE TLS: resolve %s: %s — salto", cert_path, e) return + + live_root = _path_resolved(LETSENCRYPT_LIVE) + archive_root = _path_resolved(LETSENCRYPT_ARCHIVE) + try: - cert_resolved.relative_to(LETSENCRYPT_LIVE) + cert_resolved.relative_to(live_root) except ValueError: - log.debug("LE TLS: cert não está sob %s — salto", LETSENCRYPT_LIVE) + log.debug( + "LE TLS: cert não está sob a árvore LE resolvida (%s) — salto (%s)", + live_root, + cert_resolved, + ) return live_domain_dir = cert_resolved.parent - if live_domain_dir.parent != LETSENCRYPT_LIVE: + parent_resolved = _path_resolved(live_domain_dir.parent) + if parent_resolved != live_root: log.debug( - "LE TLS: esperado .../live/<domínio>/fullchain.pem — salto (%s)", - cert_resolved, + "LE TLS: esperado .../live/<domínio>/fullchain.pem — salto (pai=%s live_root=%s)", + parent_resolved, + live_root, ) return domain = live_domain_dir.name - archive_domain_dir = LETSENCRYPT_ARCHIVE / domain + archive_domain_dir = archive_root / domain try: ssl_gid = grp.getgrnam(SSL_CERT_GROUP).gr_gid @@ -373,8 +393,8 @@ def ensure_le_tls_readable_for_molly( ssl_gid = None dirs_755: list[Path] = [ - LETSENCRYPT_LIVE, - LETSENCRYPT_ARCHIVE, + live_root, + archive_root, live_domain_dir, ] if archive_domain_dir.is_dir(): @@ -636,9 +656,31 @@ def ufw_maybe_allow( log.info("UFW: permitido %s/tcp (%s)", port, label) +def _www_data_can_read(path: Path, *, dry_run: bool, log: logging.Logger) -> bool | None: + """None = skip (sem runuser), True/False = resultado de test -r como www-data.""" + if dry_run: + if shutil.which("runuser"): + log.info("[dry-run] runuser -u www-data -- test -r %s", path) + else: + log.info("[dry-run] (runuser ausente) test -r como www-data em %s", path) + return None + if not shutil.which("runuser"): + log.debug("validação www-data: runuser não encontrado — salto test -r") + return None + r = subprocess.run( + ["runuser", "-u", "www-data", "--", "test", "-r", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + return r.returncode == 0 + + def validate_final( usernames: list[str], log: logging.Logger, + *, + dry_run: bool = False, ) -> None: log.info("--- validação final ---") for pkg, label in (("gophernicus", "Gopher"), ("molly-brown", "Gemini")): @@ -685,6 +727,19 @@ def validate_final( sl = GEMINI_USERS / sample ok_sl = sl.is_symlink() and sl.resolve() == (home / "public_gemini").resolve() log.info("amostra symlink Gemini: %s", "OK" if ok_sl else "FALTA/INCORRETO") + index_gmi = home / "public_gemini" / "index.gmi" + if molly_state == "active" and index_gmi.is_file(): + can = _www_data_can_read(index_gmi, dry_run=dry_run, log=log) + if can is False: + log.warning( + "amostra %s: www-data não consegue ler %s (runuser … test -r falhou). " + "Confirme home 755 (ou o+x), public_gemini 755, index.gmi 644 e symlink %s.", + sample, + index_gmi, + sl, + ) + elif can is True: + log.info("amostra %s: index.gmi legível por www-data (test -r): OK", sample) except KeyError: log.info("amostra %s: utilizador não existe neste sistema", sample) @@ -867,7 +922,7 @@ def main(argv: list[str] | None = None) -> int: delay_s=1.0, ) - validate_final(users, log) + validate_final(users, log, dry_run=args.dry_run) log.info("Concluído.") return 0 diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md @@ -17,12 +17,12 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph Serviços que leem `~/public_html`, `~/public_gopher` e `~/public_gemini` (Apache, gophernicus, molly-brown) correndo como utilizador do sistema (ex. `www-data`) precisam de **execução para «others»** (`o+x`, mínimo) em **cada** componente do caminho até à pasta pública. Uma home em **`700`** impede essa travessia: o Gemini pode responder **«Not found»** mesmo com `index.gmi` presente. - **Novas contas:** [`create_runv_user.py`](../admin/create_runv_user.py) aplica **`755`** na home em `apply_runv_permissions`. -- **Backfill:** a partir do **v0.07**, [`setup_alt_protocols.py`](../admin/setup_alt_protocols.py) repõe a home do utilizador para **`755`** quando o modo actual é outro (com registo em log). +- **Backfill:** a partir do **v0.07**, [`setup_alt_protocols.py`](../admin/setup_alt_protocols.py) repõe a home do utilizador para **`755`** quando o modo actual é outro (com registo em log). O **v0.08** corrige a detecção de caminhos Let's Encrypt quando `live`/`archive` são **symlinks** (o bloco LE deixa de saltar incorrectamente). - **Conflito:** [`patches/patch_permissions.py`](../../patches/patch_permissions.py) pode aplicar **`chmod 700`** em cada `/home/<user>` por política de privacidade — isso **quebra** a hospedagem em `public_*` até voltar a alinhar permissões (provisionamento ou `chmod` manual). -## Let's Encrypt e chave TLS (v0.07+) +## Let's Encrypt e chave TLS (v0.07+; symlinks v0.08+) -Quando o certificado Gemini está sob **`/etc/letsencrypt/live/<domínio>/`** (por defeito `runv.club/fullchain.pem`), o script aplica **antes** de gravar o `.conf` do molly-brown: +Quando o certificado Gemini está sob a árvore Let's Encrypt (por defeito **`/etc/letsencrypt/live/<domínio>/fullchain.pem`**), o script aplica **antes** de gravar o `.conf` do molly-brown. A partir do **v0.08**, as raízes `live` e `archive` são **resolvidas** (`resolve(strict=False)`): se `/etc/letsencrypt/live` for um **symlink**, o ajuste de `chmod` / `ssl-cert` nos `privkey` continua a aplicar-se ao caminho canónico correcto (deixa de aparecer no log um salto falso «cert não está sob …/live»). | Alvo | Acção | |------|--------| @@ -38,10 +38,15 @@ Se o grupo **`ssl-cert`** não existir no sistema, o script regista **WARNING** **`certbot renew`** pode repor modos mais restritos nos directórios e chaves. Recomenda-se um script em **`/etc/letsencrypt/renewal-hooks/deploy/`** que volte a aplicar a mesma política, ou reexecutar `setup_alt_protocols.py` após renovações (com as flags que fizer sentido: p.ex. `--skip-install --skip-gopher --skip-backfill` se só quiser TLS + Gemini). +## Validação final e `www-data` (v0.08+) + +No fim da execução, além de verificar ficheiros e symlink **como root**, o script tenta **`runuser -u www-data -- test -r`** no `index.gmi` do primeiro utilizador da lista **se** `molly-brown@` estiver `active`. Se falhar, regista **WARNING** (home `755`/`o+x`, `public_gemini` `755`, `index.gmi` `644`, symlink em `/var/gemini/users/<user>`). Em **`--dry-run`**, só regista o comando que seria executado. Sem o binário **`runuser`** (util-linux), este passo é omitido. + ## Utilizadores antigos vs novos -- **Novos:** recebem modelos via **`/etc/skel`** (após `tools/tools.py`) e via **`create_runv_user.py`** (sempre que o provisionador corre). -- **Antigos:** correr **`setup_alt_protocols.py`** (backfill completo) ou só pastas/symlinks com **`patches/yetgg.py`** (mesma lista de contas que `patch_irc.py`: união JSON + `/home`) se a infraestrutura de sistema já existir. +- **Política:** permissões correctas para **HTTP**, **Gopher** e **Gemini** devem existir **à criação** (fluxo [`create_runv_user.py`](../admin/create_runv_user.py): `apply_runv_permissions`) e ser **reaplicadas** no backfill ([`setup_alt_protocols.py`](../admin/setup_alt_protocols.py): home `755`, `public_gopher` / `public_gemini`, symlinks). +- **Novos:** modelos via **`/etc/skel`** (após `tools/tools.py`) e **`create_runv_user.py`** quando o provisionador corre. +- **Antigos / contas só `adduser`:** correr **`setup_alt_protocols.py`** (backfill completo) ou pastas/symlinks com **`patches/yetgg.py`** (mesma lista que `patch_irc.py`: união JSON + `/home`) se a infraestrutura de sistema já existir; ou reparar com `create_runv_user` e flags `--force-*` onde fizer sentido. ## Requisitos Gemini @@ -52,7 +57,7 @@ Se o grupo **`ssl-cert`** não existir no sistema, o script regista **WARNING** O **molly-brown** trata `AccessLog` e `ErrorLog` como **caminhos de ficheiro**. Valores como `"-"` (estilo «stdout» noutros programas) são interpretados de forma errada e o processo tenta abrir `/-`, falhando de imediato. -- **Comportamento actual do script (v0.06+):** grava `AccessLog` / `ErrorLog` em **`/var/lib/molly-brown/`** (v0.07+ inclui ajuste automático de permissões Let's Encrypt; ver secção acima). (`runv.club-access.log`, `runv.club-error.log`). Esse caminho coincide com **`StateDirectory=molly-brown`** do unit Debian: o systemd cria o directório com o dono correcto (**`DynamicUser=yes`**) **antes** do `ExecStart`, sem `chown` manual. **Não** pré-cria pastas nem ficheiros de log (evita conflitos com `LogsDirectory` em `/var/log`). +- **Comportamento actual do script (v0.06+):** grava `AccessLog` / `ErrorLog` em **`/var/lib/molly-brown/`** (v0.07+ ajuste LE; v0.08+ LE com symlinks + teste `www-data`; ver secções acima). (`runv.club-access.log`, `runv.club-error.log`). Esse caminho coincide com **`StateDirectory=molly-brown`** do unit Debian: o systemd cria o directório com o dono correcto (**`DynamicUser=yes`**) **antes** do `ExecStart`, sem `chown` manual. **Não** pré-cria pastas nem ficheiros de log (evita conflitos com `LogsDirectory` em `/var/log`). - **Versões antigas (v0.05):** usavam o drop-in `50-runv-logs.conf` com `LogsDirectory=molly-brown`. Se `/var/log/molly-brown` já existia como root, o systemd podia **migrar** para `/var/log/private/molly-brown` e o serviço falhava. O **v0.06+** **remove** esse drop-in e muda os caminhos no `.conf` para `/var/lib/molly-brown/`. - **Servidor já provisionado:** correr com **`--force`** para regravar o `.conf` e remover o drop-in obsoleto (com backup do drop-in se usar `--force`). Exemplo: `sudo python3 scripts/admin/setup_alt_protocols.py --verbose --force`