runv-server

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

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:
Mpatches/patch_permissions.py | 6++++++
Mscripts/admin/create_runv_user.py | 3++-
Mscripts/admin/setup_alt_protocols.py | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mscripts/docs/alt_protocols.md | 28+++++++++++++++++++++++++++-
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`