runv-server

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

commit 69278520d6af6e595187f1db676227c4865b89b5
parent 799a9dbcaf091d012dfcfde7de2beb9ba203856a
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 15:21:13 -0300

chat, gemini and gopher

Diffstat:
Apatches/yetgg.py | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/admin/patch_irc.py | 60++++++++++++++++++++++++++++++++++--------------------------
Mscripts/docs/alt_protocols.md | 2+-
Mscripts/docs/irc_patch.md | 5+++--
Mtools/docs/INSTALL.md | 1+
Mtools/docs/USER_EXPERIENCE.md | 2+-
Mtools/motd/60-runv | 27++++++++++++++++-----------
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