runv-server

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

commit 546718851148f54da9c0791061ed369c543efaf1
parent 69278520d6af6e595187f1db676227c4865b89b5
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 15:30:26 -0300

chat, gemini and gopher

Diffstat:
MREADME.md | 2+-
Apatches/patch_irc.py | 673+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpatches/yetgg.py | 2+-
Dscripts/admin/patch_irc.py | 671-------------------------------------------------------------------------------
Mscripts/admin/setup_alt_protocols.py | 156++++++++++++++++++++++++++++++++++---------------------------------------------
Mscripts/docs/alt_protocols.md | 22++++++++++++++++++----
Mscripts/docs/irc_patch.md | 16++++++++--------
Mtools/README.md | 2+-
Mtools/bin/chat | 6+++---
Mtools/docs/INSTALL.md | 1+
Mtools/docs/USER_EXPERIENCE.md | 2+-
11 files changed, 774 insertions(+), 779 deletions(-)

diff --git a/README.md b/README.md @@ -8,7 +8,7 @@ Repositório de scripts e documentação para o servidor **runv.club** (Debian, |------|-----------| | **`scripts/admin/create_runv_user.py`** | Provisiona contas Unix: SSH, `~/public_html` (HTTP), **`~/public_gopher`** (Gopher), **`~/public_gemini`** (Gemini), symlink em `/var/gemini/users/`, README, quota, metadados. | | **`scripts/admin/setup_alt_protocols.py`** | Instala/configura **gophernicus** (porta 70) e **molly-brown** (Gemini, TLS, porta 1965), UFW se ativo, backfill para utilizadores existentes. Ver **`scripts/docs/alt_protocols.md`**. | -| **`scripts/admin/patch_irc.py`** | IRC (estilo tilde.club): comando **`chat`** para utilizadores; rede por defeito `irc.portalidea.com.br`. Ver **`scripts/docs/irc_patch.md`**. | +| **`patches/patch_irc.py`** | IRC (estilo tilde.club): comando **`chat`** para utilizadores; rede por defeito `irc.portalidea.com.br`. Ver **`scripts/docs/irc_patch.md`**. | | **`tools/tools.py`** | Pacotes globais (incl. IRC), MOTD, `/usr/local/bin` (**`chat`**, `runv-help`, …), **`/etc/skel`**. | | **`terminal/`** | Fluxo SSH «entre» (pedidos de conta). | diff --git a/patches/patch_irc.py b/patches/patch_irc.py @@ -0,0 +1,673 @@ +#!/usr/bin/env python3 +""" +Provisiona a rede IRC da casa (estilo tilde.club) e o comando «chat» para utilizadores. + +- Config em ~/.config/weechat (XDG), servidor interno «runv», autoconnect. +- Aplicação **só** via binário ``weechat-headless`` (-a, -r, --stdout); não usar cliente interactivo no patch. +- Instala /usr/local/bin/chat (launcher) salvo --skip-launcher. + +MOTD e runv-help referem apenas **chat** (sem expor outros nomes de comando ao utilizador). + +Executar como root no Debian. Ver scripts/docs/irc_patch.md. + +Versão 0.03 — runv.club +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import pwd +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Final + +# --------------------------------------------------------------------------- +# SASL / NickServ (futuro) +# --------------------------------------------------------------------------- +# Não gravar senhas em texto plano. Para SASL, usar depois comandos WeeChat + dados +# seguros (sec.conf), por exemplo: +# /set irc.server.<name>.sasl_mechanism plain +# /secure set runv_irc_senha ... +# /set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}" +# Documentação: https://weechat.org/doc/ + +VERSION: Final[str] = "0.03" + +DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") +DEFAULT_HOMES_ROOT: Final[Path] = Path("/home") +DEFAULT_HOST: Final[str] = "irc.portalidea.com.br" +DEFAULT_SERVER_NAME: Final[str] = "runv" +DEFAULT_AUTOJOIN: Final[str] = "#runv" + +MIN_UID_USER: Final[int] = 1000 + +IRC_PATCH_SKIP_USERS: Final[frozenset[str]] = frozenset( + { + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "_apt", + "nobody", + "entre", + "admin", + "postmaster", + } +) + +CHAT_DEST: Final[Path] = Path("/usr/local/bin/chat") + + +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("patch_irc") + + +def require_root(log: logging.Logger) -> None: + if os.geteuid() != 0: + log.error("Execute como root (sudo).") + sys.exit(1) + + +def run_cmd( + cmd: list[str], + *, + dry_run: bool, + log: logging.Logger, + timeout: int = 180, +) -> subprocess.CompletedProcess[str] | None: + log.debug("exec: %s", " ".join(cmd)) + if dry_run: + log.info("[dry-run] %s", " ".join(cmd)) + return None + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def launcher_source_path() -> Path: + return repo_root() / "tools" / "bin" / "chat" + + +def embedded_launcher_text() -> str: + return """#!/bin/sh +# runv.club — fallback mínimo (preferir tools/bin/chat do repositório) +IRC_UI="" +for c in weechat weechat-curses; do + command -v "$c" >/dev/null 2>&1 && IRC_UI=$c && break +done +if [ -z "$IRC_UI" ]; then + echo "runv: cliente IRC interactivo não encontrado; corra tools/tools.py." >&2 + exit 127 +fi +CONFIG_DIR="${WEECHAT_HOME:-$HOME/.config/weechat}" +exec "$IRC_UI" -d "$CONFIG_DIR" "$@" +""" + + +def install_chat_launcher(*, dry_run: bool, log: logging.Logger) -> bool: + src = launcher_source_path() + if dry_run: + log.info("[dry-run] instalaria %s -> %s", src if src.is_file() else "(embutido)", CHAT_DEST) + return True + CHAT_DEST.parent.mkdir(parents=True, exist_ok=True) + if src.is_file(): + shutil.copy2(src, CHAT_DEST) + else: + log.warning("origem %s inexistente; escrevo launcher mínimo embutido", src) + CHAT_DEST.write_text(embedded_launcher_text(), encoding="utf-8") + os.chmod(CHAT_DEST, 0o755) + try: + os.chown(CHAT_DEST, 0, 0) + except OSError as e: + log.warning("chown em %s: %s", CHAT_DEST, e) + log.info("launcher: %s", CHAT_DEST) + return True + + +def find_weechat_headless(log: logging.Logger) -> str | None: + """Apenas weechat-headless — o patch não usa cliente interactivo.""" + p = shutil.which("weechat-headless") + if p: + log.debug("binário de provisionamento IRC: %s", p) + return p + + +def load_usernames_from_json(path: Path, log: logging.Logger) -> list[str] | None: + if not path.is_file(): + return None + try: + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return [] + data = json.loads(raw) + if not isinstance(data, list): + log.warning("%s: JSON não é lista; ignoro.", path) + return None + names: list[str] = [] + for item in data: + if isinstance(item, dict): + u = item.get("username") + if isinstance(u, str) and u: + names.append(u) + return sorted(set(names)) + except (json.JSONDecodeError, OSError) as e: + log.warning("falha ao ler %s: %s — uso fallback /home", path, e) + return None + + +def usernames_from_homes(homes_root: Path, log: logging.Logger) -> list[str]: + names: list[str] = [] + if not homes_root.is_dir(): + log.warning("homes_root inexistente: %s", homes_root) + return [] + for entry in sorted(homes_root.iterdir()): + if not entry.is_dir() or entry.name.startswith("."): + continue + try: + pw = pwd.getpwnam(entry.name) + except KeyError: + continue + if pw.pw_uid < MIN_UID_USER: + continue + if entry.name in IRC_PATCH_SKIP_USERS: + continue + names.append(entry.name) + return sorted(set(names)) + + +def resolve_all_users(users_json: Path, homes_root: Path, log: logging.Logger) -> list[str]: + from_json = load_usernames_from_json(users_json, log) + from_homes = usernames_from_homes(homes_root, log) + + if from_json is None: + log.info("utilizadores a partir de %s (%d); JSON indisponível", homes_root, len(from_homes)) + return from_homes + + if not from_json: + log.info("%s vazio — só homes em %s (%d)", users_json, homes_root, len(from_homes)) + return from_homes + + merged = sorted(set(from_json) | set(from_homes)) + log.info( + "utilizadores: união %s (%d) + %s (%d) → %d contas", + users_json, + len(from_json), + homes_root, + len(from_homes), + len(merged), + ) + return [u for u in merged if u not in IRC_PATCH_SKIP_USERS] + + +def weechat_config_dir(home: Path) -> Path: + return home / ".config" / "weechat" + + +def parse_server_options(irc_conf_text: str, server: str) -> dict[str, str]: + opts: dict[str, str] = {} + in_server = False + prefix = f"{server}." + for raw in irc_conf_text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line == "[server]": + in_server = True + continue + if line.startswith("[") and line.endswith("]"): + in_server = False + continue + if not in_server: + continue + if not line.startswith(prefix): + continue + key_part, _, rest = line.partition("=") + key_part = key_part.strip() + val = rest.strip() + if len(key_part) <= len(prefix): + continue + sub = key_part[len(prefix) :] + if val.startswith('"') and val.endswith('"') and len(val) >= 2: + val = val[1:-1] + opts[sub] = val + return opts + + +def tls_effective(opts: dict[str, str]) -> bool: + v = (opts.get("tls") or opts.get("ssl") or "off").lower() + return v in ("on", "true", "yes", "1") + + +def expected_nicks(username: str) -> str: + return f"{username},{username}_,{username}__,{username}|away" + + +def config_matches( + irc_conf: Path, + *, + server: str, + host: str, + port: int, + tls: bool, + username: str, + autojoin: str, + log: logging.Logger, +) -> bool: + if not irc_conf.is_file(): + return False + try: + text = irc_conf.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log.debug("ler %s: %s", irc_conf, e) + return False + opts = parse_server_options(text, server) + if "addresses" not in opts: + return False + addr = opts["addresses"].lower() + expect_addr = f"{host.lower()}/{port}" + if addr != expect_addr: + log.debug("addresses %r != %r", addr, expect_addr) + return False + if tls_effective(opts) != tls: + log.debug("tls/ssl diverge") + return False + if opts.get("nicks") != expected_nicks(username): + log.debug("nicks divergem") + return False + if (opts.get("username") or "") != username: + return False + if (opts.get("realname") or "") != username: + return False + ac = (opts.get("autoconnect") or "off").lower() + if ac not in ("on", "true", "yes", "1"): + return False + aj = opts.get("autojoin") or "" + if aj != autojoin: + log.debug("autojoin %r != %r", aj, autojoin) + return False + return True + + +def build_apply_command_chain( + *, + server: str, + host: str, + port: int, + tls: bool, + username: str, + autojoin: str, +) -> str: + add_tokens = [f"/server add {server} {host}/{port}"] + if tls: + add_tokens.append("-tls") + add_tokens.append("-autoconnect") + parts: list[str] = [" ".join(add_tokens)] + nicks = expected_nicks(username) + parts.append(f'/set irc.server.{server}.nicks "{nicks}"') + parts.append(f'/set irc.server.{server}.username "{username}"') + parts.append(f'/set irc.server.{server}.realname "{username}"') + parts.append(f"/set irc.server.{server}.autoconnect on") + if autojoin: + 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ó canais IRC (#runv, …) — esconde buffer do servidor «runv» e core.weechat na lista. + 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 && ${type} == channel"' + ) + parts.append("/save") + parts.append("/quit") + return " ; ".join(parts) + + +def ensure_xdg_weechat_dir(home: Path, uid: int, gid: int, log: logging.Logger, dry_run: bool) -> Path: + xdg = home / ".config" + weechat_d = weechat_config_dir(home) + if dry_run: + log.info("[dry-run] garantiria dirs %s e %s (700, dono %d:%d)", xdg, weechat_d, uid, gid) + return weechat_d + if not home.is_dir(): + raise FileNotFoundError(f"home inexistente: {home}") + if not xdg.is_dir(): + xdg.mkdir(parents=True, exist_ok=True) + os.chmod(xdg, 0o700) + os.chown(xdg, uid, gid) + elif xdg.stat().st_uid != uid: + log.warning("%s não pertence a uid %d; não altero dono do .config inteiro", xdg, uid) + if not weechat_d.is_dir(): + weechat_d.mkdir(parents=True, exist_ok=True) + os.chmod(weechat_d, 0o700) + os.chown(weechat_d, uid, gid) + else: + os.chmod(weechat_d, 0o700) + try: + os.chown(weechat_d, uid, gid) + except OSError as e: + log.warning("chown %s: %s", weechat_d, e) + return weechat_d + + +def run_weechat_script( + *, + username: str, + home: Path, + weechat_bin: str, + command_chain: str, + dry_run: bool, + log: logging.Logger, + allow_failure: bool = False, +) -> bool: + runuser = shutil.which("runuser") + if not runuser: + log.error("runuser não encontrado (pacote util-linux).") + return False + weechat_dir = weechat_config_dir(home) + cmd: list[str] = [ + runuser, + "-u", + username, + "--", + weechat_bin, + "-d", + str(weechat_dir), + "-a", + "--stdout", + "-r", + command_chain, + ] + r = run_cmd(cmd, dry_run=dry_run, log=log) + if dry_run: + return True + assert r is not None + out = (r.stdout or "") + (r.stderr or "") + if r.returncode != 0: + msg = f"weechat-headless código {r.returncode} para {username}: {out.strip() or '(sem saída)'}" + if allow_failure: + log.debug("%s (ignorado)", msg) + return True + log.error("%s", msg) + return False + if out.strip(): + log.debug("weechat-headless saída (%s): %s", username, out.strip()[:2000]) + return True + + +def patch_user( + username: str, + *, + host: str, + port: int, + tls: bool, + server: str, + autojoin: str, + force: bool, + weechat_bin: str, + dry_run: bool, + log: logging.Logger, +) -> bool: + try: + pw = pwd.getpwnam(username) + except KeyError: + log.error("utilizador inexistente: %s", username) + return False + if username in IRC_PATCH_SKIP_USERS: + log.warning("utilizador reservado, ignorado: %s", username) + return False + if pw.pw_uid < MIN_UID_USER: + log.warning("UID < %d, ignorado: %s", MIN_UID_USER, username) + return False + + home = Path(pw.pw_dir) + uid, gid = pw.pw_uid, pw.pw_gid + try: + ensure_xdg_weechat_dir(home, uid, gid, log, dry_run) + except OSError as e: + log.error("%s: %s", username, e) + return False + + irc_conf = weechat_config_dir(home) / "irc.conf" + 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 + + 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 server_exists and (force or not matched): + del_chain = f"/server del {server} ; /quit" + 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, + weechat_bin=weechat_bin, + command_chain=del_chain, + dry_run=dry_run, + log=log, + allow_failure=True, + ) + + chain = build_apply_command_chain( + server=server, + host=host, + port=port, + tls=tls, + username=username, + autojoin=autojoin, + ) + log.info("%s: aplicar configuração IRC — servidor «%s» (weechat-headless)", username, server) + ok = run_weechat_script( + username=username, + home=home, + weechat_bin=weechat_bin, + command_chain=chain, + dry_run=dry_run, + log=log, + ) + if not ok: + return False + if not dry_run and irc_conf.is_file(): + try: + os.chown(irc_conf, uid, gid) + except OSError: + pass + return True + + +def validate_post( + sample_user: str | None, + server: str, + log: logging.Logger, +) -> None: + if not CHAT_DEST.is_file() or not os.access(CHAT_DEST, os.X_OK): + log.warning("validação: %s em falta ou não executável", CHAT_DEST) + else: + log.info("validação: launcher %s OK", CHAT_DEST) + if not sample_user: + return + try: + pw = pwd.getpwnam(sample_user) + except KeyError: + return + irc_conf = weechat_config_dir(Path(pw.pw_dir)) / "irc.conf" + if not irc_conf.is_file(): + log.warning("validação: %s sem %s", sample_user, irc_conf) + return + txt = irc_conf.read_text(encoding="utf-8", errors="replace") + if re.search(rf"^{re.escape(server)}\.addresses\s*=", txt, re.MULTILINE): + log.info("validação: %s tem %s.addresses em %s", sample_user, server, irc_conf) + else: + log.warning("validação: %s.addresses não encontrado em %s", server, irc_conf) + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Provisiona IRC (servidor runv, weechat-headless) e instala o comando chat.", + ) + p.add_argument("--dry-run", action="store_true", help="só mostrar o plano") + p.add_argument("--verbose", action="store_true", help="log detalhado") + p.add_argument("--force", action="store_true", help="reconfigurar mesmo se existir servidor divergente") + p.add_argument("--skip-launcher", action="store_true", help="não instalar /usr/local/bin/chat") + p.add_argument("--skip-backfill", action="store_true", help="não aplicar config por utilizador") + p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON, metavar="PATH") + p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT, metavar="PATH") + p.add_argument("--host", default=DEFAULT_HOST, help="hostname IRC") + p.add_argument( + "--port", + type=int, + default=None, + metavar="PORT", + help="porta (omissão: 6697 com TLS, 6667 sem TLS)", + ) + tls_g = p.add_mutually_exclusive_group() + tls_g.add_argument("--tls", dest="tls", action="store_true", help="usar TLS (padrão)") + tls_g.add_argument("--no-tls", dest="tls", action="store_false", help="IRC sem TLS") + p.set_defaults(tls=True) + p.add_argument( + "--server-name", + default=DEFAULT_SERVER_NAME, + metavar="NAME", + help="nome interno na config IRC (equivalente a /server add …)", + ) + p.add_argument( + "--autojoin", + default=DEFAULT_AUTOJOIN, + metavar="CHANNELS", + help=( + f'canais separados por vírgula (padrão: {DEFAULT_AUTOJOIN!r}); ' + 'use --autojoin "" para não autoentrar em canais' + ), + ) + ug = p.add_mutually_exclusive_group(required=True) + ug.add_argument("--user", metavar="USER", help="apenas este utilizador Unix") + ug.add_argument("--all-users", action="store_true", help="todos os utilizadores válidos") + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + log = setup_logging(args.verbose) + + if args.port is None: + port = 6697 if args.tls else 6667 + else: + port = args.port + + if not args.dry_run: + require_root(log) + else: + log.info("dry-run: não grava alterações.") + + if not args.skip_launcher: + install_chat_launcher(dry_run=args.dry_run, log=log) + + weechat_bin = find_weechat_headless(log) + if not args.skip_backfill and not weechat_bin: + log.error( + "weechat-headless não encontrado no PATH; instale o pacote Debian «weechat-headless» (ex.: apt).", + ) + return 1 + + if args.all_users: + users = resolve_all_users(args.users_json, args.homes_root, log) + else: + assert args.user is not None + users = [args.user] + + failures = 0 + if not args.skip_backfill: + assert weechat_bin is not None + for u in users: + if u in IRC_PATCH_SKIP_USERS: + log.warning("ignorado (reservado): %s", u) + continue + ok = patch_user( + u, + host=args.host, + port=port, + tls=args.tls, + server=args.server_name, + autojoin=args.autojoin.strip(), + force=args.force, + weechat_bin=weechat_bin, + dry_run=args.dry_run, + log=log, + ) + if not ok: + failures += 1 + else: + log.info("backfill ignorado (--skip-backfill).") + + sample = users[0] if users else None + validate_post(sample, args.server_name, log) + + print() + print("========== patch_irc — resumo ==========") + print(f"Modo: {'DRY-RUN' if args.dry_run else 'aplicação'}") + print(f"Host: {args.host}:{port} TLS: {args.tls} servidor na config: {args.server_name}") + aj = args.autojoin.strip() + print(f"Autojoin: {aj if aj else '(nenhum)'}") + if not args.skip_backfill: + print(f"Utilizadores processados: {len(users)} falhas: {failures}") + print("Comando para utilizadores: chat") + print("========================================") + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/patches/yetgg.py b/patches/yetgg.py @@ -110,7 +110,7 @@ def main(argv: list[str] | None = None) -> int: log.info("dry-run: não grava alterações.") root = repo_root() - patch_irc_path = root / "scripts" / "admin" / "patch_irc.py" + patch_irc_path = root / "patches" / "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) diff --git a/scripts/admin/patch_irc.py b/scripts/admin/patch_irc.py @@ -1,671 +0,0 @@ -#!/usr/bin/env python3 -""" -Provisiona a rede IRC da casa (estilo tilde.club) e o comando «chat» para utilizadores. - -- Config em ~/.config/weechat (XDG), servidor interno «runv», autoconnect. -- Aplicação **só** via binário ``weechat-headless`` (-a, -r, --stdout); não usar cliente interactivo no patch. -- Instala /usr/local/bin/chat (launcher) salvo --skip-launcher. - -MOTD e runv-help referem apenas **chat** (sem expor outros nomes de comando ao utilizador). - -Executar como root no Debian. Ver scripts/docs/irc_patch.md. - -Versão 0.02 — runv.club -""" - -from __future__ import annotations - -import argparse -import json -import logging -import os -import pwd -import re -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Final - -# --------------------------------------------------------------------------- -# SASL / NickServ (futuro) -# --------------------------------------------------------------------------- -# Não gravar senhas em texto plano. Para SASL, usar depois comandos WeeChat + dados -# seguros (sec.conf), por exemplo: -# /set irc.server.<name>.sasl_mechanism plain -# /secure set runv_irc_senha ... -# /set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}" -# Documentação: https://weechat.org/doc/ - -VERSION: Final[str] = "0.02" - -DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") -DEFAULT_HOMES_ROOT: Final[Path] = Path("/home") -DEFAULT_HOST: Final[str] = "irc.portalidea.com.br" -DEFAULT_SERVER_NAME: Final[str] = "runv" -DEFAULT_AUTOJOIN: Final[str] = "#runv" - -MIN_UID_USER: Final[int] = 1000 - -IRC_PATCH_SKIP_USERS: Final[frozenset[str]] = frozenset( - { - "root", - "daemon", - "bin", - "sys", - "sync", - "games", - "man", - "lp", - "mail", - "news", - "uucp", - "proxy", - "www-data", - "backup", - "list", - "irc", - "_apt", - "nobody", - "entre", - "admin", - "postmaster", - } -) - -CHAT_DEST: Final[Path] = Path("/usr/local/bin/chat") - - -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("patch_irc") - - -def require_root(log: logging.Logger) -> None: - if os.geteuid() != 0: - log.error("Execute como root (sudo).") - sys.exit(1) - - -def run_cmd( - cmd: list[str], - *, - dry_run: bool, - log: logging.Logger, - timeout: int = 180, -) -> subprocess.CompletedProcess[str] | None: - log.debug("exec: %s", " ".join(cmd)) - if dry_run: - log.info("[dry-run] %s", " ".join(cmd)) - return None - return subprocess.run( - cmd, - check=False, - capture_output=True, - text=True, - timeout=timeout, - ) - - -def repo_root() -> Path: - return Path(__file__).resolve().parent.parent.parent - - -def launcher_source_path() -> Path: - return repo_root() / "tools" / "bin" / "chat" - - -def embedded_launcher_text() -> str: - return """#!/bin/sh -# runv.club — fallback mínimo (preferir tools/bin/chat do repositório) -IRC_UI="" -for c in weechat weechat-curses; do - command -v "$c" >/dev/null 2>&1 && IRC_UI=$c && break -done -if [ -z "$IRC_UI" ]; then - echo "runv: cliente IRC interactivo não encontrado; corra tools/tools.py." >&2 - exit 127 -fi -CONFIG_DIR="${WEECHAT_HOME:-$HOME/.config/weechat}" -exec "$IRC_UI" -d "$CONFIG_DIR" "$@" -""" - - -def install_chat_launcher(*, dry_run: bool, log: logging.Logger) -> bool: - src = launcher_source_path() - if dry_run: - log.info("[dry-run] instalaria %s -> %s", src if src.is_file() else "(embutido)", CHAT_DEST) - return True - CHAT_DEST.parent.mkdir(parents=True, exist_ok=True) - if src.is_file(): - shutil.copy2(src, CHAT_DEST) - else: - log.warning("origem %s inexistente; escrevo launcher mínimo embutido", src) - CHAT_DEST.write_text(embedded_launcher_text(), encoding="utf-8") - os.chmod(CHAT_DEST, 0o755) - try: - os.chown(CHAT_DEST, 0, 0) - except OSError as e: - log.warning("chown em %s: %s", CHAT_DEST, e) - log.info("launcher: %s", CHAT_DEST) - return True - - -def find_weechat_headless(log: logging.Logger) -> str | None: - """Apenas weechat-headless — o patch não usa cliente interactivo.""" - p = shutil.which("weechat-headless") - if p: - log.debug("binário de provisionamento IRC: %s", p) - return p - - -def load_usernames_from_json(path: Path, log: logging.Logger) -> list[str] | None: - if not path.is_file(): - return None - try: - raw = path.read_text(encoding="utf-8").strip() - if not raw: - return [] - data = json.loads(raw) - if not isinstance(data, list): - log.warning("%s: JSON não é lista; ignoro.", path) - return None - names: list[str] = [] - for item in data: - if isinstance(item, dict): - u = item.get("username") - if isinstance(u, str) and u: - names.append(u) - return sorted(set(names)) - except (json.JSONDecodeError, OSError) as e: - log.warning("falha ao ler %s: %s — uso fallback /home", path, e) - return None - - -def usernames_from_homes(homes_root: Path, log: logging.Logger) -> list[str]: - names: list[str] = [] - if not homes_root.is_dir(): - log.warning("homes_root inexistente: %s", homes_root) - return [] - for entry in sorted(homes_root.iterdir()): - if not entry.is_dir() or entry.name.startswith("."): - continue - try: - pw = pwd.getpwnam(entry.name) - except KeyError: - continue - if pw.pw_uid < MIN_UID_USER: - continue - if entry.name in IRC_PATCH_SKIP_USERS: - continue - names.append(entry.name) - return sorted(set(names)) - - -def resolve_all_users(users_json: Path, homes_root: Path, log: logging.Logger) -> list[str]: - from_json = load_usernames_from_json(users_json, log) - from_homes = usernames_from_homes(homes_root, log) - - if from_json is None: - log.info("utilizadores a partir de %s (%d); JSON indisponível", homes_root, len(from_homes)) - return from_homes - - if not from_json: - log.info("%s vazio — só homes em %s (%d)", users_json, homes_root, len(from_homes)) - return from_homes - - merged = sorted(set(from_json) | set(from_homes)) - log.info( - "utilizadores: união %s (%d) + %s (%d) → %d contas", - users_json, - len(from_json), - homes_root, - len(from_homes), - len(merged), - ) - return [u for u in merged if u not in IRC_PATCH_SKIP_USERS] - - -def weechat_config_dir(home: Path) -> Path: - return home / ".config" / "weechat" - - -def parse_server_options(irc_conf_text: str, server: str) -> dict[str, str]: - opts: dict[str, str] = {} - in_server = False - prefix = f"{server}." - for raw in irc_conf_text.splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if line == "[server]": - in_server = True - continue - if line.startswith("[") and line.endswith("]"): - in_server = False - continue - if not in_server: - continue - if not line.startswith(prefix): - continue - key_part, _, rest = line.partition("=") - key_part = key_part.strip() - val = rest.strip() - if len(key_part) <= len(prefix): - continue - sub = key_part[len(prefix) :] - if val.startswith('"') and val.endswith('"') and len(val) >= 2: - val = val[1:-1] - opts[sub] = val - return opts - - -def tls_effective(opts: dict[str, str]) -> bool: - v = (opts.get("tls") or opts.get("ssl") or "off").lower() - return v in ("on", "true", "yes", "1") - - -def expected_nicks(username: str) -> str: - return f"{username},{username}_,{username}__,{username}|away" - - -def config_matches( - irc_conf: Path, - *, - server: str, - host: str, - port: int, - tls: bool, - username: str, - autojoin: str, - log: logging.Logger, -) -> bool: - if not irc_conf.is_file(): - return False - try: - text = irc_conf.read_text(encoding="utf-8", errors="replace") - except OSError as e: - log.debug("ler %s: %s", irc_conf, e) - return False - opts = parse_server_options(text, server) - if "addresses" not in opts: - return False - addr = opts["addresses"].lower() - expect_addr = f"{host.lower()}/{port}" - if addr != expect_addr: - log.debug("addresses %r != %r", addr, expect_addr) - return False - if tls_effective(opts) != tls: - log.debug("tls/ssl diverge") - return False - if opts.get("nicks") != expected_nicks(username): - log.debug("nicks divergem") - return False - if (opts.get("username") or "") != username: - return False - if (opts.get("realname") or "") != username: - return False - ac = (opts.get("autoconnect") or "off").lower() - if ac not in ("on", "true", "yes", "1"): - return False - aj = opts.get("autojoin") or "" - if aj != autojoin: - log.debug("autojoin %r != %r", aj, autojoin) - return False - return True - - -def build_apply_command_chain( - *, - server: str, - host: str, - port: int, - tls: bool, - username: str, - autojoin: str, -) -> str: - add_tokens = [f"/server add {server} {host}/{port}"] - if tls: - add_tokens.append("-tls") - add_tokens.append("-autoconnect") - parts: list[str] = [" ".join(add_tokens)] - nicks = expected_nicks(username) - parts.append(f'/set irc.server.{server}.nicks "{nicks}"') - parts.append(f'/set irc.server.{server}.username "{username}"') - parts.append(f'/set irc.server.{server}.realname "{username}"') - parts.append(f"/set irc.server.{server}.autoconnect on") - if autojoin: - 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) - - -def ensure_xdg_weechat_dir(home: Path, uid: int, gid: int, log: logging.Logger, dry_run: bool) -> Path: - xdg = home / ".config" - weechat_d = weechat_config_dir(home) - if dry_run: - log.info("[dry-run] garantiria dirs %s e %s (700, dono %d:%d)", xdg, weechat_d, uid, gid) - return weechat_d - if not home.is_dir(): - raise FileNotFoundError(f"home inexistente: {home}") - if not xdg.is_dir(): - xdg.mkdir(parents=True, exist_ok=True) - os.chmod(xdg, 0o700) - os.chown(xdg, uid, gid) - elif xdg.stat().st_uid != uid: - log.warning("%s não pertence a uid %d; não altero dono do .config inteiro", xdg, uid) - if not weechat_d.is_dir(): - weechat_d.mkdir(parents=True, exist_ok=True) - os.chmod(weechat_d, 0o700) - os.chown(weechat_d, uid, gid) - else: - os.chmod(weechat_d, 0o700) - try: - os.chown(weechat_d, uid, gid) - except OSError as e: - log.warning("chown %s: %s", weechat_d, e) - return weechat_d - - -def run_weechat_script( - *, - username: str, - home: Path, - weechat_bin: str, - command_chain: str, - dry_run: bool, - log: logging.Logger, - allow_failure: bool = False, -) -> bool: - runuser = shutil.which("runuser") - if not runuser: - log.error("runuser não encontrado (pacote util-linux).") - return False - weechat_dir = weechat_config_dir(home) - cmd: list[str] = [ - runuser, - "-u", - username, - "--", - weechat_bin, - "-d", - str(weechat_dir), - "-a", - "--stdout", - "-r", - command_chain, - ] - r = run_cmd(cmd, dry_run=dry_run, log=log) - if dry_run: - return True - assert r is not None - out = (r.stdout or "") + (r.stderr or "") - if r.returncode != 0: - msg = f"weechat-headless código {r.returncode} para {username}: {out.strip() or '(sem saída)'}" - if allow_failure: - log.debug("%s (ignorado)", msg) - return True - log.error("%s", msg) - return False - if out.strip(): - log.debug("weechat-headless saída (%s): %s", username, out.strip()[:2000]) - return True - - -def patch_user( - username: str, - *, - host: str, - port: int, - tls: bool, - server: str, - autojoin: str, - force: bool, - weechat_bin: str, - dry_run: bool, - log: logging.Logger, -) -> bool: - try: - pw = pwd.getpwnam(username) - except KeyError: - log.error("utilizador inexistente: %s", username) - return False - if username in IRC_PATCH_SKIP_USERS: - log.warning("utilizador reservado, ignorado: %s", username) - return False - if pw.pw_uid < MIN_UID_USER: - log.warning("UID < %d, ignorado: %s", MIN_UID_USER, username) - return False - - home = Path(pw.pw_dir) - uid, gid = pw.pw_uid, pw.pw_gid - try: - ensure_xdg_weechat_dir(home, uid, gid, log, dry_run) - except OSError as e: - log.error("%s: %s", username, e) - return False - - irc_conf = weechat_config_dir(home) / "irc.conf" - 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 - - 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 server_exists and (force or not matched): - del_chain = f"/server del {server} ; /quit" - 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, - weechat_bin=weechat_bin, - command_chain=del_chain, - dry_run=dry_run, - log=log, - allow_failure=True, - ) - - chain = build_apply_command_chain( - server=server, - host=host, - port=port, - tls=tls, - username=username, - autojoin=autojoin, - ) - log.info("%s: aplicar configuração IRC — servidor «%s» (weechat-headless)", username, server) - ok = run_weechat_script( - username=username, - home=home, - weechat_bin=weechat_bin, - command_chain=chain, - dry_run=dry_run, - log=log, - ) - if not ok: - return False - if not dry_run and irc_conf.is_file(): - try: - os.chown(irc_conf, uid, gid) - except OSError: - pass - return True - - -def validate_post( - sample_user: str | None, - server: str, - log: logging.Logger, -) -> None: - if not CHAT_DEST.is_file() or not os.access(CHAT_DEST, os.X_OK): - log.warning("validação: %s em falta ou não executável", CHAT_DEST) - else: - log.info("validação: launcher %s OK", CHAT_DEST) - if not sample_user: - return - try: - pw = pwd.getpwnam(sample_user) - except KeyError: - return - irc_conf = weechat_config_dir(Path(pw.pw_dir)) / "irc.conf" - if not irc_conf.is_file(): - log.warning("validação: %s sem %s", sample_user, irc_conf) - return - txt = irc_conf.read_text(encoding="utf-8", errors="replace") - if re.search(rf"^{re.escape(server)}\.addresses\s*=", txt, re.MULTILINE): - log.info("validação: %s tem %s.addresses em %s", sample_user, server, irc_conf) - else: - log.warning("validação: %s.addresses não encontrado em %s", server, irc_conf) - - -def parse_args(argv: list[str] | None) -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Provisiona IRC (servidor runv, weechat-headless) e instala o comando chat.", - ) - p.add_argument("--dry-run", action="store_true", help="só mostrar o plano") - p.add_argument("--verbose", action="store_true", help="log detalhado") - p.add_argument("--force", action="store_true", help="reconfigurar mesmo se existir servidor divergente") - p.add_argument("--skip-launcher", action="store_true", help="não instalar /usr/local/bin/chat") - p.add_argument("--skip-backfill", action="store_true", help="não aplicar config por utilizador") - p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON, metavar="PATH") - p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT, metavar="PATH") - p.add_argument("--host", default=DEFAULT_HOST, help="hostname IRC") - p.add_argument( - "--port", - type=int, - default=None, - metavar="PORT", - help="porta (omissão: 6697 com TLS, 6667 sem TLS)", - ) - tls_g = p.add_mutually_exclusive_group() - tls_g.add_argument("--tls", dest="tls", action="store_true", help="usar TLS (padrão)") - tls_g.add_argument("--no-tls", dest="tls", action="store_false", help="IRC sem TLS") - p.set_defaults(tls=True) - p.add_argument( - "--server-name", - default=DEFAULT_SERVER_NAME, - metavar="NAME", - help="nome interno na config IRC (equivalente a /server add …)", - ) - p.add_argument( - "--autojoin", - default=DEFAULT_AUTOJOIN, - metavar="CHANNELS", - help=( - f'canais separados por vírgula (padrão: {DEFAULT_AUTOJOIN!r}); ' - 'use --autojoin "" para não autoentrar em canais' - ), - ) - ug = p.add_mutually_exclusive_group(required=True) - ug.add_argument("--user", metavar="USER", help="apenas este utilizador Unix") - ug.add_argument("--all-users", action="store_true", help="todos os utilizadores válidos") - return p.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv) - log = setup_logging(args.verbose) - - if args.port is None: - port = 6697 if args.tls else 6667 - else: - port = args.port - - if not args.dry_run: - require_root(log) - else: - log.info("dry-run: não grava alterações.") - - if not args.skip_launcher: - install_chat_launcher(dry_run=args.dry_run, log=log) - - weechat_bin = find_weechat_headless(log) - if not args.skip_backfill and not weechat_bin: - log.error( - "weechat-headless não encontrado no PATH; instale o pacote Debian «weechat-headless» (ex.: apt).", - ) - return 1 - - if args.all_users: - users = resolve_all_users(args.users_json, args.homes_root, log) - else: - assert args.user is not None - users = [args.user] - - failures = 0 - if not args.skip_backfill: - assert weechat_bin is not None - for u in users: - if u in IRC_PATCH_SKIP_USERS: - log.warning("ignorado (reservado): %s", u) - continue - ok = patch_user( - u, - host=args.host, - port=port, - tls=args.tls, - server=args.server_name, - autojoin=args.autojoin.strip(), - force=args.force, - weechat_bin=weechat_bin, - dry_run=args.dry_run, - log=log, - ) - if not ok: - failures += 1 - else: - log.info("backfill ignorado (--skip-backfill).") - - sample = users[0] if users else None - validate_post(sample, args.server_name, log) - - print() - print("========== patch_irc — resumo ==========") - print(f"Modo: {'DRY-RUN' if args.dry_run else 'aplicação'}") - print(f"Host: {args.host}:{port} TLS: {args.tls} servidor na config: {args.server_name}") - aj = args.autojoin.strip() - print(f"Autojoin: {aj if aj else '(nenhum)'}") - if not args.skip_backfill: - print(f"Utilizadores processados: {len(users)} falhas: {failures}") - print("Comando para utilizadores: chat") - print("========================================") - - return 1 if failures else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -7,13 +7,13 @@ Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club. Idempotente, dry-run, subprocess sem shell. Executar como root no Debian. -Versão 0.01 — runv.club +Versão 0.02 — runv.club """ from __future__ import annotations import argparse -import json +import importlib.util import logging import os import pwd @@ -21,15 +21,16 @@ import re import shutil import subprocess import sys +import time from datetime import datetime, timezone from pathlib import Path -from typing import Final +from typing import Any, Final # --------------------------------------------------------------------------- # Constantes # --------------------------------------------------------------------------- -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") @@ -49,35 +50,6 @@ MOLLY_INSTANCE: Final[str] = "runv.club" # molly-brown@runv.club.service PACKAGES_GOPHER: Final[tuple[str, ...]] = ("gophernicus",) PACKAGES_GEMINI: Final[tuple[str, ...]] = ("molly-brown",) -MIN_UID_USER: Final[int] = 1000 - -ALT_PROTOCOL_SKIP_USERS: Final[frozenset[str]] = frozenset( - { - "root", - "daemon", - "bin", - "sys", - "sync", - "games", - "man", - "lp", - "mail", - "news", - "uucp", - "proxy", - "www-data", - "backup", - "list", - "irc", - "_apt", - "nobody", - "pmurad-admin", - "entre", - "admin", - "postmaster", - } -) - DEFAULT_ROOT_GOPHERMAP: Final[str] = """iBem-vindo ao Gopher em runv.club — pubnix. fake NULL 0 iCada utilizador com ~/public_gopher aparece como ~user no menu do servidor. fake NULL 0 """ @@ -216,63 +188,68 @@ ReadMollyFiles = true """ -def load_usernames_from_json(path: Path, log: logging.Logger) -> list[str] | None: - if not path.is_file(): - return None - try: - raw = path.read_text(encoding="utf-8").strip() - if not raw: - return [] - data = json.loads(raw) - if not isinstance(data, list): - log.warning("%s: JSON não é lista; ignoro.", path) - return None - names: list[str] = [] - for item in data: - if isinstance(item, dict): - u = item.get("username") - if isinstance(u, str) and u: - names.append(u) - return sorted(set(names)) - except (json.JSONDecodeError, OSError) as e: - log.warning("falha ao ler %s: %s — uso fallback /home", path, e) - return None +def repo_root() -> Path: + """Raiz do repositório runv-server (scripts/admin/ → …/runv-server).""" + return Path(__file__).resolve().parent.parent.parent -def usernames_from_homes(homes_root: Path, log: logging.Logger) -> list[str]: - names: list[str] = [] - if not homes_root.is_dir(): - log.warning("homes_root inexistente: %s", homes_root) - return [] - for entry in sorted(homes_root.iterdir()): - if not entry.is_dir() or entry.name.startswith("."): - continue - try: - pw = pwd.getpwnam(entry.name) - except KeyError: - continue - if pw.pw_uid < MIN_UID_USER: - continue - if entry.name in ALT_PROTOCOL_SKIP_USERS: - continue - names.append(entry.name) - return sorted(set(names)) +def load_patch_irc_module(log: logging.Logger) -> Any: + path = repo_root() / "patches" / "patch_irc.py" + if not path.is_file(): + log.error( + "Ficheiro em falta: %s — clone completo do repo ou copie patches/patch_irc.py.", + path, + ) + raise FileNotFoundError(str(path)) + spec = importlib.util.spec_from_file_location("patch_irc_setup_alt", 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 resolve_user_list( +def resolve_backfill_users( users_json: Path, homes_root: Path, log: logging.Logger, ) -> list[str]: - from_json = load_usernames_from_json(users_json, log) - if from_json is not None and from_json: - log.info("utilizadores a partir de %s (%d)", users_json, len(from_json)) - return [u for u in from_json if u not in ALT_PROTOCOL_SKIP_USERS] - if from_json is not None and from_json == []: - log.info("%s vazio — fallback /home", users_json) - users = usernames_from_homes(homes_root, log) - log.info("utilizadores a partir de %s (%d)", homes_root, len(users)) - return users + """União users.json + /home, mesma política que patches/patch_irc.py.""" + patch_irc = load_patch_irc_module(log) + return patch_irc.resolve_all_users(users_json, homes_root, log) + + +def wait_for_unit_active( + unit: str, + *, + log: logging.Logger, + dry_run: bool, + attempts: int = 5, + delay_s: float = 1.0, +) -> bool: + if dry_run: + return True + for i in range(attempts): + r = subprocess.run( + ["systemctl", "is-active", unit], + capture_output=True, + text=True, + timeout=30, + ) + state = (r.stdout or "").strip() + if state == "active": + log.info("%s: active", unit) + return True + log.debug("%s: %s (tentativa %d/%d)", unit, state or r.returncode, i + 1, attempts) + if i + 1 < attempts: + time.sleep(delay_s) + log.warning( + "%s não ficou «active» após %d tentativas — veja: sudo journalctl -u %s -b --no-pager", + unit, + attempts, + unit, + ) + return False def ensure_user_public_dirs( @@ -594,7 +571,11 @@ def main(argv: list[str] | None = None) -> int: skip_firewall=args.skip_firewall, ) - users = resolve_user_list(args.users_json, args.homes_root, log) + try: + users = resolve_backfill_users(args.users_json, args.homes_root, log) + except (FileNotFoundError, ImportError) as e: + log.error("%s", e) + return 1 if not args.skip_backfill: for u in users: ensure_user_public_dirs( @@ -622,16 +603,13 @@ def main(argv: list[str] | None = None) -> int: log=log, ) if not args.skip_gemini and cert.is_file() and key.is_file(): + molly_unit = f"molly-brown@{MOLLY_INSTANCE}.service" run_cmd( - [ - "systemctl", - "enable", - "--now", - f"molly-brown@{MOLLY_INSTANCE}.service", - ], + ["systemctl", "enable", "--now", molly_unit], dry_run=args.dry_run, log=log, ) + wait_for_unit_active(molly_unit, log=log, dry_run=args.dry_run) validate_final(users, log) log.info("Concluído.") diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md @@ -22,8 +22,18 @@ Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **goph - **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. +## Molly não sobe ou fica em «activating» + +- **`journalctl` sem mensagens:** os logs do serviço do sistema exigem **root** — use `sudo journalctl -u molly-brown@runv.club.service -b --no-pager -n 80`. +- **Estado e porta:** `sudo systemctl status molly-brown@runv.club.service --no-pager` e `sudo ss -tlnp | grep 1965` (deve haver um processo a escutar em **1965/tcp**). +- **Permissões TLS (frequente):** o Molly corre como utilizador não-root; se `privkey.pem` for só `root:root` `0600`, o arranque falha. Verifique `sudo namei -l /etc/letsencrypt/live/runv.club/privkey.pem` e compare com o utilizador do unit (`systemctl cat molly-brown@runv.club.service`). Soluções típicas: grupo `ssl-cert`, ACL, ou certificados num path legível pelo utilizador do serviço (mantendo segurança). +- **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/` **depois** de `systemctl is-active molly-brown@runv.club.service` devolver `active`. + ## 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. + ```bash cd /caminho/para/runv-server sudo python3 scripts/admin/setup_alt_protocols.py --dry-run --verbose @@ -43,15 +53,19 @@ sudo python3 scripts/admin/setup_alt_protocols.py --verbose | `--skip-backfill` | Não cria pastas/symlinks 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` | Fonte de usernames (lista JSON com `username`). Predefinido: `/var/lib/runv/users.json`. | -| `--homes-root PATH` | Fallback se JSON vazio/inexistente (varre UIDs ≥ 1000). | +| `--users-json PATH` | Parte da fonte de usernames (lista JSON com `username`). Predefinido: `/var/lib/runv/users.json`. | +| `--homes-root PATH` | Parte da fonte de usernames (directórios em `/home` com UID ≥ 1000). O backfill usa a **união** JSON + homes (igual a `patches/patch_irc.py`). | | `--gemini-hostname HOST` | Predefinido: `runv.club`. | | `--gemini-cert` / `--gemini-key` | Caminhos PEM para molly-brown. | ## Descoberta de utilizadores (backfill) -1. Se **`users.json`** existir e for uma lista JSON válida com objetos que tenham **`username`**, usa essa lista. -2. Caso contrário, varre **`--homes-root`** (predefinido `/home`), UIDs ≥ 1000, excluindo contas reservadas (`root`, `entre`, `pmurad-admin`, contas de sistema, etc.). +A lista de contas para criar `~/public_gopher`, `~/public_gemini` e symlinks 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`**. ## Relação com outros scripts diff --git a/scripts/docs/irc_patch.md b/scripts/docs/irc_patch.md @@ -5,21 +5,21 @@ Estilo [tilde.club](https://tilde.club): o utilizador corre só **`chat`** e lig ## Alinhamento (plano / produto) - **MOTD** (`tools/motd/60-runv`) e **`runv-help`** referem **apenas o comando `chat`** — sem citar outros nomes de binário ao utilizador. -- **Provisionamento** (`patch_irc.py`) usa **sempre** `weechat-headless` (`-a`, `-r`, `--stdout`): é o fluxo suportado para automatizar `/server add` e `/set` sem editar ficheiros à mão. +- **Provisionamento** (`patches/patch_irc.py`) usa **sempre** `weechat-headless` (`-a`, `-r`, `--stdout`): é o fluxo suportado para automatizar `/server add` e `/set` sem editar ficheiros à mão. - O **cliente interactivo** no terminal é instalado pelos **pacotes globais** em `tools/manifests/apt_packages.txt` (o launcher `chat` escolhe o primeiro binário adequado no PATH); utilizadores continuam a ver só **`chat`**. ## O que o admin faz ```bash -cd /caminho/runv-server/scripts -sudo python3 admin/patch_irc.py --all-users --verbose +cd /caminho/runv-server +sudo python3 patches/patch_irc.py --all-users --verbose # ou um utilizador: -sudo python3 admin/patch_irc.py --user alice --verbose +sudo python3 patches/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. +- O patch aplica também **definições globais** WeeChat (na mesma sessão `weechat-headless`): `irc.look.buffer_switch_join` = `on`, `irc.look.server_buffer` = `independent`, e `buflist.look.display_conditions` = ``${buffer.plugin} == irc && ${type} == channel`` (na buflist aparecem **só canais**, ex. `#runv` — sem a linha do servidor `runv` nem `core.weechat`). Mensagens do servidor continuam acessíveis noutros modos; para voltar a listar tudo, redefine o `display_conditions` na mão. WeeChat antigo ou sem plugin buflist: o `/set buflist.*` pode falhar. - 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`). - 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). @@ -53,13 +53,13 @@ Não há SASL/NickServ automático; no código há comentários para extensão f ## Integração `tools.py` -Copia **`tools/bin/chat`** → `/usr/local/bin`. O `patch_irc.py` pode reinstalar o mesmo ficheiro se correres o patch sem rerodar `tools.py`. +Copia **`tools/bin/chat`** → `/usr/local/bin`. O `patches/patch_irc.py` pode reinstalar o mesmo ficheiro se correres o patch sem rerodar `tools.py`. ## Testes rápidos (Debian 13) ```bash -sudo python3 admin/patch_irc.py --dry-run --all-users --verbose -sudo python3 admin/patch_irc.py --user "$(logname)" --verbose +sudo python3 patches/patch_irc.py --dry-run --all-users --verbose +sudo python3 patches/patch_irc.py --user "$(logname)" --verbose command -v chat && ls -l "$(command -v chat)" command -v weechat-headless sudo -u USER test -f /home/USER/.config/weechat/irc.conf && grep '^runv\.' /home/USER/.config/weechat/irc.conf diff --git a/tools/README.md b/tools/README.md @@ -3,7 +3,7 @@ Módulo para **automatizar** no servidor Debian 13 (ou compatível): 1. **Pacotes globais** via `apt` (lista em `manifests/apt_packages.txt`) — para todos os usuários, **sem** passar pelo `/etc/skel`. -2. **Comandos locais** em `/usr/local/bin`: `runv-help`, `runv-links`, `runv-status`, **`chat`** (IRC; rede da casa provisionada com **`scripts/admin/patch_irc.py`** — utilizadores usam só `chat`). +2. **Comandos locais** em `/usr/local/bin`: `runv-help`, `runv-links`, `runv-status`, **`chat`** (IRC; rede da casa provisionada com **`patches/patch_irc.py`** — utilizadores usam só `chat`). 3. **MOTD dinâmico** em `/etc/update-motd.d/60-runv` (arte ASCII verde, texto em português). 4. **Arquivos padrão** copiados para `/etc/skel/` (README, `.bash_aliases`, `public_html/index.html`, `public_gopher/gophermap`, `public_gemini/index.gmi`) — **somente modelos de home**, nunca instaladores de sistema. diff --git a/tools/bin/chat b/tools/bin/chat @@ -1,5 +1,5 @@ #!/bin/sh -# runv.club — cliente IRC interactivo; config em ~/.config/weechat (servidor «runv» após patch_irc.py). +# runv.club — cliente IRC interactivo; config em ~/.config/weechat (servidor «runv» após patches/patch_irc.py). # Utilizadores: use só o comando «chat»; não é preciso memorizar outros nomes de binário. IRC_UI="" @@ -19,10 +19,10 @@ CONFIG_DIR="${WEECHAT_HOME:-$HOME/.config/weechat}" if [ ! -f "$CONFIG_DIR/irc.conf" ]; then echo "runv: aviso — $CONFIG_DIR/irc.conf ainda não existe; será criada ao ligar." >&2 - echo "runv: peça ao admin para correr scripts/admin/patch_irc.py (rede IRC da casa)." >&2 + echo "runv: peça ao admin para correr patches/patch_irc.py (rede IRC da casa)." >&2 elif ! grep -q '^runv\.' "$CONFIG_DIR/irc.conf" 2>/dev/null; then echo "runv: aviso — servidor «runv» não está definido em $CONFIG_DIR/irc.conf." >&2 - echo "runv: o admin pode aplicar scripts/admin/patch_irc.py (ex.: irc.portalidea.com.br)." >&2 + echo "runv: o admin pode aplicar patches/patch_irc.py (ex.: irc.portalidea.com.br)." >&2 fi exec "$IRC_UI" -d "$CONFIG_DIR" "$@" diff --git a/tools/docs/INSTALL.md b/tools/docs/INSTALL.md @@ -121,3 +121,4 @@ Novas contas criadas com `adduser` **depois** desta instalação recebem esses a - **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. +- **Gemini (`molly-brown`) inactivo ou «activating»:** guia de diagnóstico (journalctl com `sudo`, porta 1965, permissões da chave TLS) em **`scripts/docs/alt_protocols.md`** — secção *Molly não sobe ou fica em «activating»*. diff --git a/tools/docs/USER_EXPERIENCE.md b/tools/docs/USER_EXPERIENCE.md @@ -35,7 +35,7 @@ Quando um administrador cria a conta com **`adduser`**, o Debian copia **`/etc/s ## Programas globais (apt) -Pacotes listados em **`manifests/apt_packages.txt`** (incluindo ferramentas de terminal e IRC) ficam **instalados no sistema**. O comando global **`chat`** em `/usr/local/bin` é o único nome que o utilizador precisa para IRC na rede da casa; a config é aplicada pelo admin com **`scripts/admin/patch_irc.py`**. O usuário **não** precisa de nada no skel para **executá-los**: após o admin rodar `tools.py`, eles passam a existir no `PATH`. Ou seja: +Pacotes listados em **`manifests/apt_packages.txt`** (incluindo ferramentas de terminal e IRC) ficam **instalados no sistema**. O comando global **`chat`** em `/usr/local/bin` é o único nome que o utilizador precisa para IRC na rede da casa; a config é aplicada pelo admin com **`patches/patch_irc.py`**. O usuário **não** precisa de nada no skel para **executá-los**: após o admin rodar `tools.py`, eles passam a existir no `PATH`. Ou seja: - **Skel** ≠ instalar programas. - **Skel** = arquivos iniciais na home.