runv-server

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

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())