commit d6ab5efcf02d0de615a856434cbe7724a079caf7
parent 0db766cd18c714fc020048f6a15c22298de47b77
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sun, 22 Mar 2026 18:46:00 -0300
documentação: still-1
Diffstat:
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=''