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:
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`.