commit 546718851148f54da9c0791061ed369c543efaf1
parent 69278520d6af6e595187f1db676227c4865b89b5
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 15:30:26 -0300
chat, gemini and gopher
Diffstat:
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.