commit f4fba944cb54b1a7072e6653cf300031af150f98
parent 271f19a446c3c0829612f2808c4b5467c9860017
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 16:26:51 -0300
fix(alt-protocols): Add Molly TempRedirects and align Gemini URLs
- Generate [TempRedirects] in molly-brown conf so /~user/ maps to /~/user/
- Bump setup_alt_protocols to v0.09; remove duplicate shutil import
- validate_final: test -r gophermap as gophernicus unit User= (infer from systemd)
- Generalize runuser read check; keep www-data for Molly index.gmi
- Document Gopher vs Gemini path rules; update skel and create_runv_user copy
Made-with: Cursor
Diffstat:
5 files changed, 92 insertions(+), 44 deletions(-)
diff --git a/README.md b/README.md
@@ -1,21 +1 @@
-# runv.club — runv-server
-
-Repositório de scripts e documentação para o servidor **runv.club** (Debian, pubnix).
-
-## Conteúdo principal
-
-| Área | Descrição |
-|------|-----------|
-| **`scripts/admin/create_runv_user.py`** | Provisiona contas Unix: SSH, `~/public_html` (HTTP), **`~/public_gopher`** (Gopher), **`~/public_gemini`** (Gemini), symlink em `/var/gemini/users/`, README, quota, metadados. |
-| **`scripts/admin/setup_alt_protocols.py`** | Instala/configura **gophernicus** (porta 70) e **molly-brown** (Gemini, TLS, porta 1965), UFW se ativo, backfill para utilizadores existentes. Ver **`scripts/docs/alt_protocols.md`**. |
-| **`patches/patch_irc.py`** | IRC (estilo tilde.club): comando **`chat`** para utilizadores; rede por defeito `irc.portalidea.com.br`. Ver **`scripts/docs/irc_patch.md`**. |
-| **`tools/tools.py`** | Pacotes globais (incl. IRC), MOTD, `/usr/local/bin` (**`chat`**, `runv-help`, …), **`/etc/skel`**. |
-| **`terminal/`** | Fluxo SSH «entre» (pedidos de conta). |
-
-## Protocolos públicos por utilizador
-
-- **HTTP:** ficheiros em `~/public_html/` (Apache `mod_userdir`).
-- **Gopher:** `~/public_gopher/` (ficheiro inicial `gophermap`); URL típica `gopher://runv.club/1/~usuario`.
-- **Gemini:** `~/public_gemini/` (`index.gmi`); URL típica `gemini://runv.club/~usuario/` (serviço global + TLS).
-
-— ~pmurad
-\ No newline at end of file
+runv.club setup
+\ No newline at end of file
diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py
@@ -418,7 +418,7 @@ Tudo o que colocares em **`public_html`** pode ser lido pelo mundo via HTTP no e
- **Gopher:** edita `~/public_gopher/gophermap` (e outros ficheiros nessa pasta). URL típica:
`gopher://{DEFAULT_GEMINI_HOST_PUBLIC}/1/~{username}` (o caminho exacto depende do servidor).
-- **Gemini:** edita `~/public_gemini/index.gmi`. URL típica: `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/`
+- **Gemini:** edita `~/public_gemini/index.gmi`. URL canónica: `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~/{username}/`; `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/` também funciona (redirect no servidor).
- Mantém **755** nas pastas públicas e **644** nos ficheiros, para o servidor conseguir ler.
## Comandos úteis na shell
@@ -478,7 +478,7 @@ iDocumentação: man gophermap (no pacote gophernicus). fake NULL 0
def default_gemini_index_gmi(username: str) -> str:
return f"""# ~{username} — runv.club (Gemini)
-Bem-vindo ao teu capsule em `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/`.
+Bem-vindo ao teu capsule em `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~/{username}/` (canónica). `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/` também funciona.
Edita este ficheiro em `~/public_gemini/index.gmi`. Mantém pastas **755** e ficheiros **644**.
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.08 — runv.club
+Versão 0.09 — runv.club
"""
from __future__ import annotations
@@ -20,7 +20,6 @@ import shutil
import os
import pwd
import re
-import shutil
import stat
import subprocess
import sys
@@ -33,7 +32,7 @@ from typing import Any, Final
# Constantes
# ---------------------------------------------------------------------------
-VERSION: Final[str] = "0.08"
+VERSION: Final[str] = "0.09"
LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live")
LETSENCRYPT_ARCHIVE: Final[Path] = Path("/etc/letsencrypt/archive")
@@ -74,7 +73,7 @@ iEdita este ficheiro em ~/public_gopher/gophermap. fake NULL 0
DEFAULT_USER_INDEX_GMI: Final[str] = """# ~{username} — runv.club (Gemini)
-Bem-vindo ao teu capsule em `gemini://runv.club/~{username}/`.
+Bem-vindo ao teu capsule em `gemini://runv.club/~/{username}/` (URL canónica Molly). O endereço `gemini://runv.club/~{username}/` também funciona (redirect no servidor).
Edita este ficheiro em `~/public_gemini/index.gmi`. Mantém pastas **755** e ficheiros **644** para o servidor ler o conteúdo.
@@ -152,6 +151,28 @@ def default_gopher_options(hostname: str) -> str:
return f'-h {hostname} -r {GOPHER_ROOT} -u public_gopher -o UTF-8'
+def infer_gophernicus_runtime_user(log: logging.Logger) -> str:
+ """Lê User= do unit gophernicus@.service; fallback ``gophernicus``."""
+ path = GOPHER_SYSTEMD_SERVICE
+ if not path.is_file():
+ log.debug("unit gophernicus inexistente (%s) — fallback User=gophernicus", path)
+ return "gophernicus"
+ try:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ except OSError as e:
+ log.debug("ler %s: %s — fallback User=gophernicus", path, e)
+ return "gophernicus"
+ m = re.search(r"^User=(\S+)", text, re.MULTILINE)
+ if not m:
+ log.debug("User= não encontrado em %s — fallback gophernicus", path)
+ return "gophernicus"
+ u = m.group(1)
+ if u.startswith("%") or "${" in u:
+ log.debug("User= dinâmico (%s) em %s — fallback gophernicus", u, path)
+ return "gophernicus"
+ return u
+
+
def write_gophernicus_default(
path: Path,
options_value: str,
@@ -271,6 +292,12 @@ AccessLog = "{access_log.as_posix()}"
ErrorLog = "{error_log.as_posix()}"
GeminiExt = "gmi"
ReadMollyFiles = true
+
+# Molly Brown resolve espaços de utilizador em paths /~/user/… (HomeDocBase).
+# URLs estilo Apache /~user/… redireccionam sem tocar em ficheiros no disco.
+[TempRedirects]
+"^/~([^/]+)(/.+)$" = "/~/$1$2"
+"^/~([^/]+)/?$" = "/~/$1/"
"""
@@ -655,19 +682,25 @@ 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."""
+def _runuser_can_read(
+ path: Path,
+ run_as: str,
+ *,
+ dry_run: bool,
+ log: logging.Logger,
+) -> bool | None:
+ """None = skip (sem runuser), True/False = resultado de ``test -r`` como *run_as*."""
if dry_run:
if shutil.which("runuser"):
- log.info("[dry-run] runuser -u www-data -- test -r %s", path)
+ log.info("[dry-run] runuser -u %s -- test -r %s", run_as, path)
else:
- log.info("[dry-run] (runuser ausente) test -r como www-data em %s", path)
+ log.info("[dry-run] (runuser ausente) test -r como %s em %s", run_as, path)
return None
if not shutil.which("runuser"):
- log.debug("validação www-data: runuser não encontrado — salto test -r")
+ log.debug("validação runuser: binário não encontrado — salto test -r")
return None
r = subprocess.run(
- ["runuser", "-u", "www-data", "--", "test", "-r", str(path)],
+ ["runuser", "-u", run_as, "--", "test", "-r", str(path)],
capture_output=True,
text=True,
timeout=30,
@@ -675,6 +708,11 @@ def _www_data_can_read(path: Path, *, dry_run: bool, log: logging.Logger) -> boo
return r.returncode == 0
+def _www_data_can_read(path: Path, *, dry_run: bool, log: logging.Logger) -> bool | None:
+ """Compatível com Molly Brown (utilizador típico ``www-data`` no Debian)."""
+ return _runuser_can_read(path, "www-data", dry_run=dry_run, log=log)
+
+
def validate_final(
usernames: list[str],
log: logging.Logger,
@@ -692,7 +730,8 @@ def validate_final(
text=True,
timeout=30,
)
- log.info("gophernicus.socket: %s", (r.stdout or "").strip() or r.returncode)
+ gopher_state = (r.stdout or "").strip()
+ log.info("gophernicus.socket: %s", gopher_state or r.returncode)
molly_unit = f"molly-brown@{MOLLY_INSTANCE}.service"
r2 = subprocess.run(
@@ -726,6 +765,25 @@ 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")
+ gophermap = home / "public_gopher" / "gophermap"
+ if gopher_state == "active" and gophermap.is_file():
+ guser = infer_gophernicus_runtime_user(log)
+ gcan = _runuser_can_read(gophermap, guser, dry_run=dry_run, log=log)
+ if gcan is False:
+ log.warning(
+ "amostra %s: utilizador %s (gophernicus) não consegue ler %s "
+ "(runuser … test -r falhou). Confirme home 755 (ou o+x), "
+ "public_gopher 755, gophermap 644.",
+ sample,
+ guser,
+ gophermap,
+ )
+ elif gcan is True:
+ log.info(
+ "amostra %s: gophermap legível pelo utilizador do serviço (%s, test -r): OK",
+ sample,
+ guser,
+ )
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)
diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md
@@ -8,16 +8,21 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph
|-----------|---------------|------------------|------------|
| **HTTP** (já existente) | `~/public_html/` | `index.html` | `http://runv.club/~user/` |
| **Gopher** | `~/public_gopher/` | `gophermap` | `gopher://runv.club/1/~user` |
-| **Gemini** | `~/public_gemini/` | `index.gmi` | `gemini://runv.club/~user/` |
+| **Gemini** | `~/public_gemini/` | `index.gmi` | `gemini://runv.club/~/user/` (canónico Molly); `gemini://runv.club/~user/` (redirect) |
**Gemini (molly-brown):** `DocBase = /var/gemini`, `HomeDocBase = users`, symlinks **`/var/gemini/users/<user>` → `~/public_gemini`**.
+### Gopher vs Gemini: formato do endereço
+
+- **Gopher (gophernicus):** selectors **`~username/…`** (tilde **colado** ao nome), alinhado com URLs como **`gopher://runv.club/1/~user`**. Não há o mesmo «split» de path que no Molly.
+- **Gemini (Molly Brown):** o servidor resolve caps em **`/~/username/…`**. URLs estilo Apache **`/~username/…`** são aceites graças a **`[TempRedirects]`** no `.conf` gerado pelo script (**v0.09+**). Pode usar indistintamente **`gemini://runv.club/~/user/`** (canónico) ou **`gemini://runv.club/~user/`** (compatível).
+
## Travessia da home (`755` na política runv)
-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.
+Apache (`mod_userdir`), **gophernicus** e **molly-brown** precisam de **execução para «others»** (`o+x`, mínimo) em **cada** componente do caminho até a pasta pública (`~/public_html`, `~/public_gopher`, `~/public_gemini`). O utilizador de runtime **não é o mesmo** em todos: no Debian o Molly costuma correr como **`www-data`**; o **gophernicus** usa o **`User=`** do unit (tipicamente `gophernicus`) — veja `/lib/systemd/system/gophernicus@.service`. Uma home em **`700`** impede a travessia: **HTTP, Gopher e Gemini** deixam de servir conteúdo (p.ex. Gemini **«Not found»** 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). 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).
+- **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). O **v0.09** adiciona redirects Molly `~user` → `~/user` e validação **`test -r`** do `gophermap` com o utilizador do serviço gophernicus.
- **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+; symlinks v0.08+)
@@ -38,9 +43,14 @@ 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+)
+## Validação final (v0.09+)
+
+No fim da execução, além de verificar ficheiros e symlink **como root**:
+
+- Se **`gophernicus.socket`** estiver **`active`**, o script tenta **`runuser -u <User=do_unit> -- test -r`** no **`gophermap`** da primeira conta da lista (o `User=` lê-se de `/lib/systemd/system/gophernicus@.service`; fallback **`gophernicus`**). Falha → **WARNING** (home `755`/`o+x`, `public_gopher` `755`, `gophermap` `644`).
+- Se **`molly-brown@`** estiver **`active`**, tenta **`runuser -u www-data -- test -r`** no **`index.gmi`** da amostra. Falha → **WARNING** (`public_gemini` `755`, `index.gmi` `644`, symlink `/var/gemini/users/<user>`).
-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.
+Em **`--dry-run`**, só regista os comandos. Sem **`runuser`** (util-linux), estes passos são omitidos.
## Utilizadores antigos vs novos
@@ -101,7 +111,7 @@ Raro se o `.conf` aponta para `/var/lib/molly-brown/` e não há override que de
- **Estado e porta:** `sudo systemctl status molly-brown@runv.club.service --no-pager` e `sudo ss -tlnp | grep 1965` (deve haver um processo a escutar em **1965/tcp**).
- **Permissões TLS (frequente):** o Molly corre como utilizador não-root; se `privkey.pem` for só `root:root` `0600`, o arranque falha. Verifique `sudo namei -l /etc/letsencrypt/live/runv.club/privkey.pem` e compare com o utilizador do unit (`systemctl cat molly-brown@runv.club.service`). Soluções típicas: grupo `ssl-cert`, ACL, ou certificados num path legível pelo utilizador do serviço (mantendo segurança).
- **Teste local:** `openssl s_client -connect 127.0.0.1:1965 -servername runv.club </dev/null 2>/dev/null | head -20`
-- **Cliente (Lagrange, etc.):** teste `gemini://runv.club/~user/` **depois** de `systemctl is-active molly-brown@runv.club.service` devolver `active`.
+- **Cliente (Lagrange, etc.):** teste `gemini://runv.club/~/user/` ou `gemini://runv.club/~user/` **depois** de `systemctl is-active molly-brown@runv.club.service` devolver `active`.
## Execução (root)
@@ -119,7 +129,7 @@ sudo python3 scripts/admin/setup_alt_protocols.py --verbose
|------|--------|
| `--dry-run` | Simula; não grava (validação de root ignorada em alguns passos só se documentado). |
| `--verbose` | Log detalhado. |
-| `--force` | Sobrescreve configs de sistema (com backup com timestamp) e ficheiros modelo no backfill (exceto **`~/public_gemini/index.gmi`** se já existir). Necessário para **regravar** `/etc/molly-brown/runv.club.conf` e remover o drop-in obsoleto **`50-runv-logs.conf`** (v0.05) ao migrar logs para `/var/lib/molly-brown/`. |
+| `--force` | Sobrescreve configs de sistema (com backup com timestamp) e ficheiros modelo no backfill (exceto **`~/public_gemini/index.gmi`** se já existir). Necessário para **regravar** `/etc/molly-brown/runv.club.conf` (incl. **`[TempRedirects]`** v0.09+) e remover o drop-in obsoleto **`50-runv-logs.conf`** (v0.05) ao migrar logs para `/var/lib/molly-brown/`. |
| `--skip-install` | Não corre `apt-get`. |
| `--skip-gopher` / `--skip-gemini` | Ignora pacote, config e serviço desse protocolo. |
| `--skip-firewall` | Não altera UFW. |
@@ -156,6 +166,6 @@ Depois aplicam-se as mesmas exclusões que em **`patches/patch_irc.py`** (`IRC_P
6. Verificar `/etc/skel/public_gopher` e `public_gemini` após `tools.py`
7. Criar utilizador de teste com `create_runv_user.py`
8. `ls -la /home/teste/public_gopher/gophermap /home/teste/public_gemini/index.gmi` e `ls -la /var/gemini/users/teste`
-9. Cliente Gopher/Gemini: `gopher://runv.club/1/~teste` e `gemini://runv.club/~teste/`
+9. Cliente Gopher/Gemini: `gopher://runv.club/1/~teste` e `gemini://runv.club/~/teste/` (ou `gemini://runv.club/~teste/` com redirect)
Versão do script: ver `python3 scripts/admin/setup_alt_protocols.py --version`.
diff --git a/tools/skel/public_gemini/index.gmi b/tools/skel/public_gemini/index.gmi
@@ -1,6 +1,6 @@
# Capsule Gemini — runv.club
-O teu endereço público segue o padrão `gemini://runv.club/~NOME_UTILIZADOR/` (substitui pelo teu username Unix).
+O endereço canónico é `gemini://runv.club/~/NOME_UTILIZADOR/` (substitui pelo teu username Unix). `gemini://runv.club/~NOME_UTILIZADOR/` também funciona (redirect no servidor). Gopher usa selector `~NOME` sem barra extra — ver documentação do projeto.
Edita este ficheiro em `~/public_gemini/index.gmi`. Ficheiros `.gmi` são Texto Gemini; mantém pastas 755 e ficheiros 644 para o servidor conseguir ler.