commit cf5d39610e70f08e25a1ba7f24237a6c870bda73
parent 467d74597314f600d4768644914a561b3dfd1f94
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 16:03:13 -0300
feat(admin): Backfill home 755 and Let's Encrypt TLS for molly-brown
setup_alt_protocols v0.07: ensure_user_public_dirs sets home 755 when needed; ensure_le_tls_readable_for_molly chmods LE live/archive paths and ssl-cert group on archive privkeys. Document home traverse, LE policy, renew hooks, and patch_permissions conflict in alt_protocols.md and create_runv_user docstring.
Made-with: Cursor
Diffstat:
4 files changed, 159 insertions(+), 4 deletions(-)
diff --git a/patches/patch_permissions.py b/patches/patch_permissions.py
@@ -6,6 +6,12 @@ Dois níveis (resumo do modelo POSIX/OpenSSH):
1. **Privacidade entre utilizadores** — não impede «cd ..»; apenas impede listar/entrar nas
homes alheias: ``chmod 711 /home`` e ``chmod 700`` em cada ``/home/<user>``.
+ **Incompatível com hospedagem runv em** ``public_html`` / ``public_gopher`` /
+ ``public_gemini``: serviços (Apache, gophernicus, molly-brown) precisam de **atravessar**
+ a home (mínimo ``o+x``, política runv ``755``). Após ``chmod 700`` por utilizador,
+ ``setup_alt_protocols`` (backfill) ou ``create_runv_user`` repõem ``755`` na home se
+ correr o fluxo de provisionamento; não misture este patch com ``public_*`` sem saber
+ o trade-off.
2. **Confinamento real** — ``Match Group`` + ``ChrootDirectory /srv/jail/%u``; o caminho do
chroot e ascendentes devem ser **root-owned** e não graváveis por outros (requisito do
diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py
@@ -755,7 +755,8 @@ def apply_runv_permissions(home: Path, uid: int, gid: int) -> None:
Aplica modos e donos esperados na home e nos artefactos runv (passo 5 do contrato).
Deve ser chamado após criar o utilizador, chave SSH, ``public_html`` e ``README.md``,
- para garantir home ``755`` (Apache atravessa até ``public_html``), ``.ssh`` ``700``,
+ para garantir home ``755`` (Apache, Gophernicus e Molly-Brown atravessam até
+ ``public_html`` / ``public_gopher`` / ``public_gemini``), ``.ssh`` ``700``,
``authorized_keys`` ``600``, site ``755``/``644``.
"""
try:
diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py
@@ -7,18 +7,20 @@ Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club.
Idempotente, dry-run, subprocess sem shell. Executar como root no Debian.
-Versão 0.06 — runv.club
+Versão 0.07 — runv.club
"""
from __future__ import annotations
import argparse
+import grp
import importlib.util
import logging
import os
import pwd
import re
import shutil
+import stat
import subprocess
import sys
import time
@@ -30,7 +32,11 @@ from typing import Any, Final
# Constantes
# ---------------------------------------------------------------------------
-VERSION: Final[str] = "0.06"
+VERSION: Final[str] = "0.07"
+
+LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live")
+LETSENCRYPT_ARCHIVE: Final[Path] = Path("/etc/letsencrypt/archive")
+SSL_CERT_GROUP: Final[str] = "ssl-cert"
DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json")
DEFAULT_HOMES_ROOT: Final[Path] = Path("/home")
@@ -324,6 +330,101 @@ def wait_for_unit_active(
return False
+def ensure_le_tls_readable_for_molly(
+ cert_path: Path,
+ *,
+ dry_run: bool,
+ log: logging.Logger,
+) -> None:
+ """
+ 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).
+ """
+ try:
+ cert_resolved = cert_path.resolve()
+ except OSError as e:
+ log.debug("LE TLS: resolve %s: %s — salto", cert_path, e)
+ return
+ try:
+ cert_resolved.relative_to(LETSENCRYPT_LIVE)
+ except ValueError:
+ log.debug("LE TLS: cert não está sob %s — salto", LETSENCRYPT_LIVE)
+ return
+
+ live_domain_dir = cert_resolved.parent
+ if live_domain_dir.parent != LETSENCRYPT_LIVE:
+ log.debug(
+ "LE TLS: esperado .../live/<domínio>/fullchain.pem — salto (%s)",
+ cert_resolved,
+ )
+ return
+
+ domain = live_domain_dir.name
+ archive_domain_dir = LETSENCRYPT_ARCHIVE / domain
+
+ try:
+ ssl_gid = grp.getgrnam(SSL_CERT_GROUP).gr_gid
+ except KeyError:
+ log.warning(
+ "LE TLS: grupo %r inexistente — não ajusto privkey*.pem (instale openssl/ssl-cert)",
+ SSL_CERT_GROUP,
+ )
+ ssl_gid = None
+
+ dirs_755: list[Path] = [
+ LETSENCRYPT_LIVE,
+ LETSENCRYPT_ARCHIVE,
+ live_domain_dir,
+ ]
+ if archive_domain_dir.is_dir():
+ dirs_755.append(archive_domain_dir)
+
+ for d in dirs_755:
+ if not d.is_dir():
+ log.info("LE TLS: omito chmod 755 (não existe): %s", d)
+ continue
+ if dry_run:
+ log.info("[dry-run] chmod 755 %s", d)
+ continue
+ try:
+ before = stat.S_IMODE(os.stat(d).st_mode)
+ os.chmod(d, 0o755)
+ if before != 0o755:
+ log.info("LE TLS: %s modo %04o -> 0755", d, before)
+ except OSError as e:
+ log.warning("LE TLS: chmod %s: %s", d, e)
+
+ if not archive_domain_dir.is_dir():
+ log.info("LE TLS: %s inexistente — sem privkey*.pem", archive_domain_dir)
+ return
+
+ privkeys = sorted(archive_domain_dir.glob("privkey*.pem"))
+ if not privkeys:
+ log.info("LE TLS: sem privkey*.pem em %s", archive_domain_dir)
+ return
+
+ if ssl_gid is None:
+ log.warning("LE TLS: sem grupo ssl-cert — não altero privkey em %s", archive_domain_dir)
+ return
+
+ for pk in privkeys:
+ if not pk.is_file():
+ continue
+ if dry_run:
+ log.info("[dry-run] chgrp %s %s && chmod 640 %s", SSL_CERT_GROUP, pk, pk)
+ continue
+ try:
+ st = os.stat(pk)
+ os.chown(pk, st.st_uid, ssl_gid)
+ before_m = stat.S_IMODE(st.st_mode)
+ os.chmod(pk, 0o640)
+ if before_m != 0o640:
+ log.info("LE TLS: %s modo %04o -> 0640, grupo %s", pk, before_m, SSL_CERT_GROUP)
+ except OSError as e:
+ log.warning("LE TLS: ajuste %s: %s", pk, e)
+
+
def ensure_user_public_dirs(
username: str,
homes_root: Path,
@@ -346,6 +447,14 @@ def ensure_user_public_dirs(
if dry_run:
log.info("[dry-run] garantiria ~/public_gopher e ~/public_gemini para %s", username)
+ if home.is_dir():
+ try:
+ cur = stat.S_IMODE(os.stat(home).st_mode)
+ except OSError as e:
+ log.debug("[dry-run] stat home %s: %s", home, e)
+ else:
+ if cur != 0o755:
+ log.info("[dry-run] chmod 755 %s (era %04o)", home, cur)
return
gdir.mkdir(parents=True, exist_ok=True)
@@ -378,6 +487,16 @@ def ensure_user_public_dirs(
else:
log.debug("index.gmi já existe, mantido: %s", xidx)
+ if home.is_dir():
+ try:
+ cur = stat.S_IMODE(os.stat(home).st_mode)
+ except OSError as e:
+ log.warning("stat home %s: %s", home, e)
+ else:
+ if cur != 0o755:
+ os.chmod(home, 0o755)
+ log.info("home %s: modo %04o -> 0755 (atravessável por serviços)", home, cur)
+
def ensure_gemini_symlink(
username: str,
@@ -604,6 +723,9 @@ def main(argv: list[str] | None = None) -> int:
cert = args.gemini_cert or DEFAULT_LE_CERT
key = args.gemini_key or DEFAULT_LE_KEY
+ if not args.skip_gemini:
+ ensure_le_tls_readable_for_molly(cert, dry_run=args.dry_run, log=log)
+
pkgs: list[str] = []
if not args.skip_install:
if not args.skip_gopher:
diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md
@@ -12,6 +12,32 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph
**Gemini (molly-brown):** `DocBase = /var/gemini`, `HomeDocBase = users`, symlinks **`/var/gemini/users/<user>` → `~/public_gemini`**.
+## 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.
+
+- **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).
+- **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+)
+
+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:
+
+| Alvo | Acção |
+|------|--------|
+| `/etc/letsencrypt/live` | `chmod 755` |
+| `/etc/letsencrypt/archive` | `chmod 755` |
+| `/etc/letsencrypt/live/<domínio>` | `chmod 755` |
+| `/etc/letsencrypt/archive/<domínio>` | `chmod 755` (se existir) |
+| `archive/<domínio>/privkey*.pem` | `chgrp ssl-cert`, `chmod 640` |
+
+O `<domínio>` é o nome do directório pai de `fullchain.pem` (igual ao de `--gemini-cert` quando aponta para LE). Caminhos **fora** de `/etc/letsencrypt/live/` **não** são alterados.
+
+Se o grupo **`ssl-cert`** não existir no sistema, o script regista **WARNING** e não altera os `privkey*.pem` (instale o pacote que fornece esse grupo, p.ex. em Debian).
+
+**`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).
+
## 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).
@@ -26,7 +52,7 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph
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/`** (`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+ 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`).
- **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`