runv-server

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

commit d6ab5efcf02d0de615a856434cbe7724a079caf7
parent 0db766cd18c714fc020048f6a15c22298de47b77
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sun, 22 Mar 2026 18:46:00 -0300

documentação: still-1

Diffstat:
MDOCS_REBUILD_CHANGELOG.md | 11++++++++++-
Mdocs/05-tools-and-system-experience.md | 7+++++--
Mdocs/06-site-and-apache.md | 5+++--
Mdocs/07-public-members-directory.md | 19++++++++++++++-----
Mdocs/10-user-provisioning-and-admin-ops.md | 8+++++---
Mdocs/11-daily-operations.md | 13++++++++++++-
Mdocs/13-troubleshooting.md | 2+-
Mdocs/14-smoke-tests-and-validation.md | 2+-
Mdocs/15-glossary-and-reference.md | 2+-
Mpatches/patch_irc.py | 284++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mscripts/admin/create_runv_user.py | 152++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mscripts/admin/del-user.py | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mscripts/admin/runv_jail.py | 34++++++++++++++++++++++++++++++++++
Ascripts/admin/runv_landing_sync.py | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/admin/update_user.py | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msite/genlanding.py | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Atests/test_patch_irc.py | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/bin/chat | 5+++--
Mtools/motd/60-runv | 2+-
19 files changed, 856 insertions(+), 172 deletions(-)

