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:
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`