commit 69278520d6af6e595187f1db676227c4865b89b5
parent 799a9dbcaf091d012dfcfde7de2beb9ba203856a
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 15:21:13 -0300
chat, gemini and gopher
Diffstat:
7 files changed, 220 insertions(+), 41 deletions(-)
diff --git a/patches/yetgg.py b/patches/yetgg.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""
+runv.club — backfill Gopher/Gemini para utilizadores já registados.
+
+Cria ``~/public_gopher``, ``~/public_gemini`` (modelos) e symlinks em
+``/var/gemini/users/<user>``, usando a **mesma lista de contas** que o IRC
+(união ``users.json`` + ``/home``, filtro ``IRC_PATCH_SKIP_USERS``).
+
+Não instala pacotes nem serviços; ver ``scripts/admin/setup_alt_protocols.py``.
+
+Executar como root em produção. Ver ``--help``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import Any, Final
+
+VERSION: Final[str] = "0.01"
+
+GEMINI_ROOT: Final[Path] = Path("/var/gemini")
+GEMINI_USERS: Final[Path] = GEMINI_ROOT / "users"
+
+
+def eprint(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def repo_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def load_script_module(name: str, path: Path) -> Any:
+ spec = importlib.util.spec_from_file_location(name, path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"não foi possível carregar {path}")
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def setup_logging(verbose: bool) -> logging.Logger:
+ logging.basicConfig(
+ level=logging.DEBUG if verbose else logging.INFO,
+ format="%(levelname)s: %(message)s",
+ )
+ return logging.getLogger("yetgg")
+
+
+def require_root(log: logging.Logger) -> None:
+ if os.geteuid() != 0:
+ log.error("Execute como root (sudo).")
+ raise SystemExit(1)
+
+
+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)
+ if dry_run:
+ log.info("[dry-run] mkdir -p %s %s (755 root:root)", GEMINI_ROOT, GEMINI_USERS)
+ return
+ GEMINI_ROOT.mkdir(parents=True, exist_ok=True)
+ GEMINI_USERS.mkdir(parents=True, exist_ok=True)
+ os.chmod(GEMINI_ROOT, 0o755)
+ os.chmod(GEMINI_USERS, 0o755)
+ try:
+ os.chown(GEMINI_ROOT, 0, 0)
+ os.chown(GEMINI_USERS, 0, 0)
+ except OSError as e:
+ log.warning("chown em %s / %s: %s", GEMINI_ROOT, GEMINI_USERS, e)
+ log.info("criado: %s e %s", GEMINI_ROOT, GEMINI_USERS)
+
+
+def parse_args(argv: list[str] | None) -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description="Backfill Gopher/Gemini por utilizador (lista como patch_irc).",
+ )
+ p.add_argument("--dry-run", action="store_true", help="só simular")
+ p.add_argument("--verbose", action="store_true", help="log detalhado")
+ p.add_argument("--force", action="store_true", help="sobrescrever modelos / symlinks (como setup_alt_protocols)")
+ p.add_argument(
+ "--users-json",
+ type=Path,
+ default=Path("/var/lib/runv/users.json"),
+ metavar="PATH",
+ )
+ p.add_argument(
+ "--homes-root",
+ type=Path,
+ default=Path("/home"),
+ metavar="PATH",
+ )
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
+ return p.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+ log = setup_logging(args.verbose)
+
+ if not args.dry_run:
+ require_root(log)
+ else:
+ log.info("dry-run: não grava alterações.")
+
+ root = repo_root()
+ patch_irc_path = root / "scripts" / "admin" / "patch_irc.py"
+ alt_path = root / "scripts" / "admin" / "setup_alt_protocols.py"
+ if not patch_irc_path.is_file():
+ log.error("ficheiro em falta: %s", patch_irc_path)
+ return 1
+ if not alt_path.is_file():
+ log.error("ficheiro em falta: %s", alt_path)
+ return 1
+
+ patch_irc = load_script_module("patch_irc_dynamic", patch_irc_path)
+ setup_alt = load_script_module("setup_alt_protocols_dynamic", alt_path)
+
+ 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
+
+ users = resolve_all_users(args.users_json, args.homes_root, log)
+ ensure_gemini_users_tree(dry_run=args.dry_run, log=log)
+
+ failures = 0
+ for username in users:
+ try:
+ ensure_user_public_dirs(
+ username,
+ args.homes_root,
+ force=args.force,
+ dry_run=args.dry_run,
+ log=log,
+ )
+ ensure_gemini_symlink(
+ username,
+ args.homes_root,
+ force=args.force,
+ dry_run=args.dry_run,
+ log=log,
+ )
+ except OSError as e:
+ log.error("%s: %s", username, e)
+ failures += 1
+
+ print()
+ print("========== yetgg — resumo ==========")
+ print(f"Modo: {'DRY-RUN' if args.dry_run else 'aplicação'}")
+ print(f"Utilizadores na lista: {len(users)} falhas: {failures}")
+ print(f"JSON: {args.users_json} homes: {args.homes_root}")
+ print("====================================")
+
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/admin/patch_irc.py b/scripts/admin/patch_irc.py
@@ -10,7 +10,7 @@ MOTD e runv-help referem apenas **chat** (sem expor outros nomes de comando ao u
Executar como root no Debian. Ver scripts/docs/irc_patch.md.
-Versão 0.01 — runv.club
+Versão 0.02 — runv.club
"""
from __future__ import annotations
@@ -37,7 +37,7 @@ from typing import Final
# /set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}"
# Documentação: https://weechat.org/doc/
-VERSION: Final[str] = "0.01"
+VERSION: Final[str] = "0.02"
DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json")
DEFAULT_HOMES_ROOT: Final[Path] = Path("/home")
@@ -341,6 +341,11 @@ def build_apply_command_chain(
parts.append(f'/set irc.server.{server}.autojoin "{autojoin}"')
else:
parts.append(f'/set irc.server.{server}.autojoin ""')
+ # Globais: ao entrar num canal, mudar para esse buffer; servidor IRC em buffer próprio;
+ # buflist só entradas do plugin IRC (menos ruído tipo core.weechat na árvore).
+ parts.append("/set irc.look.buffer_switch_join on")
+ parts.append("/set irc.look.server_buffer independent")
+ parts.append('/set buflist.look.display_conditions "${buffer.plugin} == irc"')
parts.append("/save")
parts.append("/quit")
return " ; ".join(parts)
@@ -452,35 +457,38 @@ def patch_user(
return False
irc_conf = weechat_config_dir(home) / "irc.conf"
- if (
- not force
- and config_matches(
- irc_conf,
- server=server,
- host=host,
- port=port,
- tls=tls,
- username=username,
- autojoin=autojoin,
- log=log,
- )
- ):
+ matched = config_matches(
+ irc_conf,
+ server=server,
+ host=host,
+ port=port,
+ tls=tls,
+ username=username,
+ autojoin=autojoin,
+ log=log,
+ )
+ if not force and matched:
log.info("%s: servidor %s já coincide com o desejado — a saltar", username, server)
return True
- if not force and irc_conf.is_file() and parse_server_options(
- irc_conf.read_text(encoding="utf-8", errors="replace"), server
- ).get("addresses"):
- log.warning(
- "%s: servidor %s existe mas difere do alvo; use --force para reconfigurar",
- username,
- server,
- )
- return False
+ server_exists = False
+ if irc_conf.is_file():
+ try:
+ conf_text = irc_conf.read_text(encoding="utf-8", errors="replace")
+ server_exists = bool(parse_server_options(conf_text, server).get("addresses"))
+ except OSError as e:
+ log.debug("%s: ler %s: %s", username, irc_conf, e)
- if force:
+ if server_exists and (force or not matched):
del_chain = f"/server del {server} ; /quit"
- log.info("%s: remover servidor %s existente (--force)", username, server)
+ if force:
+ log.info("%s: remover servidor %s existente (--force)", username, server)
+ else:
+ log.info(
+ "%s: realinhar servidor «%s» ao alvo (remove e volta a criar)",
+ username,
+ server,
+ )
run_weechat_script(
username=username,
home=home,
diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md
@@ -15,7 +15,7 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph
## 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).
-- **Antigos:** correr **`setup_alt_protocols.py`** (backfill) ou criar pastas/ficheiros à mão.
+- **Antigos:** correr **`setup_alt_protocols.py`** (backfill completo) ou só pastas/symlinks com **`patches/yetgg.py`** (mesma lista de contas que `patch_irc.py`: união JSON + `/home`) se a infraestrutura de sistema já existir.
## Requisitos Gemini
diff --git a/scripts/docs/irc_patch.md b/scripts/docs/irc_patch.md
@@ -19,9 +19,10 @@ sudo python3 admin/patch_irc.py --user alice --verbose
- Instala **`/usr/local/bin/chat`** (salvo `--skip-launcher`).
- Por utilizador: `~/.config/weechat/`, servidor interno **`runv`** (por defeito), nick = **username Unix**, nicks alternativos `user_`, `user__`, `user|away`, autojoin por defeito no canal **`#runv`**.
+- O patch aplica também **definições globais** WeeChat (na mesma sessão `weechat-headless`): `irc.look.buffer_switch_join` = `on` (ao entrar num canal, o buffer activo passa a ser esse canal), `irc.look.server_buffer` = `independent` (buffer do servidor separado), `buflist.look.display_conditions` = ``${buffer.plugin} == irc`` (buflist só lista buffers do plugin IRC, reduzindo ruído tipo `core.weechat`). Se o teu WeeChat for muito antigo ou sem plugin buflist, o `/set buflist.*` pode falhar — rever a versão ou ajustar à mão.
- Com **`--all-users`**, a lista de contas é a **união** de: usernames em `users.json` **e** utilizadores com diretório em `--homes-root` (por omissão `/home`), UID ≥ 1000 e fora da lista interna de contas de sistema — assim contas de administração (ex.: `pmurad-admin`) que não estão no JSON também são provisionadas.
- Exige **`weechat-headless`** no sistema para aplicar o patch; sem esse binário o script falha com mensagem clara (`apt install weechat-headless`).
-- Configurações já aplicadas sem o canal comum: voltar a correr com **`--force`** para atualizar `autojoin` (e restantes opções) para os defaults atuais.
+- Se a config **já existe** mas **não coincide** com o alvo (host, TLS, nicks, autojoin, etc.), o patch **realinha** sozinho (`/server del` + voltar a criar). Não é obrigatório **`--force`** para isso. **`--force`** serve para **reaplicar mesmo quando já estava alinhada** (útil para repor estado conhecido).
## O que o utilizador faz
@@ -45,7 +46,7 @@ Não há SASL/NickServ automático; no código há comentários para extensão f
## Flags úteis
-- `--dry-run`, `--verbose`, `--force`
+- `--dry-run`, `--verbose`, `--force` (reaplica mesmo com config já igual ao alvo)
- `--skip-launcher`, `--skip-backfill`
- `--users-json`, `--homes-root`
- `--user` **ou** `--all-users` (obrigatório um dos dois)
diff --git a/tools/docs/INSTALL.md b/tools/docs/INSTALL.md
@@ -120,3 +120,4 @@ Novas contas criadas com `adduser` **depois** desta instalação recebem esses a
- **apt-get update falha:** corrija espelhos/rede; o script registra erro e ainda pode copiar bin/MOTD/skel.
- **Permissão negada:** execute com `sudo` / root.
- **MOTD não aparece:** em alguns setups o display do MOTD depende de `pam_motd` e SSH; confira configuração do `sshd` e PAM no Debian.
+- **MOTD sem grelha `last`:** o fragmento `60-runv` usa `/usr/bin/last` quando o PATH mínimo não o expõe; confirme **util-linux** e permissões de leitura em `/var/log/wtmp`. A mensagem *sem registos recentes em wtmp* indica wtmp vazio, não falta do binário.
diff --git a/tools/docs/USER_EXPERIENCE.md b/tools/docs/USER_EXPERIENCE.md
@@ -8,7 +8,7 @@ Visão para **quem entra no servidor** pela primeira vez (e para quem documenta
- logótipo **RUNV** (mesmo desenho UTF-8 da landing) **só nesse bloco** em verde;
- tagline `.club — um computador para compartilhar` (sem estatísticas no MOTD; o comando **`runv-status`** existe mas **não** é listado aqui e só o utilizador **`pmurad-admin`** pode executá-lo);
- **Comandos úteis** em lista, com nome a verde e descrição a cinza (ANSI), alinhada ao texto do `runv-help`;
- - grelha **3×3** com os **primeiros campos** das **9** sessões mais recentes de **`last`** (wtmp; ignora linhas `reboot` / `wtmp`);
+ - grelha **3×3** com os **primeiros campos** das **9** sessões mais recentes de **`last`** (wtmp; ignora linhas `reboot` / `wtmp`). O script tenta **`/usr/bin/last`** se o PATH de `update-motd.d` não incluir `last` (pacote **util-linux**). Se aparecer *sem registos recentes em wtmp*, o ficheiro de logins ainda não tem entradas (ex.: sem logins SSH registados).
- linha final: **digite `runv-help` para começar**.
2. **Prompt da shell** — Depende do shell padrão (geralmente Bash no Debian). O que o usuário **herda** da home vem do **`/etc/skel`** no momento em que a conta foi criada.
diff --git a/tools/motd/60-runv b/tools/motd/60-runv
@@ -28,15 +28,19 @@ RUNV_ART
}
# Últimas 9 sessões em last(1) → grelha 3×3 (primeiro campo = utilizador)
+# update-motd.d costuma ter PATH mínimo: tentar /usr/bin/last e /bin/last.
print_last_sessions_3x3() {
- if ! command -v last >/dev/null 2>&1; then
- printf ' %b\n' "${D}(comando last indisponível)${R}"
+ LAST_CMD=$(command -v last 2>/dev/null) || LAST_CMD=
+ [ -z "$LAST_CMD" ] && [ -x /usr/bin/last ] && LAST_CMD=/usr/bin/last
+ [ -z "$LAST_CMD" ] && [ -x /bin/last ] && LAST_CMD=/bin/last
+ if [ -z "$LAST_CMD" ]; then
+ printf ' %b\n' "${D}(comando last indisponível — instale util-linux ou ajuste o PATH)${R}"
return
fi
tf=$(mktemp -t runvmotd.XXXXXX 2>/dev/null) || tf=/tmp/runvmotd.$$
trap 'rm -f "$tf"' EXIT HUP INT
# Ignora reboot, wtmp e linhas vazias; até 9 utilizadores (sessões recentes)
- last -n 200 2>/dev/null | awk '
+ "$LAST_CMD" -n 200 2>/dev/null | awk '
/^reboot/ || /^wtmp/ || /^$/ { next }
NF < 1 { next }
{ print $1; if (++n >= 9) exit }
@@ -69,14 +73,15 @@ print_runv_art
printf '\n%s\n' '.club — um computador para compartilhar'
printf '\n%bComandos úteis%b\n' "${B}" "${R}"
-printf ' %brunv-help%b %b—%b ajuda e boas práticas do runv.club.\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %brunv-links%b %b—%b links do site e do mantenedor.\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %blynx%b %b—%b navegador web no terminal.\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %btmux%b %b—%b multiplexador de terminal (várias sessões).\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %bbyobu%b %b—%b barra de estado e atalhos sobre tmux/screen.\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %bmutt%b %b—%b cliente de e-mail no terminal.\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %bchat%b %b—%b IRC da rede da casa.\n' "${G}" "${R}" "${D}" "${R}"
-printf ' %badventure%b %b—%b jogo de aventura em texto (bsdgames).\n' "${G}" "${R}" "${D}" "${R}"
+# Coluna do nome do comando alinhada (maior: runv-links = 10 chars)
+printf ' %b%-12s%b %b—%b ajuda e boas práticas do runv.club.\n' "${G}" "runv-help" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b links do site e do mantenedor.\n' "${G}" "runv-links" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b navegador web no terminal.\n' "${G}" "lynx" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b multiplexador de terminal (várias sessões).\n' "${G}" "tmux" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b barra de estado e atalhos sobre tmux/screen.\n' "${G}" "byobu" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b cliente de e-mail no terminal.\n' "${G}" "mutt" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b IRC da rede da casa.\n' "${G}" "chat" "${R}" "${D}" "${R}"
+printf ' %b%-12s%b %b—%b jogo de aventura em texto (bsdgames).\n' "${G}" "adventure" "${R}" "${D}" "${R}"
print_last_sessions_3x3