diff --git a/DOCS_REBUILD_CHANGELOG.md b/DOCS_REBUILD_CHANGELOG.md @@ -26,7 +26,16 @@ Documento em **pt-BR**. Data da passagem: conforme o commit em que este ficheiro | [docs/diagrams/architecture.mmd](docs/diagrams/architecture.mmd) | Sequência SSH entre → fila (Mermaid) | | [docs/diagrams/member-flow.mmd](docs/diagrams/member-flow.mmd) | Fluxo pedido → admin → dados públicos (Mermaid) | -Alteração mínima **fora** de `docs/` para não quebrar referências em código ou templates: +## Actualizações posteriores (código + docs) + +- **`genlanding.py --sync-public-only`:** cópia de `site/public/` para o DocumentRoot + `members.json`, sem reconfigurar Apache (`site/genlanding.py` v0.05). +- **`create_runv_user.py`:** após criar membro, invoca esse modo em vez de só `build_directory.py`; `--no-refresh-landing-members` omite cópia e JSON. +- **MOTD** [`tools/motd/60-runv`](tools/motd/60-runv): título “Últimos usuários online” sem o sufixo explicativo entre parêntesis. +- Documentação actualizada: `docs/06`, `docs/07`, `docs/10`, `docs/11`, `docs/13`, `docs/15`. + +--- + +Alteração mínima **fora** de `docs/` para não quebrar referências em código ou templates (reconstrução inicial): - [README.md](README.md) (raiz): ponteiro para `docs/README.md`. - `tools/tools.py`, `site/build_directory.py`, `email/configure_mailgun.py`, `email/configure_msmtp_legacy.py`: docstrings / mensagens apontam para `docs/…` em vez de `.md` removidos nos módulos. diff --git a/docs/05-tools-and-system-experience.md b/docs/05-tools-and-system-experience.md @@ -25,8 +25,11 @@ sudo python3 tools.py Flags úteis: `--force`, `--skip-apt` (ver `--help`). -## IRC / patches +## IRC / comando `chat` -A rede IRC “da casa” e o comando `chat` ligam-se a `patches/patch_irc.py` conforme documentação histórica do módulo (código em `patches/`). +- **Utilizador:** no servidor, use apenas o comando `chat` (wrapper em `/usr/local/bin/chat` após `tools/tools.py` ou `patches/patch_irc.py`). O cliente gráfico no terminal é `weechat` / `weechat-curses` (pacote `chat` no manifesto APT). +- **Por omissão** (após `patches/patch_irc.py`): o WeeChat fica com um único servidor com autoconnect no arranque — nome interno **`runv`**, endereço **`irc.portalidea.com.br`**, porta **6697**, **TLS ligado**, autojoin só **`#runv`**. Outras redes que o utilizador adicionar manualmente **não** autoconectam por defeito (o patch desliga `autoconnect` nos outros servidores já existentes, sem apagar redes). +- **Provisionamento:** o patch corre com `weechat-headless -a -r '…' --stdout` (o `-a` evita auto-connect durante o batch). O launcher **`chat` não usa `-a`**. Novas contas Unix criadas com `scripts/admin/create_runv_user.py` invocam o patch automaticamente para esse utilizador. +- **Backfill / admin:** `sudo python3 patches/patch_irc.py --all-users` (ou `--user NOME`). Requer `weechat-headless` no sistema. Próximo: [06-site-and-apache.md](06-site-and-apache.md). diff --git a/docs/06-site-and-apache.md b/docs/06-site-and-apache.md @@ -14,7 +14,8 @@ - Modo `--dev`: `runv.local`, `/var/www/runv-dev/html`. - Opcional: `--certbot` (incompatível com `--dev`). - Após cópia, por omissão chama `build_directory.py` para gravar `data/members.json` no DocumentRoot (`--no-refresh-members` para omitir). -- Versão actual do script: constante `VERSION` no ficheiro (ex.: `0.04`). +- **`--sync-public-only`:** só copia `site/public/` → DocumentRoot, `chown www-data` e regenera `members.json`; **não** altera Apache (uso típico após `create_runv_user.py` e disponível para correr à mão). +- Versão actual do script: constante `VERSION` no ficheiro (ex.: `0.05`). ## TLS e DNS @@ -23,6 +24,6 @@ ## Constelação (bolhas) - Depende de `members.json` no DocumentRoot. -- Após **`create_runv_user.py`:** se `--landing-document-root` existir como directório, o script tenta regerar `data/members.json` e imprime linha **`constelação (bolhas)`** ou **AVISO** se faltar path ou falhar o refresh (**evidência:** código actual em `create_runv_user.py`). +- Após **`create_runv_user.py`:** se `--landing-document-root` existir como directório, o script corre **`genlanding.py --sync-public-only`** (cópia de `site/public/` + `members.json`) e imprime **`landing (public + bolhas)`** ou **AVISO** se faltar path ou falhar (**evidência:** `create_runv_user.py`). Próximo: [07-public-members-directory.md](07-public-members-directory.md). diff --git a/docs/07-public-members-directory.md b/docs/07-public-members-directory.md @@ -26,9 +26,10 @@ Cada elemento do array gerado contém: ## Quando regenerar -1. **Hooks:** `create_runv_user.py` (se DocumentRoot existir e refresh activo); `genlanding.py` após cópia (por omissão). -2. **Cron (opcional):** exemplo histórico em `INSTALL` — adequado se quiser actualização periódica sem depender só de criar utilizadores. -3. **Manual:** +1. **Hook em `create_runv_user.py`:** se `--landing-document-root` existir como directório e **não** usar `--no-refresh-landing-members`, o script invoca `site/genlanding.py --sync-public-only` — copia `site/public/` para o DocumentRoot, `chown www-data` e corre `build_directory.py` para `data/members.json` (equivalente a sincronizar landing + bolhas num único passo). +2. **`genlanding.py` completo** (primeira instalação / Apache): após `copy_landing`, por omissão também regenera `members.json` (a menos de `--no-refresh-members`). +3. **Cron (opcional):** adequado para alinhar `members.json` com `users.json` periodicamente, mesmo sem novos provisionamentos. +4. **Manual — só `members.json` (sem recopiar `public/`):** ```bash python3 REPO/site/build_directory.py \ @@ -36,9 +37,17 @@ python3 REPO/site/build_directory.py \ -o /var/www/runv.club/html/data/members.json ``` +5. **Manual — `public/` + `members.json` (sem reconfigurar Apache):** + +```bash +sudo python3 REPO/site/genlanding.py --sync-public-only \ + --document-root /var/www/runv.club/html \ + --members-users-json /var/lib/runv/users.json +``` + ## Cron vs hooks (sem contradição) -- **Hooks** actualizam quando corres `create_runv_user` ou `genlanding`. -- **Cron** é **opcional** para alinhar site com `users.json` mesmo sem novos provisionamentos. +- **Hooks** actualizam quando corres `create_runv_user` (sync-only) ou `genlanding` (completo ou sync-only). +- **Cron** com `build_directory.py` é **opcional** para alinhar só o JSON sem tocar no resto do DocumentRoot. Próximo: [08-email.md](08-email.md). diff --git a/docs/10-user-provisioning-and-admin-ops.md b/docs/10-user-provisioning-and-admin-ops.md @@ -7,10 +7,12 @@ - **Único** script de criação de **membros** com a política completa (docstring longa no ficheiro): `adduser`, chaves, `public_html` / gopher / gemini, permissões, jail (Jailkit), quota, metadados em `users.json`. - Executar como **root** no servidor Debian. -## Pós-criação: constelação +## Pós-criação: landing pública e constelação -- Flag `--landing-document-root` (default `/var/www/runv.club/html`): se o directório **existir**, corre `build_directory.py` para `data/members.json` (salvo `--no-refresh-landing-members`). -- Saída explícita para o operador: linha de **sucesso** com contagem ou **AVISO** com comando sugerido se path em falta ou falha (**código actual**). +- **`genlanding.py` completo** continua necessário para a **primeira** montagem do site (VirtualHost Apache, módulos, cópia inicial). Não é preciso repetir esse fluxo **a cada** novo membro. +- Flag **`--landing-document-root`** (default `/var/www/runv.club/html`): se o directório **existir**, após gravar `users.json` o script invoca **`site/genlanding.py --sync-public-only`** — recopia `site/public/` para o DocumentRoot, aplica `chown` a `www-data` e regenera `data/members.json` via `build_directory.py` interno ao genlanding. +- **`--no-refresh-landing-members`:** omite toda essa sincronização (nem cópia de `public/` nem `members.json`). +- Saída para o operador: linha **`landing (public + bolhas): sincronizado`** com contagem opcional, ou **AVISO** com comando manual (`genlanding.py --sync-public-only …`) se o DocumentRoot não existir ou o subprocess falhar. ## Outros scripts admin diff --git a/docs/11-daily-operations.md b/docs/11-daily-operations.md @@ -6,15 +6,26 @@ 1. Pedido via `entre` ou processo interno. 2. `sudo python3 scripts/admin/create_runv_user.py …` (ver `--help` no servidor). -3. Confirmar linha **constelação (bolhas)** ou corrigir com `build_directory.py` manual. +3. Confirmar linha **`landing (public + bolhas): sincronizado`** ou corrigir com `genlanding.py --sync-public-only` (ou só `build_directory.py` se bastar actualizar `members.json`). ## Actualizar lista pública sem novo membro +Só regenerar **`members.json`**: + ```bash sudo python3 REPO/site/build_directory.py \ --users-json /var/lib/runv/users.json \ -o /var/www/runv.club/html/data/members.json ``` + +Recopiar também **`site/public/`** (assets/HTML) para o DocumentRoot + `members.json`: + +```bash +sudo python3 REPO/site/genlanding.py --sync-public-only \ + --document-root /var/www/runv.club/html \ + --members-users-json /var/lib/runv/users.json +``` + (Ajustar paths ao teu DocumentRoot.) ## Após `git pull` no servidor diff --git a/docs/13-troubleshooting.md b/docs/13-troubleshooting.md @@ -5,7 +5,7 @@ ## Bolhas / constelação não aparecem 1. Confirmar que existe **`DocumentRoot/data/members.json`** (não só `site/public/data/members.json` no clone). -2. Ver mensagem de **`create_runv_user.py`**: AVISO se DocumentRoot inexistente. +2. Ver mensagem de **`create_runv_user.py`**: AVISO se DocumentRoot inexistente ou se `genlanding --sync-public-only` falhou (ver log / comando manual sugerido). 3. Browser: em viewport ≤768px o JS **omitido** de propósito (`app.js`). ## `members.json` vazio diff --git a/docs/14-smoke-tests-and-validation.md b/docs/14-smoke-tests-and-validation.md @@ -36,7 +36,7 @@ Vários scripts importam `fcntl` ou `grp` — **não executáveis** em Windows t - `terminal/setup_entre.py --help` - `site/genlanding.py --help` -Em **Debian:** correr os `--help` acima e guardar a saída para operadores. +Em **Debian:** correr os `--help` acima e guardar a saída para operadores. Confirmar que `site/genlanding.py --help` lista **`--sync-public-only`**. ## O que **não** existe no repo (facto) diff --git a/docs/15-glossary-and-reference.md b/docs/15-glossary-and-reference.md @@ -22,7 +22,7 @@ | `scripts/admin/update_user.py` | Actualizar membro / metadados | | `scripts/admin/del-user.py` | Remover membro | | `tools/tools.py` | APT, MOTD, skel, binários locais | -| `site/genlanding.py` | Apache + cópia landing + refresh members | +| `site/genlanding.py` | Apache + cópia landing + refresh members; `--sync-public-only` só cópia `public/` + members (sem Apache) | | `site/build_directory.py` | users.json → members.json público | | `email/configure_mailgun.py` | Config email Mailgun / legado | | `terminal/setup_entre.py` | Instalar fluxo `entre` | diff --git a/patches/patch_irc.py b/patches/patch_irc.py @@ -6,16 +6,17 @@ O conjunto ``IRC_PATCH_SKIP_USERS`` também é usado por ``resolve_all_users`` p backfill Gopher/Gemini (``setup_alt_protocols.py``): contas listadas não recebem bind mount em ``/var/gemini/users/<user>`` nem entram no menu Gopher/Gemini raiz. -- 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. +- Config em ~/.config/weechat (XDG), servidor interno «runv», TLS, autoconnect só nele. +- Outros servidores existentes mantêm-se; apenas ``irc.server.<outro>.autoconnect`` fica ``off``. +- Aplicação **só** via ``weechat-headless`` (-a, -r, --stdout) no patch; o launcher ``chat`` não usa -a. - 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; detalhes em scripts/docs/irc_patch.md. +Executar como root no Debian; detalhes em docs/05-tools-and-system-experience.md. SASL/NickServ: ver constante ``SASL_WEECHAT_SNIPPETS`` e https://weechat.org/doc/ -Versão 0.03 — runv.club +Versão 0.04 — runv.club """ from __future__ import annotations @@ -40,11 +41,12 @@ SASL_WEECHAT_SNIPPETS: Final[tuple[str, ...]] = ( '/set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}"', ) -VERSION: Final[str] = "0.03" +VERSION: Final[str] = "0.04" 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_PORT_TLS: Final[int] = 6697 DEFAULT_SERVER_NAME: Final[str] = "runv" DEFAULT_AUTOJOIN: Final[str] = "#runv" @@ -237,6 +239,29 @@ def weechat_config_dir(home: Path) -> Path: return home / ".config" / "weechat" +def parse_all_server_names(irc_conf_text: str) -> set[str]: + """Nomes de servidor na secção [server] (prefixos antes do primeiro '.' na chave).""" + names: set[str] = set() + in_server = False + for raw in irc_conf_text.splitlines(): + line = raw.strip() + if line == "[server]": + in_server = True + continue + if line.startswith("[") and line.endswith("]"): + in_server = False + continue + if not in_server or not line or line.startswith("#") or "=" not in line: + continue + key_part = line.split("=", 1)[0].strip() + if "." not in key_part: + continue + srv, _sub = key_part.split(".", 1) + if srv: + names.add(srv) + return names + + def parse_server_options(irc_conf_text: str, server: str) -> dict[str, str]: opts: dict[str, str] = {} in_server = False @@ -272,29 +297,25 @@ def tls_effective(opts: dict[str, str]) -> bool: return v in ("on", "true", "yes", "1") +def autoconnect_enabled(opts: dict[str, str]) -> bool: + ac = (opts.get("autoconnect") or "off").lower() + return ac in ("on", "true", "yes", "1") + + def expected_nicks(username: str) -> str: return f"{username},{username}_,{username}__,{username}|away" -def config_matches( - irc_conf: Path, +def runv_server_options_match( + opts: dict[str, str], *, - server: str, host: str, port: int, tls: bool, - username: str, + unix_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() @@ -305,15 +326,14 @@ def config_matches( if tls_effective(opts) != tls: log.debug("tls/ssl diverge") return False - if opts.get("nicks") != expected_nicks(username): + if opts.get("nicks") != expected_nicks(unix_username): log.debug("nicks divergem") return False - if (opts.get("username") or "") != username: + if (opts.get("username") or "") != unix_username: return False - if (opts.get("realname") or "") != username: + if (opts.get("realname") or "") != unix_username: return False - ac = (opts.get("autoconnect") or "off").lower() - if ac not in ("on", "true", "yes", "1"): + if not autoconnect_enabled(opts): return False aj = opts.get("autojoin") or "" if aj != autojoin: @@ -322,31 +342,93 @@ def config_matches( return True +def non_primary_servers_autoconnect_all_off( + irc_conf_text: str, + primary: str, + log: logging.Logger, +) -> bool: + for name in parse_all_server_names(irc_conf_text): + if name == primary: + continue + o = parse_server_options(irc_conf_text, name) + if not o.get("addresses"): + continue + if autoconnect_enabled(o): + log.debug("servidor %r tem autoconnect on (deveria off)", name) + return False + return True + + +def config_matches( + irc_conf: Path, + *, + server: str, + host: str, + port: int, + tls: bool, + unix_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 not runv_server_options_match( + opts, + host=host, + port=port, + tls=tls, + unix_username=unix_username, + autojoin=autojoin, + log=log, + ): + return False + return non_primary_servers_autoconnect_all_off(text, server, log) + + +def build_disable_other_autoconnect_chain(irc_conf_text: str, primary: str) -> str: + """Comandos /set para desligar autoconnect em servidores != primary (só onde está on).""" + parts: list[str] = [] + for name in sorted(parse_all_server_names(irc_conf_text)): + if name == primary: + continue + o = parse_server_options(irc_conf_text, name) + if not o.get("addresses"): + continue + if not autoconnect_enabled(o): + continue + parts.append(f"/set irc.server.{name}.autoconnect off") + return " ; ".join(parts) + + def build_apply_command_chain( *, server: str, host: str, port: int, tls: bool, - username: str, + unix_username: str, autojoin: str, ) -> str: - add_tokens = [f"/server add {server} {host}/{port}"] + # Sem -autoconnect no /server add: autoconnect via /set (requisito runv). + add_cmd = 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) + add_cmd += " -tls" + parts: list[str] = [add_cmd] + nicks = expected_nicks(unix_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}.username "{unix_username}"') + parts.append(f'/set irc.server.{server}.realname "{unix_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( @@ -357,6 +439,17 @@ def build_apply_command_chain( return " ; ".join(parts) +def chain_with_save_quit(prefix_chain: str) -> str: + p = prefix_chain.strip() + if p: + return f"{p} ; /save ; /quit" + return "/save ; /quit" + + +def merge_command_chains(*parts: str) -> str: + return " ; ".join(s.strip() for s in parts if s and s.strip()) + + 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) @@ -463,38 +556,66 @@ def patch_user( return False irc_conf = weechat_config_dir(home) / "irc.conf" - matched = config_matches( + conf_text = "" + if irc_conf.is_file(): + try: + conf_text = irc_conf.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log.debug("%s: ler %s: %s", username, irc_conf, e) + + if not force and config_matches( irc_conf, server=server, host=host, port=port, tls=tls, - username=username, + unix_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) + ): + log.info("%s: IRC já conforme (runv + sem autoconnect noutros) — no-op", username) 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) + opts_runv = parse_server_options(conf_text, server) + runv_ok = runv_server_options_match( + opts_runv, + host=host, + port=port, + tls=tls, + unix_username=username, + autojoin=autojoin, + log=log, + ) + others_ok = non_primary_servers_autoconnect_all_off(conf_text, server, log) - if server_exists and (force or not matched): + disable_others = build_disable_other_autoconnect_chain(conf_text, server) + + if not force and runv_ok and not others_ok: + log.info("%s: só desligar autoconnect noutros servidores", username) + chain = chain_with_save_quit(disable_others) + ok = run_weechat_script( + username=username, + home=home, + weechat_bin=weechat_bin, + command_chain=chain, + dry_run=dry_run, + log=log, + ) + if ok and not dry_run and irc_conf.is_file(): + try: + os.chown(irc_conf, uid, gid) + except OSError: + pass + return ok + + server_exists = bool(opts_runv.get("addresses")) + + if server_exists and (force or not runv_ok): 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, - ) + log.info("%s: realinhar servidor «%s» (remove e volta a criar)", username, server) run_weechat_script( username=username, home=home, @@ -504,21 +625,29 @@ def patch_user( log=log, allow_failure=True, ) - - chain = build_apply_command_chain( + if not dry_run and irc_conf.is_file(): + try: + conf_text = irc_conf.read_text(encoding="utf-8", errors="replace") + except OSError: + conf_text = "" + disable_others = build_disable_other_autoconnect_chain(conf_text, server) + + apply_chain = build_apply_command_chain( server=server, host=host, port=port, tls=tls, - username=username, + unix_username=username, autojoin=autojoin, ) + # apply_chain já termina em /save;/quit — prefixar desligar outros antes do /server add. + full_chain = merge_command_chains(disable_others, apply_chain) 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, + command_chain=full_chain, dry_run=dry_run, log=log, ) @@ -534,7 +663,12 @@ def patch_user( def validate_post( sample_user: str | None, + *, + host: str, + port: int, + tls: bool, server: str, + autojoin: str, log: logging.Logger, ) -> None: if not CHAT_DEST.is_file() or not os.access(CHAT_DEST, os.X_OK): @@ -551,11 +685,19 @@ def validate_post( 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) + if config_matches( + irc_conf, + server=server, + host=host, + port=port, + tls=tls, + unix_username=sample_user, + autojoin=autojoin, + log=log, + ): + log.info("validação: %s — runv=%s/%s TLS=%s autoconnect+autojoin OK; outros sem autoconnect", sample_user, host, port, tls) + return + log.warning("validação: %s — config não passa em todas as verificações (ver patch / irc.conf)", sample_user) def parse_args(argv: list[str] | None) -> argparse.Namespace: @@ -575,7 +717,7 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: type=int, default=None, metavar="PORT", - help="porta (omissão: 6697 com TLS, 6667 sem TLS)", + help=f"porta (omissão: {DEFAULT_PORT_TLS} 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)") @@ -590,9 +732,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: p.add_argument( "--autojoin", default=DEFAULT_AUTOJOIN, - metavar="CHANNELS", + metavar="CHANNEL", help=( - f'canais separados por vírgula (padrão: {DEFAULT_AUTOJOIN!r}); ' + f'canal único por omissão ({DEFAULT_AUTOJOIN!r}); ' 'use --autojoin "" para não autoentrar em canais' ), ) @@ -607,7 +749,7 @@ def main(argv: list[str] | None = None) -> int: log = setup_logging(args.verbose) if args.port is None: - port = 6697 if args.tls else 6667 + port = DEFAULT_PORT_TLS if args.tls else 6667 else: port = args.port @@ -633,6 +775,7 @@ def main(argv: list[str] | None = None) -> int: users = [args.user] failures = 0 + autojoin = args.autojoin.strip() if not args.skip_backfill: assert weechat_bin is not None for u in users: @@ -645,7 +788,7 @@ def main(argv: list[str] | None = None) -> int: port=port, tls=args.tls, server=args.server_name, - autojoin=args.autojoin.strip(), + autojoin=autojoin, force=args.force, weechat_bin=weechat_bin, dry_run=args.dry_run, @@ -657,14 +800,21 @@ def main(argv: list[str] | None = None) -> int: log.info("backfill ignorado (--skip-backfill).") sample = users[0] if users else None - validate_post(sample, args.server_name, log) + validate_post( + sample, + host=args.host, + port=port, + tls=args.tls, + server=args.server_name, + autojoin=autojoin, + log=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)'}") + print(f"Autojoin (só runv): {autojoin if autojoin else '(nenhum)'}") if not args.skip_backfill: print(f"Utilizadores processados: {len(users)} falhas: {failures}") print("Comando para utilizadores: chat") diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -67,6 +67,7 @@ if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) import runv_jail +from runv_landing_sync import try_sync_landing_via_genlanding # constantes USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") @@ -1097,64 +1098,6 @@ def try_apply_quota( # CLI -def try_refresh_landing_members_json( - *, - document_root: Path, - users_json: Path, - homes_root: Path | None, - log: logging.Logger, -) -> tuple[bool, int | None]: - """ - Regenera public/data/members.json no DocumentRoot da landing (build_directory.py). - Falhas são apenas registadas — não aborta o provisionamento. - Devolve (sucesso, número de membros no JSON público ou None se não foi possível contar). - """ - script = _REPO_ROOT / "site" / "build_directory.py" - if not script.is_file(): - log.warning( - "build_directory.py não encontrado em %s; members.json da landing não atualizado", - script, - ) - return False, None - out = document_root / "data" / "members.json" - cmd = [ - sys.executable, - str(script), - "--users-json", - str(users_json), - "-o", - str(out), - ] - if homes_root is not None: - cmd.extend(["--homes-root", str(homes_root)]) - try: - r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - err_tail = (r.stderr or r.stdout or "").strip() - if r.returncode != 0: - log.warning( - "build_directory terminou com código %s: %s", - r.returncode, - err_tail[:2000] if err_tail else "(sem saída)", - ) - return False, None - log.info("members.json da landing actualizado em %s", out) - if r.stderr and r.stderr.strip(): - log.debug("build_directory stderr: %s", r.stderr.strip()[:1500]) - n_public: int | None = None - try: - raw = out.read_text(encoding="utf-8") - parsed = json.loads(raw) - if isinstance(parsed, list): - n_public = len(parsed) - log.info("constelação: %s membro(s) no dataset público (%s)", n_public, out) - except (OSError, json.JSONDecodeError, TypeError) as ex: - log.warning("members.json escrito mas não foi possível validar a lista: %s", ex) - return True, n_public - except (OSError, subprocess.TimeoutExpired) as e: - log.warning("falha ao executar build_directory: %s", e) - return False, None - - def print_banner() -> None: print() print(" create_runv_user — provisionamento runv.club") @@ -1307,6 +1250,54 @@ def _resolve_email_package_root(state: dict[str, Any] | None) -> Path | None: return cand if cand.is_dir() else None +def try_patch_irc_for_new_user( + username: str, + *, + dry_run: bool, + log: logging.Logger, +) -> None: + """ + Executa ``patches/patch_irc.py --user`` (WeeChat headless: servidor «runv», TLS, #runv). + Não aborta o provisionamento se o patch falhar; contas em ``IRC_PATCH_SKIP_USERS`` são ignoradas. + """ + if dry_run: + return + patch_path = _REPO_ROOT / "patches" / "patch_irc.py" + if not patch_path.is_file(): + log.warning("patch IRC: ficheiro ausente %s — corra o patch manualmente no servidor.", patch_path) + return + try: + import importlib.util + + spec = importlib.util.spec_from_file_location("patch_irc_embed", patch_path) + if spec is None or spec.loader is None: + log.warning("patch IRC: não foi possível carregar %s", patch_path) + return + pim = importlib.util.module_from_spec(spec) + spec.loader.exec_module(pim) + if username in pim.IRC_PATCH_SKIP_USERS: + log.info("patch IRC omitido (lista reservada / serviço): %s", username) + return + except Exception as e: + log.warning("patch IRC: verificação de skip falhou (%s); tento subprocess mesmo assim.", e) + cmd = [sys.executable, str(patch_path), "--user", username] + log.info("patch IRC: %s", " ".join(cmd)) + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + except (OSError, subprocess.TimeoutExpired) as e: + log.warning("patch IRC: execução falhou: %s", e) + return + if r.returncode != 0: + log.warning( + "patch IRC terminou com código %s para %s: %s", + r.returncode, + username, + ((r.stderr or "") + (r.stdout or "")).strip()[:2000] or "(sem saída)", + ) + else: + log.info("patch IRC concluído para %s (comando «chat», rede runv / #runv).", username) + + def try_send_welcome_email( *, username: str, @@ -1573,21 +1564,23 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: type=Path, default=Path("/var/www/runv.club/html"), help=( - "DocumentRoot da landing Apache (directório existente para actualizar a constelação); " - "após criar o utilizador, executa site/build_directory.py para gravar data/members.json. " - "Se não existir, o refresh é omitido e é impresso um AVISO com o comando sugerido." + "DocumentRoot da landing Apache (directório existente); após criar o utilizador, " + "executa site/genlanding.py --sync-public-only (copia site/public + data/members.json). " + "Se não existir, a sincronização é omitida e é impresso um AVISO com o comando sugerido." ), ) p.add_argument( "--no-refresh-landing-members", action="store_true", - help="não regenerar data/members.json na landing após gravar metadados", + help=( + "não sincronizar site/public → DocumentRoot nem regenerar data/members.json após gravar metadados" + ), ) p.add_argument( "--members-homes-root", type=Path, default=None, - help="se definido (ex. /home), passa --homes-root a build_directory.py (homepage_mtime)", + help="se definido (ex. /home), passa --members-homes-root a genlanding (homepage_mtime em members.json)", ) p.add_argument( "--no-quota", @@ -1752,7 +1745,7 @@ def main(argv: list[str] | None = None) -> int: " ações: (1) adduser + skel (2) authorized_keys (3) public_html " "(4) public_gopher + public_gemini + bind Gemini (5) README só com --with-readme " "(6) permissões (7) jail runv-jailed salvo --no-jail " - "(8) quota (9) verificação + metadados JSON" + "(8) quota (9) verificação + patch IRC (chat) (10) metadados JSON" ) print(f" with-readme: {getattr(args, 'with_readme', False)} no-jail: {getattr(args, 'no_jail', False)}") if args.no_quota: @@ -1858,6 +1851,9 @@ def main(argv: list[str] | None = None) -> int: expect_readme=bool(args.with_readme), ) + log.info("=== fase: IRC WeeChat (patches/patch_irc.py — comando chat, runv / #runv)") + try_patch_irc_for_new_user(user, dry_run=False, log=log) + record = UserRecord( username=user, email=email, @@ -1884,8 +1880,8 @@ def main(argv: list[str] | None = None) -> int: if not args.no_refresh_landing_members and args.landing_document_root: root = args.landing_document_root.resolve() if root.is_dir(): - log.info("=== fase: actualizar members.json da landing (%s)", root) - members_refreshed, members_public_count = try_refresh_landing_members_json( + log.info("=== fase: sincronizar landing (public + members) (%s)", root) + members_refreshed, members_public_count = try_sync_landing_via_genlanding( document_root=root, users_json=args.metadata_file, homes_root=args.members_homes_root.resolve() @@ -1912,6 +1908,7 @@ def main(argv: list[str] | None = None) -> int: print(" public_gopher: pronto (gophermap)") print(" public_gemini: pronto (index.gmi)") print(" bind Gemini: /var/gemini/users/<user> <- ~/public_gemini (se o diretório existir)") + print(" IRC: comando «chat» → irc.portalidea.com.br (TLS) #runv (patch_irc.py)") if args.with_readme: print(" README.md: criado em ~/README.md (pt-BR)") else: @@ -1927,15 +1924,19 @@ def main(argv: list[str] | None = None) -> int: args.landing_document_root.resolve() if args.landing_document_root else None ) out_members = (dr_resolved / "data" / "members.json") if dr_resolved else None + homes_opt = "" + if args.members_homes_root: + homes_opt = f" --members-homes-root {args.members_homes_root.resolve()}" if args.no_refresh_landing_members: - print(" constelação (bolhas): omitida (--no-refresh-landing-members)") + print(" landing (public + bolhas): omitida (--no-refresh-landing-members)") elif dr_resolved is not None: if not dr_resolved.is_dir(): print( - f" AVISO constelação: DocumentRoot inexistente ({dr_resolved}) — " - "bolhas não actualizadas. Depois de criar o site: " - f"python3 {_REPO_ROOT / 'site' / 'build_directory.py'} " - f"--users-json {args.metadata_file} -o {out_members}", + f" AVISO landing: DocumentRoot inexistente ({dr_resolved}) — " + "public/members não actualizados. Primeiro: site/genlanding.py (Apache); depois: " + f"python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only " + f"--document-root {dr_resolved} --members-users-json {args.metadata_file}" + f"{homes_opt}", file=sys.stderr, ) elif members_refreshed: @@ -1944,12 +1945,13 @@ def main(argv: list[str] | None = None) -> int: if members_public_count is not None else "" ) - print(f" constelação (bolhas): actualizado{cnt} → {out_members}") + print(f" landing (public + bolhas): sincronizado{cnt} → {out_members}") else: print( - f" AVISO constelação: falha ao regenerar members.json (ver log). " - f"Manual: python3 {_REPO_ROOT / 'site' / 'build_directory.py'} " - f"--users-json {args.metadata_file} -o {out_members}", + f" AVISO landing: falha ao sincronizar (ver log). " + f"Manual: python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only " + f"--document-root {dr_resolved} --members-users-json {args.metadata_file}" + f"{homes_opt}", file=sys.stderr, ) if args.no_quota: diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py @@ -5,9 +5,13 @@ Remove permanentemente uma conta Unix (banimento) no servidor runv.club (Debian) Usa ``deluser`` com remoção da home. Opcionalmente remove o registro em ``/var/lib/runv/users.json`` se existir. -Executar como root. Não altera Apache nem SSH diretamente. +Antes de ``deluser``: desmonta jail SSH (bind em ``/srv/jail/…``), quota Gemini, etc. +Após actualizar ``users.json``: opcionalmente executa ``site/genlanding.py --sync-public-only`` +(alinhado a ``create_runv_user``). -Versão 0.03 — runv.club +Executar como root. Não altera a configuração Apache; a sincronização só copia ficheiros estáticos. + +Versão 0.04 — runv.club """ from __future__ import annotations @@ -15,6 +19,7 @@ from __future__ import annotations import argparse import fcntl import json +import logging import os import pwd import shutil @@ -31,6 +36,9 @@ _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +import runv_jail +from runv_landing_sync import try_sync_landing_via_genlanding + # constantes USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") @@ -55,13 +63,15 @@ RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( "irc", "_apt", "nobody", + "admin", + "postmaster", } ) DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock") -VERSION: Final[str] = "0.03" +VERSION: Final[str] = "0.04" _REPO_ROOT: Final[Path] = _SCRIPT_DIR.parent.parent @@ -72,6 +82,17 @@ EXIT_SYSTEM: Final[int] = 2 MIN_UID_NORMAL_USER: Final[int] = 1000 +def setup_del_user_log(*, verbose: bool) -> logging.Logger: + log = logging.getLogger("runv.del_user") + log.setLevel(logging.DEBUG if verbose else logging.INFO) + log.propagate = False + if not log.handlers: + h = logging.StreamHandler(sys.stderr) + h.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + log.addHandler(h) + return log + + # validação / root def validate_privileges() -> None: if os.geteuid() != 0: @@ -129,6 +150,14 @@ def enforce_safety_rules( ) raise SystemExit(EXIT_VALIDATION) + if username in runv_jail.JAIL_SKIP_USERNAMES and not force: + print( + f"Erro: {username!r} é conta de serviço runv (SSH signup / admin). " + "Não remover excepto com --force (quebra o sistema).", + file=sys.stderr, + ) + raise SystemExit(EXIT_VALIDATION) + if uid < MIN_UID_NORMAL_USER and not force: print( f"Erro: UID {uid} < {MIN_UID_NORMAL_USER} (conta de sistema). " @@ -610,12 +639,35 @@ def main() -> int: help="não envia email ao utilizador sobre desativação por normas da comunidade", ) parser.add_argument( + "--landing-document-root", + type=Path, + default=Path("/var/www/runv.club/html"), + help=( + "DocumentRoot da landing; após remover entrada em users.json, executa " + "genlanding --sync-public-only (omitido com --skip-metadata ou --no-refresh-landing-members)" + ), + ) + parser.add_argument( + "--no-refresh-landing-members", + action="store_true", + help="não copiar site/public nem regenerar data/members.json após users.json", + ) + parser.add_argument( + "--members-homes-root", + type=Path, + default=None, + metavar="DIR", + help="opcional: --members-homes-root para genlanding (ex. /home)", + ) + parser.add_argument( "--version", action="version", version=f"%(prog)s {VERSION} — runv.club", ) args = parser.parse_args() + log = setup_del_user_log(verbose=args.verbose) + username = validate_username_syntax(args.username) uid, home = check_user_exists(username) @@ -633,6 +685,7 @@ def main() -> int: dry_run=True, ) remove_gemini_user_symlink(username, dry_run=True, verbose=args.verbose) + runv_jail.teardown_runv_jail_for_user(username, home, log, dry_run=True) run_deluser( username, purge_all_files=args.purge_all_files, @@ -647,6 +700,18 @@ def main() -> int: dry_run=True, verbose=args.verbose, ) + if not args.no_refresh_landing_members and args.landing_document_root: + dr = args.landing_document_root.resolve() + if dr.is_dir(): + print( + f" [dry-run] executaria genlanding --sync-public-only " + f"(document-root={dr}, users.json={args.metadata_file})" + ) + elif args.verbose: + print( + f" [dry-run] landing: DocumentRoot inexistente ({dr}); sync omitido", + file=sys.stderr, + ) ban_email = read_user_email_from_metadata(args.metadata_file, username) if args.no_ban_notify_email: print(" notificação ban: omitida (--no-ban-notify-email)") @@ -680,6 +745,16 @@ def main() -> int: remove_gemini_user_symlink(username, dry_run=False, verbose=args.verbose) + try: + runv_jail.teardown_runv_jail_for_user(username, home, log, dry_run=False) + except RuntimeError as e: + print(f"Erro: jail SSH: {e}", file=sys.stderr) + print( + " Resolva o bind em /srv/jail/… antes de remover o utilizador (umount, fstab).", + file=sys.stderr, + ) + raise SystemExit(EXIT_SYSTEM) from e + run_deluser( username, purge_all_files=args.purge_all_files, @@ -696,6 +771,23 @@ def main() -> int: dry_run=False, verbose=args.verbose, ) + if not args.no_refresh_landing_members and args.landing_document_root: + root = args.landing_document_root.resolve() + if root.is_dir(): + log.info("sincronizar landing após remoção de metadados (%s)", root) + try_sync_landing_via_genlanding( + document_root=root, + users_json=args.metadata_file, + homes_root=args.members_homes_root.resolve() + if args.members_homes_root + else None, + log=log, + ) + else: + log.warning( + "DocumentRoot da landing inexistente (%s); constelação não actualizada", + root, + ) try_send_community_ban_notice( username, diff --git a/scripts/admin/runv_jail.py b/scripts/admin/runv_jail.py @@ -244,3 +244,37 @@ def ensure_runv_jail_for_user( ) ensure_bind_mount(home, jail_home, log) append_fstab_bind(home, jail_home, log) + + +def teardown_runv_jail_for_user( + username: str, + home: Path, + log: logging.Logger, + *, + dry_run: bool = False, +) -> None: + """ + Inverte ``ensure_runv_jail_for_user``: umount do bind, remove linha fstab, gpasswd -d, + apaga ``/srv/jail/<user>``. Omitido para contas em ``JAIL_SKIP_USERNAMES``. + """ + if jail_skip_username(username): + log.info("jail teardown: omitido (conta excluída: %s)", username) + return + real_home = home.resolve() + jail_home = jail_bind_mountpoint(username) + jail_root = JAIL_ROOT / username + if dry_run: + log.info( + "jail teardown [dry-run]: umount %s, fstab, gpasswd -d, rmtree %s", + jail_home, + jail_root, + ) + return + unbind_jail_home(jail_home, log) + remove_fstab_bind(real_home, jail_home, log) + remove_user_from_jailed_group(username, log) + if jail_root.is_dir(): + shutil.rmtree(jail_root, ignore_errors=False) + log.info("jail: removido %s", jail_root) + elif jail_root.exists(): + log.warning("jail: %s existe mas não é directório; não removido automaticamente", jail_root) diff --git a/scripts/admin/runv_landing_sync.py b/scripts/admin/runv_landing_sync.py @@ -0,0 +1,76 @@ +""" +Sincronização da landing pública após alterações a ``users.json``. + +Invoca ``site/genlanding.py --sync-public-only`` (cópia de ``site/public/`` + +``data/members.json``). Partilhado por create_runv_user, update_user e del-user. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parent.parent + + +def try_sync_landing_via_genlanding( + *, + document_root: Path, + users_json: Path, + homes_root: Path | None, + log: logging.Logger, +) -> tuple[bool, int | None]: + """ + Copia site/public → DocumentRoot e regenera data/members.json (genlanding.py --sync-public-only). + Falhas são apenas registadas — não aborta o chamador. + Devolve (sucesso, número de membros no JSON público ou None se não foi possível contar). + """ + script = _REPO_ROOT / "site" / "genlanding.py" + if not script.is_file(): + log.warning( + "genlanding.py não encontrado em %s; landing não sincronizada", + script, + ) + return False, None + cmd = [ + sys.executable, + str(script), + "--sync-public-only", + "--document-root", + str(document_root), + "--members-users-json", + str(users_json), + ] + if homes_root is not None: + cmd.extend(["--members-homes-root", str(homes_root)]) + out = document_root / "data" / "members.json" + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + combined = ((r.stdout or "") + "\n" + (r.stderr or "")).strip() + if r.returncode != 0: + log.warning( + "genlanding --sync-public-only terminou com código %s: %s", + r.returncode, + combined[:2000] if combined else "(sem saída)", + ) + return False, None + log.info("landing sincronizada (site/public + members.json) em %s", document_root) + if combined: + log.debug("genlanding sync: %s", combined[:1500]) + n_public: int | None = None + try: + raw = out.read_text(encoding="utf-8") + parsed = json.loads(raw) + if isinstance(parsed, list): + n_public = len(parsed) + log.info("constelação: %s membro(s) no dataset público (%s)", n_public, out) + except (OSError, json.JSONDecodeError, TypeError) as ex: + log.warning("members.json após sync não foi possível validar: %s", ex) + return True, n_public + except (OSError, subprocess.TimeoutExpired) as e: + log.warning("falha ao executar genlanding --sync-public-only: %s", e) + return False, None diff --git a/scripts/admin/update_user.py b/scripts/admin/update_user.py @@ -7,7 +7,10 @@ Executar como root. Alinha-se a create_runv_user / del-user / runv_mount. Modo interativo no terminal (sem argumentos ou -i) ou flags CLI. -Versão 0.02 — runv.club +Após gravar ``users.json``, pode sincronizar a landing pública com +``site/genlanding.py --sync-public-only`` (como ``create_runv_user`` / ``del-user``). + +Versão 0.03 — runv.club """ from __future__ import annotations @@ -16,6 +19,7 @@ import argparse import fcntl import getpass import json +import logging import os import pwd import re @@ -32,6 +36,8 @@ _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from runv_landing_sync import try_sync_landing_via_genlanding + USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile( r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" @@ -55,7 +61,7 @@ DEFAULT_QUOTA_HARD_MIB: Final[int] = 500 DEFAULT_QUOTA_INODE_SOFT: Final[int] = 10_000 DEFAULT_QUOTA_INODE_HARD: Final[int] = 12_000 -VERSION: Final[str] = "0.02" +VERSION: Final[str] = "0.03" EXIT_OK: Final[int] = 0 EXIT_VALIDATION: Final[int] = 1 EXIT_SYSTEM: Final[int] = 2 @@ -63,6 +69,42 @@ EXIT_SYSTEM: Final[int] = 2 MIN_UID_NORMAL_USER: Final[int] = 1000 +def setup_update_user_log() -> logging.Logger: + log = logging.getLogger("runv.update_user") + log.setLevel(logging.INFO) + log.propagate = False + if not log.handlers: + h = logging.StreamHandler(sys.stderr) + h.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + log.addHandler(h) + return log + + +def maybe_sync_landing_after_metadata( + *, + skip_metadata: bool, + no_refresh_landing_members: bool, + landing_document_root: Path | None, + metadata_file: Path, + members_homes_root: Path | None, + dry_run: bool, + log: logging.Logger, +) -> None: + if dry_run or skip_metadata or no_refresh_landing_members or landing_document_root is None: + return + root = landing_document_root.resolve() + if not root.is_dir(): + log.warning("DocumentRoot da landing inexistente (%s); sync omitido", root) + return + log.info("sincronizar landing (public + members) em %s", root) + try_sync_landing_via_genlanding( + document_root=root, + users_json=metadata_file, + homes_root=members_homes_root.resolve() if members_homes_root else None, + log=log, + ) + + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -358,7 +400,7 @@ def update_metadata_after_key( fingerprint: str, *, dry_run: bool, -) -> None: +) -> bool: def m(data: list[dict[str, Any]]) -> bool: idx = find_metadata_index(data, username) if idx is None: @@ -369,6 +411,8 @@ def update_metadata_after_key( if mutate_metadata(metadata_path, lock_path, dry_run=dry_run, mutator=m): print(f" [ok] fingerprint em metadados: {fingerprint}") + return True + return False def update_metadata_after_quota( @@ -580,6 +624,27 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: ) p.add_argument("--metadata-file", type=Path, default=DEFAULT_METADATA_PATH) p.add_argument("--lock-file", type=Path, default=DEFAULT_LOCK_PATH) + p.add_argument( + "--landing-document-root", + type=Path, + default=Path("/var/www/runv.club/html"), + help=( + "DocumentRoot da landing; após gravar users.json, executa genlanding --sync-public-only " + "(omitido com --skip-metadata ou --no-refresh-landing-members)" + ), + ) + p.add_argument( + "--no-refresh-landing-members", + action="store_true", + help="não copiar site/public nem regenerar data/members.json após alterar metadados", + ) + p.add_argument( + "--members-homes-root", + type=Path, + default=None, + metavar="DIR", + help="opcional: --members-homes-root para genlanding (ex. /home)", + ) p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club") return p.parse_args(argv) @@ -595,6 +660,7 @@ def read_key_file(path: Path) -> str: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) dry_run = args.dry_run + log = setup_update_user_log() require_root(dry_run=dry_run) has_quota_flag = any( @@ -645,6 +711,15 @@ def main(argv: list[str] | None = None) -> int: dry_run=dry_run, skip_metadata=args.skip_metadata, ) + maybe_sync_landing_after_metadata( + skip_metadata=args.skip_metadata, + no_refresh_landing_members=args.no_refresh_landing_members, + landing_document_root=args.landing_document_root, + metadata_file=args.metadata_file, + members_homes_root=args.members_homes_root, + dry_run=dry_run, + log=log, + ) return EXIT_OK pk_replace: str | None = args.replace_public_key @@ -745,6 +820,16 @@ def main(argv: list[str] | None = None) -> int: eprint(str(e)) return EXIT_SYSTEM + maybe_sync_landing_after_metadata( + skip_metadata=args.skip_metadata, + no_refresh_landing_members=args.no_refresh_landing_members, + landing_document_root=args.landing_document_root, + metadata_file=args.metadata_file, + members_homes_root=args.members_homes_root, + dry_run=dry_run, + log=log, + ) + return EXIT_OK diff --git a/site/genlanding.py b/site/genlanding.py @@ -9,7 +9,7 @@ depois volte a correr este script para copiar. Executar como root (excepto --dry-run). Apenas biblioteca padrão Python 3. -Versão 0.04 — runv.club +Versão 0.05 — runv.club """ from __future__ import annotations @@ -26,7 +26,7 @@ import sys from pathlib import Path from typing import Final -VERSION: Final[str] = "0.04" +VERSION: Final[str] = "0.05" EXIT_OK: Final[int] = 0 EXIT_USAGE: Final[int] = 1 EXIT_ERROR: Final[int] = 2 @@ -261,6 +261,14 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: help="não desactiva 000-default.conf (produção e --dev: mantém página Debian; pedidos por IP não casam com ServerName)", ) p.add_argument( + "--sync-public-only", + action="store_true", + help=( + "só copia site/public → DocumentRoot, chown www-data e regenera data/members.json; " + "não configura Apache nem recarrega o serviço (uso típico: após create_runv_user.py)" + ), + ) + p.add_argument( "--no-refresh-members", action="store_true", help="não executar site/build_directory.py após copiar public/ (omitir data/members.json)", @@ -299,6 +307,40 @@ def resolve_profile(args: argparse.Namespace) -> tuple[str, Path, str, bool]: return domain, doc.resolve(), conf, disable_default +def sync_public_only_main(args: argparse.Namespace) -> int: + """Copia site/public → DocumentRoot, chown e members.json; sem Apache.""" + _, document_root, _, _ = resolve_profile(args) + source = args.source.resolve() + + print(f"== genlanding.py v{VERSION} — sync-public-only ==") + print(f" modo: {'dev' if args.dev else 'produção'}") + print(f" DocumentRoot: {document_root}") + print(f" origem: {source}") + print() + + try: + copy_landing(source, document_root, dry_run=args.dry_run) + if not args.dry_run: + chown_www_data(document_root, dry_run=False) + + if not args.no_refresh_members: + refresh_members_json_in_document_root( + document_root, + users_json=args.members_users_json, + homes_root=args.members_homes_root.resolve() + if args.members_homes_root + else None, + dry_run=args.dry_run, + ) + except (FileNotFoundError, OSError, RuntimeError) as e: + eprint(f"Erro: {e}") + return EXIT_ERROR + + print() + print(" [ok] sync-public-only concluído (Apache não foi alterado).") + return EXIT_OK + + def main(argv: list[str] | None = None) -> int: args = parse_args(argv) @@ -306,8 +348,15 @@ def main(argv: list[str] | None = None) -> int: eprint("Erro: --certbot não pode ser usado com --dev (Certbot não serve para domínios locais).") return EXIT_USAGE + if args.sync_public_only and args.certbot: + eprint("Erro: --certbot não pode ser usado com --sync-public-only.") + return EXIT_USAGE + require_root(dry_run=args.dry_run) + if args.sync_public_only: + return sync_public_only_main(args) + domain, document_root, site_conf_name, disable_default = resolve_profile(args) source = args.source.resolve() conf_path = APACHE_SITES_AVAILABLE / site_conf_name @@ -416,8 +465,8 @@ def main(argv: list[str] | None = None) -> int: print(" - Em /etc/hosts (cliente ou VM): 127.0.0.1 runv.local www.runv.local") print( " - Membros na constelação: regenerado com build_directory após esta cópia " - "(fonte: /var/lib/runv/users.json). Novas contas: create_runv_user.py também actualiza " - "members.json se o DocumentRoot existir. Use --no-refresh-members para omitir." + "(fonte: /var/lib/runv/users.json). Novas contas: create_runv_user.py corre " + "genlanding.py --sync-public-only (public + members). Use --no-refresh-members para omitir." ) return EXIT_OK diff --git a/tests/test_patch_irc.py b/tests/test_patch_irc.py @@ -0,0 +1,160 @@ +""" +Testes unitários para patches/patch_irc.py (parsing, idempotência, autoconnect). +Executar na raiz do repo: python3 -m unittest tests.test_patch_irc -v + +No Windows o módulo alvo não carrega (falta ``pwd``); os testes são ignorados. +""" + +from __future__ import annotations + +import importlib.util +import logging +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def _load_patch_irc(): + path = ROOT / "patches" / "patch_irc.py" + spec = importlib.util.spec_from_file_location("patch_irc_test_mod", path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class _PatchIrcTestBase(unittest.TestCase): + p = None + + @classmethod + def setUpClass(cls) -> None: + if sys.platform.startswith("win"): + raise unittest.SkipTest("patch_irc e estes testes requerem Unix (pwd)") + cls.p = _load_patch_irc() + + +def _runv_section(p, username: str) -> str: + nicks = p.expected_nicks(username) + return f"""[server] +runv.addresses = "irc.portalidea.com.br/6697" +runv.tls = on +runv.nicks = "{nicks}" +runv.username = "{username}" +runv.realname = "{username}" +runv.autoconnect = on +runv.autojoin = "#runv" +""" + + +class TestParseServers(_PatchIrcTestBase): + def test_parse_all_server_names(self) -> None: + p = self.p + text = """ +[server] +runv.addresses = "x" +libera.addresses = "y" +runv.tls = on +""" + names = p.parse_all_server_names(text) + self.assertEqual(names, {"runv", "libera"}) + + def test_parse_server_options(self) -> None: + p = self.p + text = _runv_section(p, "alice") + o = p.parse_server_options(text, "runv") + self.assertEqual(o.get("addresses"), "irc.portalidea.com.br/6697") + self.assertTrue(p.tls_effective(o)) + self.assertEqual(o.get("autojoin"), "#runv") + + +class TestCompliance(_PatchIrcTestBase): + def setUp(self) -> None: + self.log = logging.getLogger("t") + self.log.disabled = True + + def test_fully_compliant_noop(self) -> None: + p = self.p + body = _runv_section(p, "bob") + with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False, encoding="utf-8") as f: + f.write(body) + path = Path(f.name) + try: + self.assertTrue( + p.config_matches( + path, + server="runv", + host="irc.portalidea.com.br", + port=6697, + tls=True, + unix_username="bob", + autojoin="#runv", + log=self.log, + ) + ) + finally: + path.unlink(missing_ok=True) + + def test_other_autoconnect_breaks_compliance(self) -> None: + p = self.p + body = _runv_section(p, "bob") + """ +libera.addresses = "irc.libera.chat/6697" +libera.tls = on +libera.autoconnect = on +""" + with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False, encoding="utf-8") as f: + f.write(body) + path = Path(f.name) + try: + self.assertFalse( + p.config_matches( + path, + server="runv", + host="irc.portalidea.com.br", + port=6697, + tls=True, + unix_username="bob", + autojoin="#runv", + log=self.log, + ) + ) + finally: + path.unlink(missing_ok=True) + + def test_disable_other_chain(self) -> None: + p = self.p + body = _runv_section(p, "bob") + """ +libera.addresses = "irc.libera.chat/6697" +libera.autoconnect = on +""" + chain = p.build_disable_other_autoconnect_chain(body, "runv") + self.assertIn("/set irc.server.libera.autoconnect off", chain) + + def test_disable_chain_empty_when_all_off(self) -> None: + p = self.p + body = _runv_section(p, "bob") + """ +libera.addresses = "irc.libera.chat/6697" +libera.autoconnect = off +""" + chain = p.build_disable_other_autoconnect_chain(body, "runv") + self.assertEqual(chain, "") + + def test_server_add_has_tls_not_autoconnect_flag(self) -> None: + p = self.p + chain = p.build_apply_command_chain( + server="runv", + host="irc.portalidea.com.br", + port=6697, + tls=True, + unix_username="u", + autojoin="#runv", + ) + self.assertIn("/server add runv irc.portalidea.com.br/6697 -tls", chain) + first = chain.split(" ; ")[0] + self.assertNotIn("-autoconnect", first) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/bin/chat b/tools/bin/chat @@ -1,5 +1,6 @@ #!/bin/sh -# runv.club — cliente IRC interactivo; config em ~/.config/weechat (servidor «runv» após patches/patch_irc.py). +# runv.club — cliente IRC interactivo; config em ~/.config/weechat. +# Por omissão (após patches/patch_irc.py): servidor interno «runv» → irc.portalidea.com.br:6697 (TLS), canal #runv. # Utilizadores: use só o comando «chat»; não é preciso memorizar outros nomes de binário. IRC_UI="" @@ -22,7 +23,7 @@ if [ ! -f "$CONFIG_DIR/irc.conf" ]; then 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 patches/patch_irc.py (ex.: irc.portalidea.com.br)." >&2 + echo "runv: o admin pode aplicar patches/patch_irc.py (TLS, #runv em irc.portalidea.com.br)." >&2 fi exec "$IRC_UI" -d "$CONFIG_DIR" "$@" diff --git a/tools/motd/60-runv b/tools/motd/60-runv @@ -62,7 +62,7 @@ print_last_sessions_3x3() { return fi - printf '\n%bÚltimos usuários online%b %b(até 9 nomes únicos, por atividade recente)%b\n' "${B}" "${R}" "${D}" "${R}" + printf '\n%bÚltimos usuários online%b\n' "${B}" "${R}" row=1 while [ "$row" -le 3 ]; do read -r c1 || c1=''