runv-server

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

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:
MREADME.md | 24++----------------------
Mscripts/admin/create_runv_user.py | 4++--
Mscripts/admin/setup_alt_protocols.py | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mscripts/docs/alt_protocols.md | 26++++++++++++++++++--------
Mtools/skel/public_gemini/index.gmi | 2+-
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.