patch_irc.py (29145B)
1 #!/usr/bin/env python3 2 """ 3 Provisiona a rede IRC da casa (estilo tilde.club) e o comando «chat» para utilizadores. 4 5 O conjunto ``IRC_PATCH_SKIP_USERS`` também é usado por ``resolve_all_users`` para o 6 backfill Gopher/Gemini (``setup_alt_protocols.py``): contas listadas não recebem 7 bind mount em ``/var/gemini/users/<user>`` nem entram no menu Gopher/Gemini raiz. 8 9 - Config em ~/.config/weechat (XDG), servidor interno «runv», TLS, autoconnect só nele. 10 - Outros servidores existentes mantêm-se; apenas ``irc.server.<outro>.autoconnect`` fica ``off``. 11 - Aplicação **só** via ``weechat-headless`` (-a, -r, --stdout) no patch; o launcher ``chat`` não usa -a. 12 - Instala /usr/local/bin/chat (launcher) salvo --skip-launcher. 13 14 MOTD e runv-help referem apenas **chat** (sem expor outros nomes de comando ao utilizador). 15 16 Executar como root no Debian; detalhes em docs/05-tools-and-system-experience.md. 17 SASL/NickServ: ver constante ``SASL_WEECHAT_SNIPPETS`` e https://weechat.org/doc/ 18 19 Versão 0.04 — runv.club 20 """ 21 22 from __future__ import annotations 23 24 import argparse 25 import json 26 import logging 27 import os 28 import pwd 29 import re 30 import shutil 31 import subprocess 32 import sys 33 from pathlib import Path 34 from typing import Final 35 36 _PATCHES_DIR = Path(__file__).resolve().parent 37 _ADMIN_DIR = _PATCHES_DIR.parent / "scripts" / "admin" 38 if str(_ADMIN_DIR) not in sys.path: 39 sys.path.insert(0, str(_ADMIN_DIR)) 40 41 from admin_guard import ensure_admin_cli 42 43 # SASL ainda não entra no patch; quando entrar, é na mão no WeeChat com sec.data (nada de 44 # password em claro neste repo). Isto é só o boneco dos comandos, para não ir buscar à memória. 45 SASL_WEECHAT_SNIPPETS: Final[tuple[str, ...]] = ( 46 "/set irc.server.<name>.sasl_mechanism plain", 47 "/secure set runv_irc_senha ...", 48 '/set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}"', 49 ) 50 51 VERSION: Final[str] = "0.04" 52 53 DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") 54 DEFAULT_HOMES_ROOT: Final[Path] = Path("/home") 55 DEFAULT_HOST: Final[str] = "irc.tilde.chat" 56 DEFAULT_PORT_TLS: Final[int] = 6697 57 DEFAULT_SERVER_NAME: Final[str] = "runv" 58 DEFAULT_AUTOJOIN: Final[str] = "#runv" 59 NICKLIST_CONDITIONS: Final[str] = "${nicklist}" 60 61 MIN_UID_USER: Final[int] = 1000 62 63 IRC_PATCH_SKIP_USERS: Final[frozenset[str]] = frozenset( 64 { 65 "root", 66 "daemon", 67 "bin", 68 "sys", 69 "sync", 70 "games", 71 "man", 72 "lp", 73 "mail", 74 "news", 75 "uucp", 76 "proxy", 77 "www-data", 78 "backup", 79 "list", 80 "irc", 81 "_apt", 82 "nobody", 83 "entre", 84 "pmurad-admin", 85 "admin", 86 "postmaster", 87 } 88 ) 89 90 CHAT_DEST: Final[Path] = Path("/usr/local/bin/chat") 91 92 93 def setup_logging(verbose: bool) -> logging.Logger: 94 logging.basicConfig( 95 level=logging.DEBUG if verbose else logging.INFO, 96 format="%(levelname)s: %(message)s", 97 ) 98 return logging.getLogger("patch_irc") 99 100 101 def require_root(log: logging.Logger) -> None: 102 if os.geteuid() != 0: 103 log.error("Execute como root (sudo).") 104 sys.exit(1) 105 106 107 def run_cmd( 108 cmd: list[str], 109 *, 110 dry_run: bool, 111 log: logging.Logger, 112 timeout: int = 180, 113 ) -> subprocess.CompletedProcess[str] | None: 114 log.debug("exec: %s", " ".join(cmd)) 115 if dry_run: 116 log.info("[dry-run] %s", " ".join(cmd)) 117 return None 118 return subprocess.run( 119 cmd, 120 check=False, 121 capture_output=True, 122 text=True, 123 timeout=timeout, 124 ) 125 126 127 def repo_root() -> Path: 128 return Path(__file__).resolve().parent.parent 129 130 131 def launcher_source_path() -> Path: 132 return repo_root() / "tools" / "bin" / "chat" 133 134 135 def embedded_launcher_text() -> str: 136 return """#!/bin/sh 137 # runv.club — fallback mínimo (preferir tools/bin/chat do repositório) 138 IRC_UI="" 139 for c in weechat weechat-curses; do 140 command -v "$c" >/dev/null 2>&1 && IRC_UI=$c && break 141 done 142 if [ -z "$IRC_UI" ]; then 143 for p in /usr/bin/weechat-curses /usr/bin/weechat; do 144 [ -x "$p" ] && IRC_UI=$p && break 145 done 146 fi 147 if [ -z "$IRC_UI" ]; then 148 echo "runv: instale weechat-curses (apt) ou corra tools/tools.py." >&2 149 exit 127 150 fi 151 CONFIG_DIR="${WEECHAT_HOME:-$HOME/.config/weechat}" 152 exec "$IRC_UI" -d "$CONFIG_DIR" "$@" 153 """ 154 155 156 def install_chat_launcher(*, dry_run: bool, log: logging.Logger) -> bool: 157 src = launcher_source_path() 158 if dry_run: 159 log.info("[dry-run] instalaria %s -> %s", src if src.is_file() else "(embutido)", CHAT_DEST) 160 return True 161 CHAT_DEST.parent.mkdir(parents=True, exist_ok=True) 162 if src.is_file(): 163 shutil.copy2(src, CHAT_DEST) 164 else: 165 log.warning("origem %s inexistente; escrevo launcher mínimo embutido", src) 166 CHAT_DEST.write_text(embedded_launcher_text(), encoding="utf-8") 167 os.chmod(CHAT_DEST, 0o755) 168 try: 169 os.chown(CHAT_DEST, 0, 0) 170 except OSError as e: 171 log.warning("chown em %s: %s", CHAT_DEST, e) 172 log.info("launcher: %s", CHAT_DEST) 173 return True 174 175 176 def find_weechat_headless(log: logging.Logger) -> str | None: 177 """Apenas weechat-headless — o patch não usa cliente interactivo.""" 178 p = shutil.which("weechat-headless") 179 if p: 180 log.debug("binário de provisionamento IRC: %s", p) 181 return p 182 183 184 def load_usernames_from_json(path: Path, log: logging.Logger) -> list[str] | None: 185 if not path.is_file(): 186 return None 187 try: 188 raw = path.read_text(encoding="utf-8").strip() 189 if not raw: 190 return [] 191 data = json.loads(raw) 192 if not isinstance(data, list): 193 log.warning("%s: JSON não é lista; ignoro.", path) 194 return None 195 names: list[str] = [] 196 for item in data: 197 if isinstance(item, dict): 198 u = item.get("username") 199 if isinstance(u, str) and u: 200 names.append(u) 201 return sorted(set(names)) 202 except (json.JSONDecodeError, OSError) as e: 203 log.warning("falha ao ler %s: %s — uso fallback /home", path, e) 204 return None 205 206 207 def usernames_from_homes(homes_root: Path, log: logging.Logger) -> list[str]: 208 names: list[str] = [] 209 if not homes_root.is_dir(): 210 log.warning("homes_root inexistente: %s", homes_root) 211 return [] 212 for entry in sorted(homes_root.iterdir()): 213 if not entry.is_dir() or entry.name.startswith("."): 214 continue 215 try: 216 pw = pwd.getpwnam(entry.name) 217 except KeyError: 218 continue 219 if pw.pw_uid < MIN_UID_USER: 220 continue 221 if entry.name in IRC_PATCH_SKIP_USERS: 222 continue 223 names.append(entry.name) 224 return sorted(set(names)) 225 226 227 def resolve_all_users(users_json: Path, homes_root: Path, log: logging.Logger) -> list[str]: 228 from_json = load_usernames_from_json(users_json, log) 229 from_homes = usernames_from_homes(homes_root, log) 230 231 if from_json is None: 232 log.info("utilizadores a partir de %s (%d); JSON indisponível", homes_root, len(from_homes)) 233 return from_homes 234 235 if not from_json: 236 log.info("%s vazio — só homes em %s (%d)", users_json, homes_root, len(from_homes)) 237 return from_homes 238 239 merged = sorted(set(from_json) | set(from_homes)) 240 log.info( 241 "utilizadores: união %s (%d) + %s (%d) → %d contas", 242 users_json, 243 len(from_json), 244 homes_root, 245 len(from_homes), 246 len(merged), 247 ) 248 return [u for u in merged if u not in IRC_PATCH_SKIP_USERS] 249 250 251 def weechat_config_dir(home: Path) -> Path: 252 return home / ".config" / "weechat" 253 254 255 def parse_all_server_names(irc_conf_text: str) -> set[str]: 256 """Nomes de servidor na secção [server] (prefixos antes do primeiro '.' na chave).""" 257 names: set[str] = set() 258 in_server = False 259 for raw in irc_conf_text.splitlines(): 260 line = raw.strip() 261 if line == "[server]": 262 in_server = True 263 continue 264 if line.startswith("[") and line.endswith("]"): 265 in_server = False 266 continue 267 if not in_server or not line or line.startswith("#") or "=" not in line: 268 continue 269 key_part = line.split("=", 1)[0].strip() 270 if "." not in key_part: 271 continue 272 srv, _sub = key_part.split(".", 1) 273 if srv: 274 names.add(srv) 275 return names 276 277 278 def parse_server_options(irc_conf_text: str, server: str) -> dict[str, str]: 279 opts: dict[str, str] = {} 280 in_server = False 281 prefix = f"{server}." 282 for raw in irc_conf_text.splitlines(): 283 line = raw.strip() 284 if not line or line.startswith("#"): 285 continue 286 if line == "[server]": 287 in_server = True 288 continue 289 if line.startswith("[") and line.endswith("]"): 290 in_server = False 291 continue 292 if not in_server: 293 continue 294 if not line.startswith(prefix): 295 continue 296 key_part, _, rest = line.partition("=") 297 key_part = key_part.strip() 298 val = rest.strip() 299 if len(key_part) <= len(prefix): 300 continue 301 sub = key_part[len(prefix) :] 302 if val.startswith('"') and val.endswith('"') and len(val) >= 2: 303 val = val[1:-1] 304 opts[sub] = val 305 return opts 306 307 308 def tls_effective(opts: dict[str, str]) -> bool: 309 v = (opts.get("tls") or opts.get("ssl") or "off").lower() 310 return v in ("on", "true", "yes", "1") 311 312 313 def autoconnect_enabled(opts: dict[str, str]) -> bool: 314 ac = (opts.get("autoconnect") or "off").lower() 315 return ac in ("on", "true", "yes", "1") 316 317 318 def expected_nicks(username: str) -> str: 319 return f"{username},{username}_,{username}__,{username}|away" 320 321 322 def runv_server_options_match( 323 opts: dict[str, str], 324 *, 325 host: str, 326 port: int, 327 tls: bool, 328 unix_username: str, 329 autojoin: str, 330 log: logging.Logger, 331 ) -> bool: 332 if "addresses" not in opts: 333 return False 334 addr = opts["addresses"].lower() 335 expect_addr = f"{host.lower()}/{port}" 336 if addr != expect_addr: 337 log.debug("addresses %r != %r", addr, expect_addr) 338 return False 339 if tls_effective(opts) != tls: 340 log.debug("tls/ssl diverge") 341 return False 342 if opts.get("nicks") != expected_nicks(unix_username): 343 log.debug("nicks divergem") 344 return False 345 if (opts.get("username") or "") != unix_username: 346 return False 347 if (opts.get("realname") or "") != unix_username: 348 return False 349 if not autoconnect_enabled(opts): 350 return False 351 aj = opts.get("autojoin") or "" 352 if aj != autojoin: 353 log.debug("autojoin %r != %r", aj, autojoin) 354 return False 355 return True 356 357 358 def non_primary_servers_autoconnect_all_off( 359 irc_conf_text: str, 360 primary: str, 361 log: logging.Logger, 362 ) -> bool: 363 for name in parse_all_server_names(irc_conf_text): 364 if name == primary: 365 continue 366 o = parse_server_options(irc_conf_text, name) 367 if not o.get("addresses"): 368 continue 369 if autoconnect_enabled(o): 370 log.debug("servidor %r tem autoconnect on (deveria off)", name) 371 return False 372 return True 373 374 375 def config_matches( 376 irc_conf: Path, 377 *, 378 server: str, 379 host: str, 380 port: int, 381 tls: bool, 382 unix_username: str, 383 autojoin: str, 384 log: logging.Logger, 385 ) -> bool: 386 if not irc_conf.is_file(): 387 return False 388 try: 389 text = irc_conf.read_text(encoding="utf-8", errors="replace") 390 except OSError as e: 391 log.debug("ler %s: %s", irc_conf, e) 392 return False 393 opts = parse_server_options(text, server) 394 if not runv_server_options_match( 395 opts, 396 host=host, 397 port=port, 398 tls=tls, 399 unix_username=unix_username, 400 autojoin=autojoin, 401 log=log, 402 ): 403 return False 404 if not nicklist_visible(irc_conf.parent / "weechat.conf", log): 405 return False 406 return non_primary_servers_autoconnect_all_off(text, server, log) 407 408 409 def nicklist_visible(weechat_conf: Path, log: logging.Logger) -> bool: 410 """Confirma que a barra lateral de nicks aparece nos buffers com nicklist.""" 411 if not weechat_conf.is_file(): 412 log.debug("weechat.conf ausente: %s", weechat_conf) 413 return False 414 try: 415 config_text = weechat_conf.read_text(encoding="utf-8", errors="replace") 416 except OSError as e: 417 log.debug("ler %s: %s", weechat_conf, e) 418 return False 419 m = re.search( 420 r"(?m)^(?:weechat\.bar\.)?nicklist\.conditions\s*=\s*(?P<value>.+?)\s*$", 421 config_text, 422 ) 423 if not m: 424 log.debug("nicklist.conditions ausente") 425 return False 426 value = m.group("value").strip().strip('"') 427 if value == NICKLIST_CONDITIONS or NICKLIST_CONDITIONS in value: 428 return True 429 log.debug("nicklist.conditions %r não inclui %r", value, NICKLIST_CONDITIONS) 430 return False 431 432 433 def build_disable_other_autoconnect_chain(irc_conf_text: str, primary: str) -> str: 434 """Comandos /set para desligar autoconnect em servidores != primary (só onde está on).""" 435 parts: list[str] = [] 436 for name in sorted(parse_all_server_names(irc_conf_text)): 437 if name == primary: 438 continue 439 o = parse_server_options(irc_conf_text, name) 440 if not o.get("addresses"): 441 continue 442 if not autoconnect_enabled(o): 443 continue 444 parts.append(f"/set irc.server.{name}.autoconnect off") 445 return " ; ".join(parts) 446 447 448 def build_apply_command_chain( 449 *, 450 server: str, 451 host: str, 452 port: int, 453 tls: bool, 454 unix_username: str, 455 autojoin: str, 456 ) -> str: 457 # Sem -autoconnect no /server add: autoconnect via /set (requisito runv). 458 add_cmd = f"/server add {server} {host}/{port}" 459 if tls: 460 add_cmd += " -tls" 461 parts: list[str] = [add_cmd] 462 nicks = expected_nicks(unix_username) 463 parts.append(f'/set irc.server.{server}.nicks "{nicks}"') 464 parts.append(f'/set irc.server.{server}.username "{unix_username}"') 465 parts.append(f'/set irc.server.{server}.realname "{unix_username}"') 466 parts.append(f"/set irc.server.{server}.autoconnect on") 467 if autojoin: 468 parts.append(f'/set irc.server.{server}.autojoin "{autojoin}"') 469 else: 470 parts.append(f'/set irc.server.{server}.autojoin ""') 471 parts.append("/set irc.look.buffer_switch_join on") 472 parts.append("/set irc.look.server_buffer independent") 473 parts.append("/bar show nicklist") 474 parts.append(f'/set weechat.bar.nicklist.conditions "{NICKLIST_CONDITIONS}"') 475 parts.append( 476 '/set buflist.look.display_conditions "${buffer.plugin} == irc && ${type} == channel"' 477 ) 478 parts.append("/save") 479 parts.append("/quit") 480 return " ; ".join(parts) 481 482 483 def build_nicklist_ui_chain() -> str: 484 return " ; ".join( 485 ( 486 "/bar show nicklist", 487 f'/set weechat.bar.nicklist.conditions "{NICKLIST_CONDITIONS}"', 488 "/save", 489 "/quit", 490 ) 491 ) 492 493 494 def chain_with_save_quit(prefix_chain: str) -> str: 495 p = prefix_chain.strip() 496 if p: 497 return f"{p} ; /save ; /quit" 498 return "/save ; /quit" 499 500 501 def merge_command_chains(*parts: str) -> str: 502 return " ; ".join(s.strip() for s in parts if s and s.strip()) 503 504 505 def ensure_xdg_weechat_dir(home: Path, uid: int, gid: int, log: logging.Logger, dry_run: bool) -> Path: 506 xdg = home / ".config" 507 weechat_d = weechat_config_dir(home) 508 if dry_run: 509 log.info("[dry-run] garantiria dirs %s e %s (700, dono %d:%d)", xdg, weechat_d, uid, gid) 510 return weechat_d 511 if not home.is_dir(): 512 raise FileNotFoundError(f"home inexistente: {home}") 513 if not xdg.is_dir(): 514 xdg.mkdir(parents=True, exist_ok=True) 515 os.chmod(xdg, 0o700) 516 os.chown(xdg, uid, gid) 517 elif xdg.stat().st_uid != uid: 518 log.warning("%s não pertence a uid %d; não altero dono do .config inteiro", xdg, uid) 519 if not weechat_d.is_dir(): 520 weechat_d.mkdir(parents=True, exist_ok=True) 521 os.chmod(weechat_d, 0o700) 522 os.chown(weechat_d, uid, gid) 523 else: 524 os.chmod(weechat_d, 0o700) 525 try: 526 os.chown(weechat_d, uid, gid) 527 except OSError as e: 528 log.warning("chown %s: %s", weechat_d, e) 529 return weechat_d 530 531 532 def run_weechat_script( 533 *, 534 username: str, 535 home: Path, 536 weechat_bin: str, 537 command_chain: str, 538 dry_run: bool, 539 log: logging.Logger, 540 allow_failure: bool = False, 541 ) -> bool: 542 runuser = shutil.which("runuser") 543 if not runuser: 544 log.error("runuser não encontrado (pacote util-linux).") 545 return False 546 weechat_dir = weechat_config_dir(home) 547 cmd: list[str] = [ 548 runuser, 549 "-u", 550 username, 551 "--", 552 weechat_bin, 553 "-d", 554 str(weechat_dir), 555 "-a", 556 "--stdout", 557 "-r", 558 command_chain, 559 ] 560 r = run_cmd(cmd, dry_run=dry_run, log=log) 561 if dry_run: 562 return True 563 assert r is not None 564 out = (r.stdout or "") + (r.stderr or "") 565 if r.returncode != 0: 566 msg = f"weechat-headless código {r.returncode} para {username}: {out.strip() or '(sem saída)'}" 567 if allow_failure: 568 log.debug("%s (ignorado)", msg) 569 return True 570 log.error("%s", msg) 571 return False 572 if out.strip(): 573 log.debug("weechat-headless saída (%s): %s", username, out.strip()[:2000]) 574 return True 575 576 577 def patch_user( 578 username: str, 579 *, 580 host: str, 581 port: int, 582 tls: bool, 583 server: str, 584 autojoin: str, 585 force: bool, 586 weechat_bin: str, 587 dry_run: bool, 588 log: logging.Logger, 589 ) -> bool: 590 try: 591 pw = pwd.getpwnam(username) 592 except KeyError: 593 log.error("utilizador inexistente: %s", username) 594 return False 595 if username in IRC_PATCH_SKIP_USERS: 596 log.warning("utilizador reservado, ignorado: %s", username) 597 return False 598 if pw.pw_uid < MIN_UID_USER: 599 log.warning("UID < %d, ignorado: %s", MIN_UID_USER, username) 600 return False 601 602 home = Path(pw.pw_dir) 603 uid, gid = pw.pw_uid, pw.pw_gid 604 try: 605 ensure_xdg_weechat_dir(home, uid, gid, log, dry_run) 606 except OSError as e: 607 log.error("%s: %s", username, e) 608 return False 609 610 irc_conf = weechat_config_dir(home) / "irc.conf" 611 weechat_conf = weechat_config_dir(home) / "weechat.conf" 612 conf_text = "" 613 if irc_conf.is_file(): 614 try: 615 conf_text = irc_conf.read_text(encoding="utf-8", errors="replace") 616 except OSError as e: 617 log.debug("%s: ler %s: %s", username, irc_conf, e) 618 619 if not force and config_matches( 620 irc_conf, 621 server=server, 622 host=host, 623 port=port, 624 tls=tls, 625 unix_username=username, 626 autojoin=autojoin, 627 log=log, 628 ): 629 log.info("%s: IRC já conforme (runv + sem autoconnect noutros) — no-op", username) 630 return True 631 632 opts_runv = parse_server_options(conf_text, server) 633 runv_ok = runv_server_options_match( 634 opts_runv, 635 host=host, 636 port=port, 637 tls=tls, 638 unix_username=username, 639 autojoin=autojoin, 640 log=log, 641 ) 642 others_ok = non_primary_servers_autoconnect_all_off(conf_text, server, log) 643 nicklist_ok = nicklist_visible(weechat_conf, log) 644 645 if not force and runv_ok and others_ok and not nicklist_ok: 646 log.info("%s: só configurar nicklist visível", username) 647 ok = run_weechat_script( 648 username=username, 649 home=home, 650 weechat_bin=weechat_bin, 651 command_chain=build_nicklist_ui_chain(), 652 dry_run=dry_run, 653 log=log, 654 ) 655 if ok and not dry_run and weechat_conf.is_file(): 656 try: 657 os.chown(weechat_conf, uid, gid) 658 except OSError: 659 pass 660 return ok 661 662 if not force and runv_ok and not others_ok: 663 disable_others = build_disable_other_autoconnect_chain(conf_text, server) 664 if nicklist_ok: 665 log.info("%s: só desligar autoconnect noutros servidores", username) 666 chain = chain_with_save_quit(disable_others) 667 else: 668 log.info("%s: desligar autoconnect noutros servidores e configurar nicklist", username) 669 chain = merge_command_chains(disable_others, build_nicklist_ui_chain()) 670 ok = run_weechat_script( 671 username=username, 672 home=home, 673 weechat_bin=weechat_bin, 674 command_chain=chain, 675 dry_run=dry_run, 676 log=log, 677 ) 678 if ok and not dry_run and irc_conf.is_file(): 679 try: 680 os.chown(irc_conf, uid, gid) 681 except OSError: 682 pass 683 if ok and not dry_run and weechat_conf.is_file(): 684 try: 685 os.chown(weechat_conf, uid, gid) 686 except OSError: 687 pass 688 return ok 689 690 server_exists = bool(opts_runv.get("addresses")) 691 692 if server_exists and (force or not runv_ok): 693 del_chain = f"/server del {server} ; /quit" 694 if force: 695 log.info("%s: remover servidor %s existente (--force)", username, server) 696 else: 697 log.info("%s: realinhar servidor «%s» (remove e volta a criar)", username, server) 698 run_weechat_script( 699 username=username, 700 home=home, 701 weechat_bin=weechat_bin, 702 command_chain=del_chain, 703 dry_run=dry_run, 704 log=log, 705 allow_failure=True, 706 ) 707 if not dry_run and irc_conf.is_file(): 708 try: 709 conf_text = irc_conf.read_text(encoding="utf-8", errors="replace") 710 except OSError: 711 conf_text = "" 712 713 apply_chain = build_apply_command_chain( 714 server=server, 715 host=host, 716 port=port, 717 tls=tls, 718 unix_username=username, 719 autojoin=autojoin, 720 ) 721 # apply_chain já termina em /save;/quit — prefixar desligar outros antes do /server add. 722 full_chain = merge_command_chains( 723 build_disable_other_autoconnect_chain(conf_text, server), 724 apply_chain, 725 ) 726 log.info("%s: aplicar configuração IRC — servidor «%s» (weechat-headless)", username, server) 727 ok = run_weechat_script( 728 username=username, 729 home=home, 730 weechat_bin=weechat_bin, 731 command_chain=full_chain, 732 dry_run=dry_run, 733 log=log, 734 ) 735 if not ok: 736 return False 737 if not dry_run and irc_conf.is_file(): 738 try: 739 os.chown(irc_conf, uid, gid) 740 except OSError: 741 pass 742 if not dry_run and weechat_conf.is_file(): 743 try: 744 os.chown(weechat_conf, uid, gid) 745 except OSError: 746 pass 747 return True 748 749 750 def validate_post( 751 sample_user: str | None, 752 *, 753 host: str, 754 port: int, 755 tls: bool, 756 server: str, 757 autojoin: str, 758 log: logging.Logger, 759 ) -> None: 760 if not CHAT_DEST.is_file() or not os.access(CHAT_DEST, os.X_OK): 761 log.warning("validação: %s em falta ou não executável", CHAT_DEST) 762 else: 763 log.info("validação: launcher %s OK", CHAT_DEST) 764 if not sample_user: 765 return 766 try: 767 pw = pwd.getpwnam(sample_user) 768 except KeyError: 769 return 770 irc_conf = weechat_config_dir(Path(pw.pw_dir)) / "irc.conf" 771 weechat_conf = weechat_config_dir(Path(pw.pw_dir)) / "weechat.conf" 772 if not irc_conf.is_file(): 773 log.warning("validação: %s sem %s", sample_user, irc_conf) 774 return 775 if not nicklist_visible(weechat_conf, log): 776 log.warning( 777 "validação: %s sem nicklist lateral visível; reaplique patch_irc.py --user %s --force", 778 sample_user, 779 sample_user, 780 ) 781 if config_matches( 782 irc_conf, 783 server=server, 784 host=host, 785 port=port, 786 tls=tls, 787 unix_username=sample_user, 788 autojoin=autojoin, 789 log=log, 790 ): 791 log.info( 792 "validação: %s — runv=%s/%s TLS=%s autoconnect+autojoin OK; " 793 "nicklist visível; outros sem autoconnect", 794 sample_user, 795 host, 796 port, 797 tls, 798 ) 799 return 800 log.warning("validação: %s — config não passa em todas as verificações (ver patch / irc.conf)", sample_user) 801 802 803 def parse_args(argv: list[str] | None) -> argparse.Namespace: 804 p = argparse.ArgumentParser( 805 description="Provisiona IRC (servidor runv, weechat-headless) e instala o comando chat.", 806 ) 807 p.add_argument("--dry-run", action="store_true", help="só mostrar o plano") 808 p.add_argument("--verbose", action="store_true", help="log detalhado") 809 p.add_argument("--force", action="store_true", help="recriar o servidor runv mesmo se já parecer conforme") 810 p.add_argument("--skip-launcher", action="store_true", help="não instalar /usr/local/bin/chat") 811 p.add_argument("--skip-backfill", action="store_true", help="não aplicar config por utilizador") 812 p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON, metavar="PATH") 813 p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT, metavar="PATH") 814 p.add_argument("--host", default=DEFAULT_HOST, help="hostname IRC") 815 p.add_argument( 816 "--port", 817 type=int, 818 default=None, 819 metavar="PORT", 820 help=f"porta (omissão: {DEFAULT_PORT_TLS} com TLS, 6667 sem TLS)", 821 ) 822 tls_g = p.add_mutually_exclusive_group() 823 tls_g.add_argument("--tls", dest="tls", action="store_true", help="usar TLS (padrão)") 824 tls_g.add_argument("--no-tls", dest="tls", action="store_false", help="IRC sem TLS") 825 p.set_defaults(tls=True) 826 p.add_argument( 827 "--server-name", 828 default=DEFAULT_SERVER_NAME, 829 metavar="NAME", 830 help="nome interno na config IRC (equivalente a /server add …)", 831 ) 832 p.add_argument( 833 "--autojoin", 834 default=DEFAULT_AUTOJOIN, 835 metavar="CHANNEL", 836 help=( 837 f'canal único por omissão ({DEFAULT_AUTOJOIN!r}); ' 838 'use --autojoin "" para não autoentrar em canais' 839 ), 840 ) 841 ug = p.add_mutually_exclusive_group(required=True) 842 ug.add_argument("--user", metavar="USER", help="apenas este utilizador Unix") 843 ug.add_argument("--all-users", action="store_true", help="todos os utilizadores válidos") 844 return p.parse_args(argv) 845 846 847 def main(argv: list[str] | None = None) -> int: 848 args = parse_args(argv) 849 ensure_admin_cli( 850 script_name=Path(__file__).name, 851 dry_run=bool(args.dry_run), 852 ) 853 log = setup_logging(args.verbose) 854 855 if args.port is None: 856 port = DEFAULT_PORT_TLS if args.tls else 6667 857 else: 858 port = args.port 859 860 if not args.dry_run: 861 require_root(log) 862 else: 863 log.info("dry-run: não grava alterações.") 864 865 if not args.skip_launcher: 866 install_chat_launcher(dry_run=args.dry_run, log=log) 867 868 weechat_bin = find_weechat_headless(log) 869 if not args.skip_backfill and not weechat_bin: 870 log.error( 871 "weechat-headless não encontrado no PATH; instale o pacote Debian «weechat-headless» (ex.: apt).", 872 ) 873 return 1 874 875 if args.all_users: 876 users = resolve_all_users(args.users_json, args.homes_root, log) 877 else: 878 assert args.user is not None 879 users = [args.user] 880 881 failures = 0 882 autojoin = args.autojoin.strip() 883 if not args.skip_backfill: 884 assert weechat_bin is not None 885 for u in users: 886 if u in IRC_PATCH_SKIP_USERS: 887 log.warning("ignorado (reservado): %s", u) 888 continue 889 ok = patch_user( 890 u, 891 host=args.host, 892 port=port, 893 tls=args.tls, 894 server=args.server_name, 895 autojoin=autojoin, 896 force=args.force, 897 weechat_bin=weechat_bin, 898 dry_run=args.dry_run, 899 log=log, 900 ) 901 if not ok: 902 failures += 1 903 else: 904 log.info("backfill ignorado (--skip-backfill).") 905 906 sample = users[0] if users else None 907 validate_post( 908 sample, 909 host=args.host, 910 port=port, 911 tls=args.tls, 912 server=args.server_name, 913 autojoin=autojoin, 914 log=log, 915 ) 916 917 print() 918 print("========== patch_irc — resumo ==========") 919 print(f"Modo: {'DRY-RUN' if args.dry_run else 'aplicação'}") 920 print(f"Host: {args.host}:{port} TLS: {args.tls} servidor na config: {args.server_name}") 921 print(f"Autojoin (só runv): {autojoin if autojoin else '(nenhum)'}") 922 if not args.skip_backfill: 923 print(f"Utilizadores processados: {len(users)} falhas: {failures}") 924 print("Comando para utilizadores: chat") 925 print("========================================") 926 927 return 1 if failures else 0 928 929 930 if __name__ == "__main__": 931 raise SystemExit(main())