runv-server

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

commit 6685c4bb6ab490041260b56bb15c681e1813cdf1
parent 61272ab855cf56544337b81c78a78da16ad8d9f3
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 16:50:09 -0300

fix(alt-protocols): Gemini bind mount + fstab for Molly Debian (v0.13)

Replace symlink under /var/gemini/users/<user> with mount --bind and
persistent fstab; validate mountpoint in validate_final. Update
create_runv_user, del-user (umount/fstab cleanup), yetgg patch, and docs.

Made-with: Cursor

Diffstat:
MREADME.md | 4----
Mpatches/yetgg.py | 10+++++-----
Mscripts/admin/create_runv_user.py | 58+++++++++++++++++++++-------------------------------------
Mscripts/admin/del-user.py | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mscripts/admin/setup_alt_protocols.py | 250++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mscripts/create_runv_user.md | 4++--
Mscripts/del-user.md | 2+-
Mscripts/docs/alt_protocols.md | 33++++++++++++++++++++-------------
8 files changed, 353 insertions(+), 109 deletions(-)

diff --git a/README.md b/README.md @@ -1,7 +1,3 @@ # runv-server Repositório de automação e documentação para **runv.club** (pubnix Debian). - -## Gemini (Molly Brown) - -O capsule de cada utilizador **não** está em `gemini://runv.club/USERNAME` (path `/USERNAME`). O formato correcto no Molly Brown é **`gemini://runv.club/~USERNAME/`** (path **`/~USERNAME/`**, tilde **colado** ao nome). Links com slash extra (`gemini://runv.club/~/USERNAME/`) devem redireccionar após **`setup_alt_protocols.py` v0.11+** com **`--force`** no servidor. Requer Molly a correr, symlink em `/var/gemini/users/USERNAME`, home e `public_gemini` atravessáveis — ver [`scripts/docs/alt_protocols.md`](scripts/docs/alt_protocols.md). diff --git a/patches/yetgg.py b/patches/yetgg.py @@ -2,7 +2,7 @@ """ runv.club — backfill Gopher/Gemini para utilizadores já registados. -Cria ``~/public_gopher``, ``~/public_gemini`` (modelos) e symlinks em +Cria ``~/public_gopher``, ``~/public_gemini`` (modelos) e bind mounts em ``/var/gemini/users/<user>``, usando a **mesma lista de contas** que o IRC (união ``users.json`` + ``/home``, filtro ``IRC_PATCH_SKIP_USERS``). @@ -61,7 +61,7 @@ def require_root(log: logging.Logger) -> None: def ensure_gemini_users_tree(*, dry_run: bool, log: logging.Logger) -> None: if GEMINI_USERS.is_dir(): return - log.warning("%s inexistente — criar antes dos symlinks Gemini", GEMINI_USERS) + log.warning("%s inexistente — criar antes dos bind mounts Gemini", GEMINI_USERS) if dry_run: log.info("[dry-run] mkdir -p %s %s (755 root:root)", GEMINI_ROOT, GEMINI_USERS) return @@ -86,7 +86,7 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: p.add_argument( "--force", action="store_true", - help="sobrescrever gophermap / symlinks (como setup_alt_protocols); index.gmi existente mantém-se", + help="sobrescrever gophermap / bind Gemini (como setup_alt_protocols); index.gmi existente mantém-se", ) p.add_argument( "--users-json", @@ -128,7 +128,7 @@ def main(argv: list[str] | None = None) -> int: resolve_all_users = patch_irc.resolve_all_users ensure_user_public_dirs = setup_alt.ensure_user_public_dirs - ensure_gemini_symlink = setup_alt.ensure_gemini_symlink + ensure_gemini_bind_mount = setup_alt.ensure_gemini_bind_mount users = resolve_all_users(args.users_json, args.homes_root, log) ensure_gemini_users_tree(dry_run=args.dry_run, log=log) @@ -143,7 +143,7 @@ def main(argv: list[str] | None = None) -> int: dry_run=args.dry_run, log=log, ) - ensure_gemini_symlink( + ensure_gemini_bind_mount( username, args.homes_root, force=args.force, diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -9,8 +9,8 @@ Contrato de provisionamento (ordem garantida após validação): 3. **Preparar public_html** — diretório ``755``, ``index.html`` estático ``644``. 4. **Preparar public_gopher / public_gemini** — ``gophermap`` modelo (não sobrescreve sem ``--force-gopher``); ``index.gmi`` só é criado se ainda não existir (nunca substituído); - symlink em ``/var/gemini/users/<user>`` quando o diretório existir (``--force-gemini`` só - força reparação do symlink / conflitos). + bind mount ``/var/gemini/users/<user>`` <- ``~/public_gemini`` quando o directório global existir + (``--force-gemini`` força migração de symlink / remount). 5. **Copiar o skel** — o Debian copia ``/etc/skel`` para a home **durante** o passo 1; depois, após os diretórios públicos, o script acrescenta ``README.md`` runv (português), sem apagar o que veio do skel (use ``--force-readme`` para substituir). Prepare ``/etc/skel`` com ``tools.py`` @@ -26,7 +26,7 @@ Quota ext4, metadados JSON e logging seguem após estes passos. 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. +ficheiros servidos ``644``, mais ``.ssh``/``authorized_keys`` e bind mount 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-*``). @@ -551,42 +551,26 @@ def ensure_gemini_user_symlink( *, force: bool, ) -> None: - """Cria /var/gemini/users/<user> -> <home>/public_gemini se o diretório global existir.""" - target = (home / "public_gemini").resolve() + """ + Garante bind mount /var/gemini/users/<user> <- <home>/public_gemini (Molly Debian; + symlinks fora do DocBase são rejeitados). Delega em setup_alt_protocols. + """ + import setup_alt_protocols as alt + if not GEMINI_USERS_DIR.is_dir(): log.warning( - "diretório %s inexistente — symlink Gemini não criado. " + "diretório %s inexistente — bind Gemini não aplicado. " "Execute scripts/admin/setup_alt_protocols.py no servidor.", GEMINI_USERS_DIR, ) return - link = GEMINI_USERS_DIR / username - if link.is_symlink(): - if link.resolve() == target: - log.info("symlink Gemini já correto: %s", link) - return - if force: - link.unlink() - log.info("symlink Gemini antigo removido: %s", link) - else: - log.warning( - "symlink %s aponta para %s (esperado %s); não altero sem --force-gemini", - link, - link.resolve(), - target, - ) - return - elif link.exists(): - if not force: - log.warning("%s existe e não é symlink; não sobrescrevo sem --force-gemini", link) - return - if link.is_dir(): - shutil.rmtree(link) - else: - link.unlink() - log.info("removido destino em conflito para symlink Gemini: %s", link) - link.symlink_to(target, target_is_directory=True) - log.info("symlink Gemini: %s -> %s", link, target) + alt.ensure_gemini_bind_mount( + username, + home.parent, + force=force, + dry_run=False, + log=log, + ) def prepare_user_readme( @@ -1170,7 +1154,7 @@ def interactive_fill(args: argparse.Namespace) -> None: default_no=True, ) args.force_gemini = prompt_yes_no( - "Forçar correção do symlink Gemini (/var/gemini/users) se estiver errado ou em conflito (--force-gemini)?", + "Forçar correção do bind mount Gemini (/var/gemini/users) se estiver errado ou em conflito (--force-gemini)?", default_no=True, ) args.force_readme = prompt_yes_no( @@ -1268,7 +1252,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: p.add_argument( "--force-gemini", action="store_true", - help="corrigir symlink em /var/gemini/users (e destinos em conflito); não sobrescreve index.gmi existente", + help="corrigir bind mount em /var/gemini/users (migra symlink; remount se necessário); não sobrescreve index.gmi existente", ) p.add_argument( "--metadata-file", @@ -1435,7 +1419,7 @@ def main(argv: list[str] | None = None) -> int: print(f" fingerprint: {fingerprint}") print( " ações: (1) adduser + /etc/skel (2) authorized_keys (3) public_html " - "(4) public_gopher + public_gemini + symlink Gemini (5) README.md " + "(4) public_gopher + public_gemini + bind Gemini (5) README.md " "(6) permissões consolidadas + quota (se ativa) + metadados JSON" ) if args.no_quota: @@ -1554,7 +1538,7 @@ def main(argv: list[str] | None = None) -> int: print(" public_html: pronto (index.html estático)") print(" public_gopher: pronto (gophermap)") print(" public_gemini: pronto (index.gmi)") - print(" symlink Gemini: /var/gemini/users/<user> (se o diretório existir)") + print(" bind Gemini: /var/gemini/users/<user> <- ~/public_gemini (se o diretório existir)") print(" README.md: criado em ~/README.md (pt-BR)") print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") print(f" fingerprint: {fingerprint}") diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py @@ -22,6 +22,7 @@ import re import subprocess import sys import tempfile +from datetime import datetime, timezone from pathlib import Path from typing import Final @@ -152,30 +153,102 @@ def confirm_interactive(username: str) -> bool: # --------------------------------------------------------------------------- -# Gemini (symlink em /var/gemini/users) +# Gemini (bind mount / symlink legado em /var/gemini/users) # --------------------------------------------------------------------------- GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") +FSTAB_PATH: Final[Path] = Path("/etc/fstab") +_GEMINI_BIND_FSTAB_RE: Final[re.Pattern[str]] = re.compile( + r"^(.+)\s+(/var/gemini/users/\S+)\s+none\s+bind\s+0\s+0\s*\Z" +) + + +def _unescape_fstab_path(s: str) -> str: + return s.replace("\\040", " ") def remove_gemini_user_symlink(username: str, *, dry_run: bool, verbose: bool) -> None: - """Remove apenas o symlink /var/gemini/users/<user> se existir e for symlink.""" - link = GEMINI_USERS_DIR / username - if not link.is_symlink(): - if link.exists() and verbose: + """ + Desmonta bind mount em /var/gemini/users/<user>, remove linha fstab correspondente, + remove symlink legado ou directório vazio. + """ + mp = GEMINI_USERS_DIR / username + + if dry_run: + print(f" [dry-run] Gemini: umount/fstab/symlink se aplicável em {mp}") + return + + r_mp = subprocess.run( + ["mountpoint", "-q", str(mp)], + capture_output=True, + timeout=60, + ) + if r_mp.returncode == 0: + u = subprocess.run( + ["umount", str(mp)], + capture_output=True, + text=True, + timeout=120, + ) + if u.returncode != 0: print( - f" [aviso] {link} existe mas não é symlink; não removo automaticamente.", + f" [aviso] umount {mp}: {(u.stderr or u.stdout or '').strip()}", file=sys.stderr, ) + elif verbose: + print(f" [ok] umount Gemini: {mp}") + + if FSTAB_PATH.is_file(): + try: + text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace") + except OSError as e: + if verbose: + print(f" [aviso] ler fstab: {e}", file=sys.stderr) + else: + new_lines: list[str] = [] + removed_line = False + for line in text.splitlines(keepends=True): + st = line.strip() + if not st or st.startswith("#"): + new_lines.append(line) + continue + m = _GEMINI_BIND_FSTAB_RE.match(st) + if m and Path(_unescape_fstab_path(m.group(2))) == mp: + removed_line = True + continue + new_lines.append(line) + if removed_line: + new_content = "".join(new_lines) + if new_content != text: + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + bak = FSTAB_PATH.with_suffix(f".bak.{ts}") + shutil.copy2(FSTAB_PATH, bak) + FSTAB_PATH.write_text(new_content, encoding="utf-8") + if verbose: + print(f" [ok] fstab: removida linha bind para {mp} (backup {bak})") + + if mp.is_symlink(): + try: + mp.unlink() + print(f" [ok] symlink Gemini removido: {mp}") + except OSError as e: + print(f" [aviso] não foi possível remover {mp}: {e}", file=sys.stderr) return - if dry_run: - print(f" [dry-run] removeria symlink Gemini {link}") - return - try: - link.unlink() - print(f" [ok] symlink Gemini removido: {link}") - except OSError as e: - print(f" [aviso] não foi possível remover {link}: {e}", file=sys.stderr) + + if mp.is_dir(): + try: + if not any(mp.iterdir()): + mp.rmdir() + if verbose: + print(f" [ok] directório Gemini vazio removido: {mp}") + except OSError as e: + if verbose: + print(f" [aviso] {mp}: {e}", file=sys.stderr) + elif mp.exists() and verbose: + print( + f" [aviso] {mp} ainda existe (não é symlink/dir vazio); verificar manualmente.", + file=sys.stderr, + ) # --------------------------------------------------------------------------- diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -3,11 +3,12 @@ Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club. - Gopher: raiz em /var/gopher, espaços de utilizador em ~/public_gopher (gophermap). -- Gemini: DocBase /var/gemini, symlinks /var/gemini/users/<user> -> ~/public_gemini. +- Gemini: DocBase /var/gemini; **bind mount** ``/var/gemini/users/<user>`` <- ``~/public_gemini`` + (o Molly Debian recusa symlinks cujo destino fica fora do DocBase). Idempotente, dry-run, subprocess sem shell. Executar como root no Debian. -Versão 0.12 — runv.club +Versão 0.13 — runv.club """ from __future__ import annotations @@ -32,7 +33,7 @@ from typing import Any, Final # Constantes # --------------------------------------------------------------------------- -VERSION: Final[str] = "0.12" +VERSION: Final[str] = "0.13" LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live") LETSENCRYPT_ARCHIVE: Final[Path] = Path("/etc/letsencrypt/archive") @@ -47,6 +48,12 @@ DEFAULT_LE_KEY: Final[Path] = Path("/etc/letsencrypt/live/runv.club/privkey.pem" GOPHER_ROOT: Final[Path] = Path("/var/gopher") GEMINI_ROOT: Final[Path] = Path("/var/gemini") GEMINI_USERS: Final[Path] = GEMINI_ROOT / "users" +FSTAB_PATH: Final[Path] = Path("/etc/fstab") + +# Linha fstab: <source> <mountpoint> none bind 0 0 (paths sem espaços no 2.º campo) +_GEMINI_BIND_FSTAB_RE: Final[re.Pattern[str]] = re.compile( + r"^(.+)\s+(/var/gemini/users/\S+)\s+none\s+bind\s+0\s+0\s*\Z" +) GOPHER_DEFAULT_PATH: Final[Path] = Path("/etc/default/gophernicus") GOPHER_SYSTEMD_SERVICE: Final[Path] = Path("/lib/systemd/system/gophernicus@.service") @@ -296,6 +303,9 @@ ReadMollyFiles = true # Molly Brown (Go): resolvePath usa o *primeiro* segmento após / como ~NOME — ou seja # path canónico /~username/… (tilde colado ao utilizador). O formato /~/username/ # deixa o nome vazio e devolve 51; redireccionamos /~/… -> /~… antes do Stat. +# +# Conteúdo por utilizador: bind mount (não symlink) de DocBase/users/<user> para +# ~/public_gemini — o pacote Debian recusa symlinks fora do DocBase. [TempRedirects] "^/~/([^/]+)(/.*)?$" = "/~$1$2" """ @@ -544,7 +554,105 @@ def ensure_user_public_dirs( log.info("home %s: modo %04o -> 0755 (atravessável por serviços)", home, cur) -def ensure_gemini_symlink( +def _escape_fstab_path(s: str) -> str: + return s.replace(" ", "\\040") + + +def _unescape_fstab_path(s: str) -> str: + return s.replace("\\040", " ") + + +def _is_dir_mountpoint(path: Path) -> bool: + r = subprocess.run( + ["mountpoint", "-q", str(path)], + capture_output=True, + timeout=30, + ) + return r.returncode == 0 + + +def _bind_mount_source_resolved(mountpoint: Path) -> Path | None: + r = subprocess.run( + ["findmnt", "-n", "-o", "SOURCE", "--target", str(mountpoint)], + capture_output=True, + text=True, + timeout=30, + ) + if r.returncode != 0: + return None + raw = (r.stdout or "").strip() + if not raw: + return None + src = raw.split()[0] + if src.startswith("[") and "]" in src: + src = src[1 : src.index("]")] + try: + return Path(src).resolve(strict=False) + except OSError: + return Path(src) + + +def _ensure_gemini_fstab_line( + source: Path, + mountpoint: Path, + *, + dry_run: bool, + log: logging.Logger, +) -> None: + src_s = str(_path_resolved(source)) + mp_s = str(_path_resolved(mountpoint)) + desired_line = f"{_escape_fstab_path(src_s)} {_escape_fstab_path(mp_s)} none bind 0 0\n" + if dry_run: + log.info("[dry-run] fstab (se necessário): %s", desired_line.rstrip()) + return + if not FSTAB_PATH.is_file(): + log.warning("%s inexistente — bind não persistido após reboot", FSTAB_PATH) + return + try: + text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log.warning("ler fstab: %s", e) + return + mp_path = mountpoint + src_res = Path(src_s).resolve() + kept: list[str] = [] + found_exact = False + for line in text.splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith("#") or not stripped: + kept.append(line) + continue + m = _GEMINI_BIND_FSTAB_RE.match(stripped) + if not m: + kept.append(line) + continue + f2 = Path(_unescape_fstab_path(m.group(2))) + if f2 != mp_path: + kept.append(line) + continue + f1 = Path(_unescape_fstab_path(m.group(1))).resolve() + if f1 == src_res: + if not found_exact: + found_exact = True + kept.append(line) + else: + log.debug("fstab: duplicado ignorado para %s", mountpoint) + else: + log.debug("fstab: removida linha antiga para %s (origem diferente)", mountpoint) + if not found_exact: + if kept and not kept[-1].endswith("\n"): + kept[-1] += "\n" + kept.append(desired_line) + new_content = "".join(kept) + if new_content == text: + log.debug("fstab inalterado para %s", mountpoint) + return + backup_if_exists(FSTAB_PATH, log, dry_run=False) + FSTAB_PATH.write_text(new_content, encoding="utf-8") + log.info("fstab: bind persistido %s -> %s", src_s, mp_s) + + +def ensure_gemini_bind_mount( username: str, homes_root: Path, *, @@ -552,45 +660,110 @@ def ensure_gemini_symlink( dry_run: bool, log: logging.Logger, ) -> None: + """ + Expõe ~/public_gemini em /var/gemini/users/<user> com mount --bind + fstab. + O Molly Debian recusa symlinks cujo destino fica fora de DocBase (/var/gemini). + """ + _ = homes_root # API compatível com o backfill (getpwnam fornece a home) try: pw = pwd.getpwnam(username) except KeyError: return home = Path(pw.pw_dir) - target = (home / "public_gemini").resolve() - link = GEMINI_USERS / username + target = home / "public_gemini" + if not target.is_dir(): + log.debug("public_gemini inexistente para %s — bind não aplicado", username) + return + target_resolved = _path_resolved(target) + mountpoint = GEMINI_USERS / username if not GEMINI_USERS.is_dir(): - log.warning("GEMINI_USERS inexistente: %s — symlink não criado", GEMINI_USERS) + log.warning("GEMINI_USERS inexistente: %s — bind não aplicado", GEMINI_USERS) return if dry_run: - log.info("[dry-run] symlink %s -> %s", link, target) + log.info("[dry-run] mount --bind %s %s + fstab", target_resolved, mountpoint) + _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=True, log=log) return - if link.is_symlink(): - cur = link.resolve() - if cur == target: - log.debug("symlink OK: %s", link) + if mountpoint.is_symlink(): + if not force: + log.warning( + "symlink %s -> %s: Molly Debian recusa symlinks fora do DocBase; " + "corra com --force para substituir por bind mount", + mountpoint, + mountpoint.resolve(), + ) return - if force: - link.unlink() - log.info("symlink antigo removido: %s", link) - else: - log.warning("symlink %s aponta para %s (esperado %s); use --force", link, cur, target) + mountpoint.unlink() + log.info("symlink removido (migração bind): %s", mountpoint) + + if mountpoint.exists() and mountpoint.is_file(): + log.warning("%s é ficheiro; não aplico bind", mountpoint) + return + + if _is_dir_mountpoint(mountpoint): + src_now = _bind_mount_source_resolved(mountpoint) + if src_now == target_resolved: + log.debug("bind mount OK: %s <- %s", mountpoint, target_resolved) + _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=False, log=log) return - elif link.exists(): - log.warning("%s existe e não é symlink; não sobrescrevo sem --force", link) - if force: - if link.is_dir(): - shutil.rmtree(link) - else: - link.unlink() - else: + if not force: + log.warning( + "mountpoint %s montado de %s; esperado %s — use --force", + mountpoint, + src_now, + target_resolved, + ) return + ru = run_cmd(["umount", str(mountpoint)], dry_run=False, log=log) + if ru is not None and ru.returncode != 0: + log.error( + "umount %s falhou: %s", + mountpoint, + (ru.stderr or ru.stdout or "").strip(), + ) + return + log.info("umount antes de remount: %s", mountpoint) - link.symlink_to(target, target_is_directory=True) - log.info("symlink: %s -> %s", link, target) + if mountpoint.exists() and mountpoint.is_dir(): + try: + if any(mountpoint.iterdir()): + log.warning( + "%s é directório com conteúdo (não é mountpoint); não aplico bind", + mountpoint, + ) + return + except OSError as e: + log.warning("listar %s: %s", mountpoint, e) + return + + mountpoint.mkdir(parents=True, exist_ok=True) + os.chmod(mountpoint, 0o755) + try: + os.chown(mountpoint, 0, 0) + except OSError as e: + log.warning("chown %s: %s", mountpoint, e) + + rm = run_cmd( + ["mount", "--bind", str(target_resolved), str(mountpoint)], + dry_run=False, + log=log, + ) + if rm is None or rm.returncode != 0: + log.error( + "mount --bind falhou: %s -> %s (%s)", + target_resolved, + mountpoint, + (rm.stderr or rm.stdout or "").strip() if rm else "", + ) + return + log.info("bind mount: %s -> %s", target_resolved, mountpoint) + _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=False, log=log) + + +# Alias legado (patches/yetgg.py e referências antigas) +ensure_gemini_symlink = ensure_gemini_bind_mount def apt_install( @@ -762,9 +935,20 @@ def validate_final( (home / "public_gemini" / "index.gmi", "index.gmi"), ): log.info("amostra %s %s: %s", sample, label, "OK" if p.is_file() else "FALTA") - 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") + mp = GEMINI_USERS / sample + pg = (home / "public_gemini").resolve() + ok_mount = False + if _is_dir_mountpoint(mp): + src = _bind_mount_source_resolved(mp) + ok_mount = src is not None and src == pg + elif mp.is_symlink(): + log.warning( + "amostra %s: %s ainda é symlink (Molly Debian rejeita); " + "corra setup_alt_protocols com --force para bind mount", + sample, + mp, + ) + log.info("amostra mount Gemini: %s", "OK" if ok_mount else "FALTA/INCORRETO") gophermap = home / "public_gopher" / "gophermap" if gopher_state == "active" and gophermap.is_file(): guser = infer_gophernicus_runtime_user(log) @@ -790,11 +974,11 @@ def validate_final( 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, symlink %s; " + "Confirme home 755 (ou o+x), public_gemini 755, index.gmi 644, bind %s; " "se `ls -l` mostrar +, veja getfacl no path (ACL).", sample, index_gmi, - sl, + mp, ) elif can is True: log.info("amostra %s: index.gmi legível por www-data (test -r): OK", sample) @@ -952,7 +1136,7 @@ def main(argv: list[str] | None = None) -> int: dry_run=args.dry_run, log=log, ) - ensure_gemini_symlink( + ensure_gemini_bind_mount( u, args.homes_root, force=args.force, diff --git a/scripts/create_runv_user.md b/scripts/create_runv_user.md @@ -13,7 +13,7 @@ Ferramenta de linha de comando para **administradores** criarem contas Unix no s 1. **Criar o usuário** — `adduser --disabled-password` (conta Unix). 2. **Instalar a chave** — `~/.ssh/authorized_keys` com chave validada e modos `700` / `600`. 3. **Preparar `public_html`** — diretório `755` e `~/public_html/index.html` estático (sem JavaScript, sem CDN); não sobrescreve sem `--force-index`. -4. **Preparar Gopher e Gemini** — `~/public_gopher/` com `gophermap` e `~/public_gemini/` com `index.gmi` (modelos em português); não sobrescreve sem **`--force-gopher`** / **`--force-gemini`**. Se existir **`/var/gemini/users`**, cria o symlink **`/var/gemini/users/<user>` → `~/public_gemini`** (ajustável com `--force-gemini` se o symlink estiver errado). Se essa pasta global não existir, regista **aviso** no log — corra **[`setup_alt_protocols.py`](docs/alt_protocols.md)** no servidor. +4. **Preparar Gopher e Gemini** — `~/public_gopher/` com `gophermap` e `~/public_gemini/` com `index.gmi` (modelos em português); não sobrescreve sem **`--force-gopher`** / **`--force-gemini`**. Se existir **`/var/gemini/users`**, aplica **bind mount** **`/var/gemini/users/<user>`** ← **`~/public_gemini`** (via `setup_alt_protocols`; **`--force-gemini`** migra symlink legado). Se essa pasta global não existir, regista **aviso** no log — corra **[`setup_alt_protocols.py`](docs/alt_protocols.md)** no servidor. 5. **Copiar o skel** — o Debian **copia `/etc/skel` para a home no passo 1**. Depois, o script acrescenta `~/README.md` runv em português (runv.club, URL `~/username/`, permissões, comandos, aviso sobre arquivos públicos, Gopher/Gemini); não sobrescreve sem **`--force-readme`**. Se o skel do sistema já tiver um `README.md`, ele permanece até usar `--force-readme`. Para padronizar o skel do servidor, use **`tools/tools.py`** (ou `admin/skel.py`, conforme a política do servidor) antes de criar contas. 6. **Aplicar permissões** — `apply_runv_permissions` reforça home `755`, `.ssh` / `authorized_keys`, `public_html`, `public_gopher`, `public_gemini` e `README.md` com modos e donos corretos; em seguida quota (se ativa), verificação final e metadados. @@ -201,7 +201,7 @@ Metadados: `/var/lib/runv/users.json` - `--verbose` — mais detalhes no stderr - `--force-index` — sobrescreve `~/public_html/index.html` se já existir - `--force-gopher` — sobrescreve `~/public_gopher/gophermap` se já existir -- `--force-gemini` — sobrescreve `~/public_gemini/index.gmi` se já existir e corrige o symlink em `/var/gemini/users/<user>` se estiver errado +- `--force-gemini` — sobrescreve `~/public_gemini/index.gmi` se já existir e corrige o **bind mount** em `/var/gemini/users/<user>` (migra symlink legado) se necessário - `--force-readme` — sobrescreve `~/README.md` se já existir (útil se o skel do sistema já criou um README) - `--no-quota` — não aplica `setquota` - `--require-quota` — falha antes de `adduser` se quota não estiver disponível diff --git a/scripts/del-user.md b/scripts/del-user.md @@ -6,7 +6,7 @@ Ferramenta para **administradores** removerem **permanentemente** um utilizador - **Não** remove nem altera configuração do Apache ou SSH globalmente. - Opcionalmente remove a entrada correspondente em `/var/lib/runv/users.json` (mesmo formato que `create_runv_user.py`). -- Remove o symlink **`/var/gemini/users/<user>`** se existir e for symlink (Gemini / molly-brown). +- **Gemini:** **umount** do bind em **`/var/gemini/users/<user>`** se estiver montado, remove a linha correspondente em **`/etc/fstab`**, remove symlink legado ou directório vazio. - Se a home estiver num **ext4** com **usrquota** ativo, tenta **`setquota`** para repor limites a zero **antes** de `deluser` (mount detetado automaticamente, mesma lógica que `create_runv_user.py` / `runv_mount.py`). Se `setquota` falhar, a remoção da conta continua com aviso em stderr. **Ambiente:** servidor **Linux** (Debian). Executar como **root** ou `sudo`. No Windows use só para revisão do código. diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md @@ -10,7 +10,7 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph | **Gopher** | `~/public_gopher/` | `gophermap` | `gopher://runv.club/1/~user` | | **Gemini** | `~/public_gemini/` | `index.gmi` | `gemini://runv.club/~user/` (canónico: path **`/~user/`**, tilde colado ao nome); `gemini://runv.club/~/user/` redirecciona (**v0.11+**) | -**Gemini (molly-brown):** `DocBase = /var/gemini`, `HomeDocBase = users`, symlinks **`/var/gemini/users/<user>` → `~/public_gemini`**. +**Gemini (molly-brown):** `DocBase = /var/gemini`, `HomeDocBase = users`. O conteúdo de **`~/public_gemini`** expõe-se em **`/var/gemini/users/<user>`** com **`mount --bind`** e linha em **`/etc/fstab`** (**v0.13+**). O pacote **Molly Debian** recusa **symlinks** cujo destino fica fora do DocBase (`Refusing to follow symlink … outside of DocBase!` no error log); por isso **não** se usam symlinks para `~/public_gemini`. ### Gopher vs Gemini: formato do endereço @@ -28,7 +28,7 @@ Apache (`mod_userdir`), **gophernicus** e **molly-brown** precisam de **execuç - **ACL (POSIX):** se `ls -l` mostrar **`+`** nos modos, há entradas **`getfacl`** além do `chmod`. Uma **mask** restritiva ou ausência de leitura efectiva para «other» / utilizador do serviço pode bloquear o Apache, gophernicus ou Molly mesmo com **`644`/`755`** aparentes. Diagnóstico: `getfacl ~/public_gemini/index.gmi` (e directórios no caminho). - **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 **v0.09** introduziu redirects Molly baseados numa leitura incorrecta do README upstream. O **v0.11** corrige **`[TempRedirects]`** para **`/~/user…` → `/~user…`** (alinhado ao `resolvePath` em Go). O **v0.12** documenta **ACL POSIX** na travessia e alarga o **WARNING** do `test -r` do `index.gmi` com indicação a `getfacl` quando `ls` mostra `+`. Validação **`test -r`** do `gophermap` com o utilizador do serviço gophernicus mantém-se. +- **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 **v0.09** introduziu redirects Molly baseados numa leitura incorrecta do README upstream. O **v0.11** corrige **`[TempRedirects]`** para **`/~/user…` → `/~user…`** (alinhado ao `resolvePath` em Go). O **v0.12** documenta **ACL POSIX** na travessia e alarga o **WARNING** do `test -r` do `index.gmi` com indicação a `getfacl` quando `ls` mostra `+`. O **v0.13** substitui **symlinks** Gemini por **bind mounts** + **`fstab`** (compatível com o Molly Debian). Validação **`test -r`** do `gophermap` com o utilizador do serviço gophernicus mantém-se. - **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+) @@ -51,23 +51,23 @@ Se o grupo **`ssl-cert`** não existir no sistema, o script regista **WARNING** ## Validação final (v0.09+) -No fim da execução, além de verificar ficheiros e symlink **como root**: +No fim da execução, além de verificar ficheiros e **bind mount** Gemini **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 (heurística: o unit Debian usa **`molly-brown`** dinâmico; ficheiros **`644`** e pastas **`755`** devem permitir leitura a «others» — ou **ACL** compatível; ver nota **ACL** na secção de travessia). Falha → **WARNING** (`public_gemini` `755`, `index.gmi` `644`, symlink `/var/gemini/users/<user>`). +- Se **`molly-brown@`** estiver **`active`**, tenta **`runuser -u www-data -- test -r`** no **`index.gmi`** da amostra (heurística: o unit Debian usa **`molly-brown`** dinâmico; ficheiros **`644`** e pastas **`755`** devem permitir leitura a «others» — ou **ACL** compatível; ver nota **ACL** na secção de travessia). Falha → **WARNING** (`public_gemini` `755`, `index.gmi` `644`, bind `/var/gemini/users/<user>`). Se a amostra ainda for **symlink**, regista **WARNING** de migração (**`--force`**). Em **`--dry-run`**, só regista os comandos. Sem **`runuser`** (util-linux), estes passos são omitidos. ## Utilizadores antigos vs novos -- **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). +- **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`, bind mounts Gemini). - **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. +- **Antigos / contas só `adduser`:** correr **`setup_alt_protocols.py`** (backfill completo) ou pastas/bind Gemini 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 - **TLS obrigatório** (certificado + chave PEM). Por defeito o script tenta Let's Encrypt em `/etc/letsencrypt/live/runv.club/`; use **`--gemini-cert`** e **`--gemini-key`** se forem noutro sítio. -- Sem certificados válidos, o script **não** ativa o serviço `molly-brown@`, mas pode criar `/var/gemini` e symlinks. +- Sem certificados válidos, o script **não** ativa o serviço `molly-brown@`, mas pode criar `/var/gemini` e aplicar bind mounts (e linhas `fstab` quando o mount corre). ## Erro `Error opening error log file: open /-` (read-only file system) @@ -119,6 +119,13 @@ Raro se o `.conf` aponta para `/var/lib/molly-brown/` e não há override que de - **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/`** (canónico); `gemini://runv.club/~/user/` deve redireccionar (**v0.11+** no `.conf`) **depois** de `systemctl is-active molly-brown@runv.club.service` devolver `active`. +## Gemini **51 Not found** e log «Refusing to follow symlink … outside of DocBase!» + +O **Molly Debian** não segue symlinks de `/var/gemini/users/<user>` para fora de `DocBase`. Com **symlink** antigo, o `index.gmi` pode existir e o `test -r` no script passar, mas o servidor devolve **51**. + +- **Correcção:** `sudo python3 scripts/admin/setup_alt_protocols.py --verbose --force` (**v0.13+**) migra para **`mount --bind`** e persiste em **`/etc/fstab`**. +- **Verificar:** `findmnt /var/gemini/users/<user>` e `grep gemini/users /etc/fstab`. + ## Execução (root) Use a **raiz do repositório** clonada; o script carrega `patches/patch_irc.py` para a lista de utilizadores (união JSON + `/home`). Sem esse ficheiro, o comando falha com mensagem explícita. @@ -139,7 +146,7 @@ sudo python3 scripts/admin/setup_alt_protocols.py --verbose | `--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. | -| `--skip-backfill` | Não cria pastas/symlinks por utilizador. | +| `--skip-backfill` | Não cria pastas nem bind mounts Gemini por utilizador. | | `--skip-services` | Não `systemctl enable --now`. | | `--skip-system-config` | Não escreve `/etc/default/gophernicus`, nem `molly-brown`, nem gophermap raiz. | | `--users-json PATH` | Parte da fonte de usernames (lista JSON com `username`). Predefinido: `/var/lib/runv/users.json`. | @@ -149,17 +156,17 @@ sudo python3 scripts/admin/setup_alt_protocols.py --verbose ## Descoberta de utilizadores (backfill) -A lista de contas para criar `~/public_gopher`, `~/public_gemini` e symlinks em `/var/gemini/users/` é a **união** de: +A lista de contas para criar `~/public_gopher`, `~/public_gemini` e **bind mounts** em `/var/gemini/users/` é a **união** de: 1. Usernames em **`users.json`** (lista de objetos com campo `username`), quando o ficheiro existe e o JSON é válido; e 2. Nomes em **`--homes-root`** com UID ≥ 1000 e entrada em `passwd`. -Depois aplicam-se as mesmas exclusões que em **`patches/patch_irc.py`** (`IRC_PATCH_SKIP_USERS` — contas de sistema, `entre`, etc.; **não** exclui `pmurad-admin` por defeito). Para só pastas/symlinks sem reinstalar serviços, pode usar **`patches/yetgg.py`**. +Depois aplicam-se as mesmas exclusões que em **`patches/patch_irc.py`** (`IRC_PATCH_SKIP_USERS` — contas de sistema, `entre`, etc.; **não** exclui `pmurad-admin` por defeito). Para só pastas/bind Gemini sem reinstalar serviços, pode usar **`patches/yetgg.py`**. ## Relação com outros scripts -- **`create_runv_user.py`**: após `public_html`, cria `public_gopher`, `public_gemini` e tenta o symlink em `/var/gemini/users/`. -- **`del-user.py`**: remove o symlink `/var/gemini/users/<user>` se existir e for symlink. +- **`create_runv_user.py`**: após `public_html`, cria `public_gopher`, `public_gemini` e aplica **bind mount** em `/var/gemini/users/<user>` (via `setup_alt_protocols`). +- **`del-user.py`**: **umount**, remove linha **`fstab`** de bind e remove symlink legado ou directório vazio em `/var/gemini/users/<user>`. - **`tools/tools.py`**: copia modelos para `/etc/skel` (só contas futuras). ## Testes manuais sugeridos @@ -171,7 +178,7 @@ Depois aplicam-se as mesmas exclusões que em **`patches/patch_irc.py`** (`IRC_P 5. `ufw status` (se ativo, confirmar 70/tcp e 1965/tcp permitidos) 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` +8. `ls -la /home/teste/public_gopher/gophermap /home/teste/public_gemini/index.gmi` e `findmnt /var/gemini/users/teste` (deve mostrar bind a `~/public_gemini`) 9. Cliente Gopher/Gemini: `gopher://runv.club/1/~teste` e **`gemini://runv.club/~teste/`** (canónico); opcionalmente `gemini://runv.club/~/teste/` → redirect **30** para o canónico (**v0.11+**) Versão do script: ver `python3 scripts/admin/setup_alt_protocols.py --version`.