runv-server

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

commit bcfb4cf7ef7047901a858670eb10de83755855db
parent 5f26e521c93f0a7514d0a981ab9a649eff0c63dc
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sun, 22 Mar 2026 19:27:13 -0300

documentação: still-1-1-3

Diffstat:
Mdocs/06-site-and-apache.md | 3++-
Mdocs/11-daily-operations.md | 4+++-
Mdocs/13-troubleshooting.md | 4++++
Mscripts/admin/runv_landing_sync.py | 38+++++++++++++++++++++++++++-----------
Mscripts/admin/setup_alt_protocols.py | 62++++++++++++++++++++++++++++++++++++++------------------------
Msite/genlanding.py | 16++++++++++++----
Msite/news/publish_news.py | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Dtests/test_patch_irc.py | 160-------------------------------------------------------------------------------
8 files changed, 189 insertions(+), 202 deletions(-)

diff --git a/docs/06-site-and-apache.md b/docs/06-site-and-apache.md @@ -15,7 +15,8 @@ - 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). - **`--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`). +- O VirtualHost HTTP gerado inclui **`ForceType text/xml`** para **`/news/feed.rss`**, para o browser mostrar o XML do feed em vez de forçar descarga (Chromium com outros MIME). Se o **Certbot** criou um VirtualHost **HTTPS** separado, esse ficheiro não é regenerado pelo `genlanding` — pode ser necessário acrescentar o mesmo bloco `<Directory …/news>` com `<Files "feed.rss">` nesse vhost ou voltar a alinhar a configuração manualmente. +- Versão actual do script: constante `VERSION` no ficheiro (ex.: `0.06`). ## TLS e DNS diff --git a/docs/11-daily-operations.md b/docs/11-daily-operations.md @@ -34,7 +34,9 @@ sudo python3 REPO/site/genlanding.py --sync-public-only \ ## Notícias -- Colocar `.md` em `site/news/`, executar `site/news/publish_news.py`; depois voltar a copiar `public/` ou correr `genlanding.py` se aplicável. +- Colocar `.md` em `site/news/`, executar **`sudo python3 REPO/site/news/publish_news.py`** em produção. O script grava `site/public/news/` (JSON, RSS, sitemap) e, se **`/var/www/runv.club/html`** existir, invoca **`site/genlanding.py --sync-public-only`** no fim (copia `site/public/` para o DocumentRoot + `data/members.json`). Se o DocumentRoot não existir no ambiente (ex.: só clone local), aparece um AVISO com o comando manual — a publicação em `site/public/` fica feita. +- **`--skip-genlanding`:** só gera ficheiros em `site/public/` sem copiar para Apache. +- Ajustar paths com `--landing-document-root`, `--members-users-json` e opcionalmente `--members-homes-root` se a tua instalação divergir dos defaults. ## Wiki diff --git a/docs/13-troubleshooting.md b/docs/13-troubleshooting.md @@ -22,6 +22,10 @@ - `apache2ctl configtest` após alterações de vhost. - `genlanding.py` imprime erros se `build_directory` falhar. +## Feed RSS descarrega em vez de abrir no browser + +- O vhost HTTP gerado pelo `genlanding` força `text/xml` em `/news/feed.rss`. Se só o vhost **HTTPS** (ex.: Certbot) servir o site, confira se esse ficheiro inclui o mesmo bloco ou volte a correr `genlanding` e funda alterações com o SSL existente (ver [06-site-and-apache.md](06-site-and-apache.md)). + ## Quotas - FS não ext4 → automatização de `starthere.py` pode recusar; configurar manualmente ou usar volume ext4. diff --git a/scripts/admin/runv_landing_sync.py b/scripts/admin/runv_landing_sync.py @@ -17,6 +17,28 @@ _SCRIPT_DIR = Path(__file__).resolve().parent _REPO_ROOT = _SCRIPT_DIR.parent.parent +def genlanding_sync_command( + *, + document_root: Path, + users_json: Path, + homes_root: Path | None = None, +) -> list[str]: + """Comando completo para ``site/genlanding.py --sync-public-only`` (lista para subprocess).""" + script = _REPO_ROOT / "site" / "genlanding.py" + cmd: list[str] = [ + 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)]) + return cmd + + def try_sync_landing_via_genlanding( *, document_root: Path, @@ -36,17 +58,11 @@ def try_sync_landing_via_genlanding( 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)]) + cmd = genlanding_sync_command( + document_root=document_root, + users_json=users_json, + homes_root=homes_root, + ) out = document_root / "data" / "members.json" try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -373,47 +373,61 @@ def wait_for_unit_active( def ensure_le_tls_readable_for_molly( cert_path: Path, + key_path: Path, *, dry_run: bool, log: logging.Logger, ) -> None: """ - Ajusta /etc/letsencrypt/live e archive (e o directório do certificado) para 755, e + Ajusta /etc/letsencrypt/live, archive, live/<domínio>, archive/<domínio> para 755 e archive/<domínio>/privkey*.pem para grupo ssl-cert + 640, para o molly-brown ler a chave. - Só actua se cert_path estiver sob .../live/<domínio>/ (Let's Encrypt típico). - Usa raízes resolvidas para não saltar quando /etc/letsencrypt/live é symlink. + + Usa caminhos lógicos (sem resolver fullchain.pem → archive), porque o symlink típico do + Let's Encrypt fazia falhar a detecção quando se aplicava resolve() ao certificado. """ + cert_p = Path(cert_path) + key_p = Path(key_path) + try: - cert_resolved = _path_resolved(cert_path) - except OSError as e: - log.debug("LE TLS: resolve %s: %s — salto", cert_path, e) + cert_rel = cert_p.relative_to(LETSENCRYPT_LIVE) + except ValueError: + log.debug( + "LE TLS: cert_path não está sob %s (%s) — salto", + LETSENCRYPT_LIVE, + cert_p, + ) return - live_root = _path_resolved(LETSENCRYPT_LIVE) - archive_root = _path_resolved(LETSENCRYPT_ARCHIVE) + cparts = cert_rel.parts + if len(cparts) < 2: + log.debug( + "LE TLS: esperado %s/<domínio>/<ficheiro> — salto (%s)", + LETSENCRYPT_LIVE, + cert_p, + ) + return + domain = cparts[0] try: - cert_resolved.relative_to(live_root) + key_rel = key_p.relative_to(LETSENCRYPT_LIVE) except ValueError: log.debug( - "LE TLS: cert não está sob a árvore LE resolvida (%s) — salto (%s)", - live_root, - cert_resolved, + "LE TLS: key_path não está sob %s (%s) — salto", + LETSENCRYPT_LIVE, + key_p, ) return - - live_domain_dir = cert_resolved.parent - parent_resolved = _path_resolved(live_domain_dir.parent) - if parent_resolved != live_root: + if not key_rel.parts or key_rel.parts[0] != domain: log.debug( - "LE TLS: esperado .../live/<domínio>/fullchain.pem — salto (pai=%s live_root=%s)", - parent_resolved, - live_root, + "LE TLS: key_path não está sob %s/%s/ — salto (%s)", + LETSENCRYPT_LIVE, + domain, + key_p, ) return - domain = live_domain_dir.name - archive_domain_dir = archive_root / domain + live_domain_dir = LETSENCRYPT_LIVE / domain + archive_domain_dir = LETSENCRYPT_ARCHIVE / domain try: ssl_gid = grp.getgrnam(SSL_CERT_GROUP).gr_gid @@ -425,8 +439,8 @@ def ensure_le_tls_readable_for_molly( ssl_gid = None dirs_755: list[Path] = [ - live_root, - archive_root, + LETSENCRYPT_LIVE, + LETSENCRYPT_ARCHIVE, live_domain_dir, ] if archive_domain_dir.is_dir(): @@ -1156,7 +1170,7 @@ def main(argv: list[str] | None = None) -> int: return 1 if not args.skip_gemini: - ensure_le_tls_readable_for_molly(cert, dry_run=args.dry_run, log=log) + ensure_le_tls_readable_for_molly(cert, key, dry_run=args.dry_run, log=log) pkgs: list[str] = [] if not args.skip_install: diff --git a/site/genlanding.py b/site/genlanding.py @@ -4,12 +4,13 @@ Configura o Apache (Debian) para servir a landing runv.club: VirtualHost, mod_userdir + mod_rewrite, cópia de site/public para DocumentRoot, redirect www → apex em HTTP. Produção ou modo --dev para testes locais. Metadados SEO: editar site/public/. FAQ estático: public/faq/ (copiado com o resto). -Notícias: site/news/publish_news.py gera public/news/data/news.json e feed.rss — -depois volte a correr este script para copiar. +Notícias: site/news/publish_news.py gera public/news/data/news.json e feed.rss e, em produção, +tenta ``genlanding --sync-public-only`` no fim (DocumentRoot existente). O VirtualHost abaixo +força ``text/xml`` em ``/news/feed.rss`` para o browser mostrar o feed em vez de descarregar. Executar como root (excepto --dry-run). Apenas biblioteca padrão Python 3. -Versão 0.05 — runv.club +Versão 0.06 — runv.club """ from __future__ import annotations @@ -26,7 +27,7 @@ import sys from pathlib import Path from typing import Final -VERSION: Final[str] = "0.05" +VERSION: Final[str] = "0.06" EXIT_OK: Final[int] = 0 EXIT_USAGE: Final[int] = 1 EXIT_ERROR: Final[int] = 2 @@ -95,6 +96,13 @@ def render_vhost( Require all granted </Directory> + # Chromium descarrega feed.rss com alguns MIME; text/xml mostra o XML na aba. + <Directory {document_root}/news> + <Files "feed.rss"> + ForceType text/xml + </Files> + </Directory> + ErrorLog ${{APACHE_LOG_DIR}}/{log_tag}-error.log CustomLog ${{APACHE_LOG_DIR}}/{log_tag}-access.log combined </VirtualHost> diff --git a/site/news/publish_news.py b/site/news/publish_news.py @@ -14,8 +14,12 @@ Os ``.md`` processados são **apagados**. Ficheiros cujo nome começa por ``_`` Não versionar notícias no HTML: os dados ficam em ``news.json`` (tipicamente ignorado pelo git no servidor após gerar conteúdo local). +Após publicar (sem ``--dry-run``), tenta ``site/genlanding.py --sync-public-only`` quando o +DocumentRoot da landing existir (por omissão ``/var/www/runv.club/html``), para copiar +``site/public/`` para o Apache. Em produção use ``sudo``. ``--skip-genlanding`` omite esse passo. + Uso:: - python3 site/news/publish_news.py [--dry-run] [--verbose] + sudo python3 site/news/publish_news.py [--dry-run] [--verbose] [--skip-genlanding] """ from __future__ import annotations @@ -24,6 +28,7 @@ import argparse import html import json import re +import subprocess import sys import uuid from xml.sax.saxutils import escape as xml_escape @@ -34,6 +39,8 @@ from zoneinfo import ZoneInfo SCRIPT_DIR = Path(__file__).resolve().parent REPO_SITE = SCRIPT_DIR.parent +_REPO_ROOT = REPO_SITE.parent + PUBLIC_NEWS = REPO_SITE / "public" / "news" DATA_DIR = PUBLIC_NEWS / "data" JSON_PATH = DATA_DIR / "news.json" @@ -44,6 +51,64 @@ TZ_BR: Final[str] = "America/Sao_Paulo" # Brasil sem DST: fallback se ``tzdata`` não estiver instalado (ex.: Windows minimal). BR_FALLBACK_TZ = timezone(timedelta(hours=-3)) SITE_URL: Final[str] = "https://runv.club" +DEFAULT_LANDING_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv.club/html") +DEFAULT_MEMBERS_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") + + +def sync_landing_after_news( + *, + document_root: Path, + members_users_json: Path, + members_homes_root: Path | None, + verbose: bool, +) -> int: + """ + Copia site/public → DocumentRoot via genlanding --sync-public-only. + Devolve 0 se omitido (sem script / sem DocumentRoot) ou sync OK; 1 se genlanding falhou. + """ + gl = _REPO_ROOT / "site" / "genlanding.py" + if not gl.is_file(): + print( + f"AVISO: genlanding.py não encontrado em {gl}; não sincronizou DocumentRoot.", + file=sys.stderr, + ) + return 0 + root = document_root.resolve() + if not root.is_dir(): + homes_opt = "" + if members_homes_root is not None: + homes_opt = f" --members-homes-root {members_homes_root.resolve()}" + print( + f"AVISO: DocumentRoot da landing inexistente ({root}) — site/public não foi copiado para Apache.\n" + f"Manual: sudo python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only " + f"--document-root {root} --members-users-json {members_users_json}{homes_opt}", + file=sys.stderr, + ) + return 0 + admin = _REPO_ROOT / "scripts" / "admin" + if str(admin) not in sys.path: + sys.path.insert(0, str(admin)) + from runv_landing_sync import genlanding_sync_command + + cmd = genlanding_sync_command( + document_root=root, + users_json=members_users_json.resolve(), + homes_root=members_homes_root.resolve() if members_homes_root else None, + ) + if verbose: + print(f" $ {' '.join(cmd)}") + r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if r.returncode == 0: + print(f"Landing sincronizada (public + members) → {root}") + return 0 + combined = ((r.stdout or "") + "\n" + (r.stderr or "")).strip() + print( + f"Erro: genlanding --sync-public-only terminou com código {r.returncode}.", + file=sys.stderr, + ) + if combined: + print(combined[:4000], file=sys.stderr) + return 1 def _apply_underline(s: str) -> str: @@ -230,6 +295,32 @@ def main() -> int: ap = argparse.ArgumentParser(description="Publica notícias a partir de .md em site/news/") ap.add_argument("--dry-run", action="store_true", help="Só mostra o que faria") ap.add_argument("--verbose", "-v", action="store_true") + ap.add_argument( + "--landing-document-root", + type=Path, + default=DEFAULT_LANDING_DOCUMENT_ROOT, + help=( + "DocumentRoot Apache; se existir como directório e não usar --skip-genlanding, " + "corre site/genlanding.py --sync-public-only após publicar" + ), + ) + ap.add_argument( + "--members-users-json", + type=Path, + default=DEFAULT_MEMBERS_USERS_JSON, + help="Fonte para data/members.json no genlanding (default: /var/lib/runv/users.json)", + ) + ap.add_argument( + "--members-homes-root", + type=Path, + default=None, + help="Opcional: --members-homes-root para genlanding (ex. /home)", + ) + ap.add_argument( + "--skip-genlanding", + action="store_true", + help="Não copiar site/public para DocumentRoot após publicar", + ) args = ap.parse_args() try: @@ -285,6 +376,17 @@ def main() -> int: print(f" removido {path.name}") print(f"Publicadas {len(new_entries)} notícia(s). Total: {len(articles)}.") + + if not args.skip_genlanding: + rc = sync_landing_after_news( + document_root=args.landing_document_root, + members_users_json=args.members_users_json, + members_homes_root=args.members_homes_root, + verbose=args.verbose, + ) + if rc != 0: + return rc + return 0 diff --git a/tests/test_patch_irc.py b/tests/test_patch_irc.py @@ -1,160 +0,0 @@ -""" -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()