runv-server

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

commit 3f22625820e04dd53cbf188fdfa0a98936bed2ad
parent 3dabcfef33203c0e0b2f7836e6e18d9469025025
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Tue, 19 May 2026 20:55:18 -0300

better mail

Diffstat:
Mdocs/08-email.md | 49+++++++++++++++++++++++++++++++++++++++++++------
Mdocs/17-community-commands.md | 7+++++++
Aemail/config/runv-member-mail.example.json | 12++++++++++++
Ascripts/admin/discover_mail_stack.py | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/sync_member_email_aliases.py | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/bin/runv-admin-email-alias | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Atools/lib/runv_mail_sync.py | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 523 insertions(+), 13 deletions(-)

diff --git a/docs/08-email.md b/docs/08-email.md @@ -23,26 +23,42 @@ ## O que o repo não é -- **Não** é MTA completo (não recebe correio para caixas locais de membros como produto deste repositório). +- **Não** substitui a instalação Postfix/Dovecot/Roundcube no servidor (isso é operação de sistema). +- **Não** usa Mailgun para correio de membros. + +## Dois canais de email (não misturar) + +| Canal | Função | Config no servidor | +|-------|--------|-------------------| +| **Mailgun** | Transacional / admin (`entre`, boas-vindas, avisos) | `/etc/runv-email.json` | +| **MTA local** | Correio `@runv.club` para membros (caixa, webmail, encaminhamento) | Postfix + Dovecot + Roundcube (fora deste repo) | + +O Mailgun **não** deve receber pedidos de alias `username@runv.club → Gmail`. Isso é papel do **Postfix** (ou mapa virtual equivalente) já instalado na VPS. ## Aliases de email para membros -O email transacional da runv.club (Mailgun, `/etc/runv-email.json`) continua separado deste fluxo. +O fluxo runv regista pedidos e aprovações em JSON. O encaminhamento real pode ser aplicado no Postfix com `runv-admin-email-alias sync` quando `/etc/runv-member-mail.json` estiver activo (ver abaixo). -Nesta etapa **não** há mailbox local nem caixa `@runv.club` no servidor. Um membro pode pedir um alias fixo: +Um membro pode pedir um alias fixo: `username@runv.club` → email externo de destino -O alias **não** é activado automaticamente: o membro pede no terminal, um admin aprova, e o registo fica em JSON local. Criar o encaminhamento real no provedor de email (Mailgun, DNS, etc.) continua a ser passo manual ou integração futura. +Por omissão o membro pede no terminal, o admin aprova, e o registo fica em JSON. Com sync Postfix configurado, o encaminhamento no MTA local pode ser automático após `approve` ou via `sync`. ### Membro +**Sem `sudo` e sem root.** O membro corre os comandos na própria sessão SSH (conta Unix da comunidade, ex. `pmurad`). O sistema usa o username dessa sessão; contas de operador/admin (ex. `pmurad-admin`) **não** estão em `runv-members` e não podem pedir alias por design. + ```bash runv-email-alias request usuario@example.org runv-email-alias status runv-email-alias cancel ``` +- `status` lê `/var/lib/runv/email-aliases.json` (modo `640`, grupo `runv-members`). +- `request` / `cancel` escrevem só na fila `email-alias-queue/` (modo `2770`, mesmo grupo). +- Aprovação e alteração do JSON activo são sempre **admin** (`runv-admin-email-alias` como root). + O alias é sempre `username@runv.club` (username Unix do utilizador que corre o comando). Não é possível escolher outro nome de alias. ### Admin @@ -51,9 +67,30 @@ O alias é sempre `username@runv.club` (username Unix do utilizador que corre o sudo runv-admin-email-alias pending sudo runv-admin-email-alias list sudo runv-admin-email-alias approve pablo +sudo runv-admin-email-alias approve pablo --sync-mail +sudo runv-admin-email-alias sync sudo runv-admin-email-alias reject pablo --reason "email destino inválido" ``` +### Inventário e sync Postfix (MTA local) + +Na VPS, antes de activar sync: + +```bash +sudo python3 scripts/admin/discover_mail_stack.py +``` + +Copiar e editar o exemplo: + +```bash +sudo cp email/config/runv-member-mail.example.json /etc/runv-member-mail.json +# enabled: true após validar postconf virtual_alias_maps +sudo python3 scripts/admin/sync_member_email_aliases.py --dry-run +sudo runv-admin-email-alias sync +``` + +O sync gera `hash:/etc/postfix/runv-member-aliases` a partir de `/var/lib/runv/email-aliases.json`, corre `postmap` e `systemctl reload postfix`. **Não** altera Mailgun. + ### Setup inicial no servidor ```bash @@ -93,11 +130,11 @@ Mais detalhe dos comandos: [17-community-commands.md](17-community-commands.md). - Não configura SPF/DKIM/DMARC. - Não configura Postfix/Dovecot. - Não configura Mailgun para aliases de membros. -- Não activa encaminhamento real automaticamente. +- Não configura Roundcube/Dovecot directamente (só mapa virtual Postfix quando sync activo). ### Próximo passo futuro -Um script como `runv-email-provider-sync` poderá ler `email-aliases.json` e aplicar aliases no provedor real. +Suporte a backends além de `postfix-hash` (ex. SQL já usado pelo servidor) após mapear o que `discover_mail_stack.py` reportar. ## Testes diff --git a/docs/17-community-commands.md b/docs/17-community-commands.md @@ -249,12 +249,16 @@ Permite pedir um alias de email fixo `username@runv.club` que, após aprovação ### Exemplos +Na sessão SSH do **membro** (sem `sudo`): + ```bash runv-email-alias request usuario@example.org runv-email-alias status runv-email-alias cancel ``` +Requisito: o utilizador tem de pertencer ao grupo `runv-members` (contas criadas com `create_runv_user.py`). Contas só de administração do servidor não usam este comando. + Política, ficheiros em `/var/lib/runv/` e setup: [08-email.md](08-email.md). --- @@ -271,9 +275,12 @@ Comando **root** para listar pedidos, aprovar ou rejeitar aliases, e actualizar sudo runv-admin-email-alias pending sudo runv-admin-email-alias list sudo runv-admin-email-alias approve pablo +sudo runv-admin-email-alias sync sudo runv-admin-email-alias reject pablo --reason "email destino inválido" ``` +Sync Postfix (membros, não Mailgun): [08-email.md](08-email.md) e `scripts/admin/discover_mail_stack.py`. + Setup inicial da fila e permissões: ```bash diff --git a/email/config/runv-member-mail.example.json b/email/config/runv-member-mail.example.json @@ -0,0 +1,12 @@ +{ + "enabled": false, + "backend": "postfix-hash", + "virtual_alias_file": "/etc/postfix/runv-member-aliases", + "file_mode": "0o644", + "check_maps": true, + "run_postmap": true, + "postmap_command": ["postmap", "/etc/postfix/runv-member-aliases"], + "reload_postfix": true, + "reload_command": ["systemctl", "reload", "postfix"], + "auto_sync_on_approve": false +} diff --git a/scripts/admin/discover_mail_stack.py b/scripts/admin/discover_mail_stack.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Inventário read-only do stack de email no servidor (Postfix, Dovecot, Roundcube, vmail). + +Não altera configuração. Use na VPS antes de activar sync_member_email_aliases. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def run(cmd: list[str], *, timeout: int = 30) -> tuple[int, str]: + if shutil.which(cmd[0]) is None: + return 127, f"(comando ausente: {cmd[0]})" + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + out = (proc.stdout or "") + (proc.stderr or "") + return proc.returncode, out.strip() + except subprocess.TimeoutExpired: + return 124, "(timeout)" + except OSError as e: + return 1, str(e) + + +def section(title: str) -> None: + print(f"\n=== {title} ===") + + +def main() -> int: + p = argparse.ArgumentParser(description="Inventário do stack de email (read-only)") + p.parse_args() + + if sys.platform == "win32": + print("Execute este script na VPS Linux (Debian).", file=sys.stderr) + return 2 + + section("Pacotes Debian (dpkg)") + code, out = run( + [ + "dpkg-query", + "-W", + "-f=${Package}\t${Status}\n", + "postfix", + "dovecot-core", + "dovecot-imapd", + "roundcube-core", + "roundcube", + "postfix-mysql", + "postfix-ldap", + ], + ) + if code == 127: + code, out = run(["dpkg", "-l"]) + if code == 0: + for line in out.splitlines(): + low = line.lower() + if any( + k in low + for k in ( + "postfix", + "dovecot", + "roundcube", + "rspamd", + "clamav", + "spamassassin", + ) + ): + print(line) + else: + print(out) + else: + print(out or "(nenhum pacote listado com esses nomes exactos)") + + section("Serviços (systemctl is-active)") + for unit in ( + "postfix", + "dovecot", + "apache2", + "nginx", + "php8.2-fpm", + "php8.3-fpm", + ): + code, out = run(["systemctl", "is-active", unit]) + if code != 127: + print(f"{unit}: {out}") + + section("Postfix (postconf)") + for key in ( + "myhostname", + "mydomain", + "virtual_mailbox_domains", + "virtual_mailbox_maps", + "virtual_alias_maps", + "relay_domains", + "transport_maps", + ): + code, out = run(["postconf", "-h", key]) + if code == 127: + print("postconf não instalado") + break + print(f"{key} = {out}") + + section("Ficheiros comuns") + paths = [ + "/etc/postfix/main.cf", + "/etc/postfix/virtual", + "/etc/postfix/virtual.db", + "/etc/postfix/mysql-virtual-alias-maps.cf", + "/etc/dovecot/dovecot.conf", + "/var/vmail", + "/etc/roundcube", + "/usr/share/roundcube", + "/var/www/roundcube", + "/etc/runv-email.json", + "/etc/runv-member-mail.json", + "/var/lib/runv/email-aliases.json", + ] + for path in paths: + pth = Path(path) + if pth.is_dir(): + print(f"{path}/ [dir]") + elif pth.is_file(): + print(f"{path} [file]") + else: + print(f"{path} (ausente)") + + section("RunV aliases de membros") + aliases = Path("/var/lib/runv/email-aliases.json") + if aliases.is_file(): + print(f"{aliases} existe ({aliases.stat().st_size} bytes)") + else: + print(f"{aliases} ausente") + + cfg = Path("/etc/runv-member-mail.json") + if cfg.is_file(): + print(f"sync MTA configurado: {cfg}") + else: + print( + f"sync MTA não configurado ({cfg} ausente). " + "Ver email/config/runv-member-mail.example.json" + ) + + section("Mailgun transacional (só leitura de paths)") + for path in ("/etc/runv-email.json", "/etc/runv-email.secrets.json"): + pth = Path(path) + print(f"{path}: {'presente' if pth.is_file() else 'ausente'}") + + print( + "\nPróximo passo: alinhar virtual_alias_maps com hash:/etc/postfix/runv-member-aliases " + "e activar /etc/runv-member-mail.json; depois: runv-admin-email-alias sync" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/sync_member_email_aliases.py b/scripts/admin/sync_member_email_aliases.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Aplica email-aliases.json no Postfix local (hash). Requer /etc/runv-member-mail.json.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_TOOLS_LIB = _SCRIPT_DIR.parent.parent / "tools" / "lib" +if str(_REPO_TOOLS_LIB) not in sys.path: + sys.path.insert(0, str(_REPO_TOOLS_LIB)) + +import runv_mail_sync as ms # noqa: E402 + + +def require_root(dry_run: bool) -> None: + if dry_run: + return + geteuid = getattr(os, "geteuid", None) + if geteuid is None or geteuid() != 0: + print("este script precisa ser executado como root (ou use --dry-run).", file=sys.stderr) + raise SystemExit(1) + + +def main() -> int: + p = argparse.ArgumentParser( + description="Sincroniza aliases aprovados com Postfix (MTA local, não Mailgun)." + ) + p.add_argument("--dry-run", action="store_true", help="simular escrita e comandos") + p.add_argument( + "--config", + default="", + help=f"ficheiro JSON (predefinição: {ms.DEFAULT_CONFIG_PATH})", + ) + args = p.parse_args() + require_root(args.dry_run) + + cfg = None + if args.config.strip(): + cfg = ms.load_config(Path(args.config.strip())) + + return ms.sync_postfix_hash(dry_run=args.dry_run, cfg=cfg) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-admin-email-alias b/tools/bin/runv-admin-email-alias @@ -25,6 +25,11 @@ def _bootstrap() -> None: _bootstrap() import runv_email_aliases as ea # noqa: E402 +try: + import runv_mail_sync as mail_sync # noqa: E402 +except ImportError: + mail_sync = None # type: ignore[assignment] + def cmd_pending() -> int: pending = ea.iter_pending_requests() @@ -53,7 +58,7 @@ def cmd_list() -> int: return 0 -def cmd_approve(username: str) -> int: +def cmd_approve(username: str, *, sync_mail: bool) -> int: user = ea.validate_alias_username(username) operator = ea.admin_operator() entry = ea.approve_pending(user, operator) @@ -62,11 +67,41 @@ def cmd_approve(username: str) -> int: print(f" {entry.get('alias')}\n") print("Destino:") print(f" {entry.get('destination')}\n") - print("Próximo passo manual:") - print( - " criar/atualizar o encaminhamento real no provedor de email:\n" - f" {entry.get('alias')} -> {entry.get('destination')}" - ) + if sync_mail and mail_sync is not None: + try: + mail_sync.sync_postfix_hash() + print("Encaminhamento aplicado no Postfix (sync OK).\n") + except SystemExit: + print( + "Aviso: sync Postfix falhou; registo JSON está activo.\n" + " Corrija /etc/runv-member-mail.json ou execute:\n" + " sudo runv-admin-email-alias sync\n", + file=sys.stderr, + ) + elif mail_sync is not None and mail_sync.is_sync_enabled(): + print( + "Próximo passo MTA:\n" + " sudo runv-admin-email-alias sync\n" + " (ou active auto_sync_on_approve em /etc/runv-member-mail.json)\n" + ) + else: + print("Próximo passo manual (MTA):") + print( + " configurar encaminhamento no Postfix/Dovecot ou\n" + " copiar email/config/runv-member-mail.example.json para\n" + " /etc/runv-member-mail.json e executar: runv-admin-email-alias sync\n" + ) + print( + f" destino: {entry.get('alias')} -> {entry.get('destination')}\n" + ) + return 0 + + +def cmd_sync() -> int: + if mail_sync is None: + ea.rc.friendly_exit("módulo runv_mail_sync não encontrado.") + mail_sync.sync_postfix_hash() + print("Sync Postfix concluído.") return 0 @@ -94,6 +129,20 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("list", help="listar aliases activos") appr = sub.add_parser("approve", help="aprovar pedido pendente mais recente") appr.add_argument("user", metavar="USER", help="nome de utilizador Unix") + appr.add_argument( + "--sync-mail", + action="store_true", + help="aplicar imediatamente no Postfix (requer /etc/runv-member-mail.json enabled)", + ) + appr.add_argument( + "--no-sync-mail", + action="store_true", + help="não tentar sync Postfix mesmo com auto_sync_on_approve", + ) + sub.add_parser( + "sync", + help="sincronizar aliases activos com Postfix (hash runv-member-aliases)", + ) rej = sub.add_parser("reject", help="rejeitar pedido pendente") rej.add_argument("user", metavar="USER", help="nome de utilizador Unix") rej.add_argument( @@ -113,7 +162,18 @@ def main(argv: list[str] | None = None) -> int: if args.command == "list": return cmd_list() if args.command == "approve": - return cmd_approve(args.user) + do_sync = bool(args.sync_mail) + if ( + not args.no_sync_mail + and not do_sync + and mail_sync is not None + and mail_sync.is_sync_enabled() + ): + cfg = mail_sync.load_config() + do_sync = bool(cfg.get("auto_sync_on_approve")) + return cmd_approve(args.user, sync_mail=do_sync) + if args.command == "sync": + return cmd_sync() if args.command == "reject": return cmd_reject(args.user, args.reason) return 1 diff --git a/tools/lib/runv_mail_sync.py b/tools/lib/runv_mail_sync.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Sincroniza aliases aprovados (email-aliases.json) com o MTA local (Postfix hash). + +Separado do Mailgun transacional (/etc/runv-email.json). Requer configuração +explícita em /etc/runv-member-mail.json no servidor. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +import runv_community as rc + +DEFAULT_CONFIG_PATH = Path("/etc/runv-member-mail.json") + +try: + import runv_email_aliases as ea +except ImportError: # pragma: no cover + ea = None # type: ignore[assignment] + + +def config_path() -> Path: + raw = os.environ.get("RUNV_MEMBER_MAIL_CONFIG", "").strip() + return Path(raw) if raw else DEFAULT_CONFIG_PATH + + +def load_config(path: Path | None = None) -> dict[str, Any]: + cfg_path = path or config_path() + if not cfg_path.is_file(): + return {} + try: + data = json.loads(cfg_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + rc.friendly_exit(f"config inválida em {cfg_path}: {e}") + if not isinstance(data, dict): + rc.friendly_exit(f"config inválida em {cfg_path}: esperado objecto JSON.") + return data + + +def is_sync_enabled(cfg: dict[str, Any] | None = None) -> bool: + data = cfg if cfg is not None else load_config() + return bool(data.get("enabled")) + + +def active_forwarding_rows() -> list[tuple[str, str]]: + if ea is None: + rc.friendly_exit("módulo runv_email_aliases indisponível.") + rows: list[tuple[str, str]] = [] + for username, alias, dest in ea.list_active_aliases(): + _ = username + rows.append((alias.lower(), dest.lower())) + rows.sort(key=lambda r: r[0]) + return rows + + +def render_postfix_virtual(rows: list[tuple[str, str]]) -> str: + lines = [ + "# Gerado por runv — não editar à mão; use runv-admin-email-alias sync", + "# Formato: alias@dominio destino@externo", + ] + for alias, dest in rows: + lines.append(f"{alias}\t{dest}") + lines.append("") + return "\n".join(lines) + + +def _run_cmd(cmd: list[str], *, dry_run: bool) -> None: + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + rc.friendly_exit(f"comando falhou ({' '.join(cmd)}): {err}") + + +def _atomic_write(path: Path, content: str, *, mode: int, dry_run: bool) -> None: + if dry_run: + print(f"[dry-run] escrever {path} ({len(content)} bytes, mode {oct(mode)})") + return + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=str(path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as out: + out.write(content) + out.flush() + os.fsync(out.fileno()) + os.chmod(tmp_path, mode) + os.replace(tmp_path, path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + +def check_postfix_maps_include(target: Path, *, dry_run: bool) -> None: + proc = subprocess.run( + ["postconf", "-h", "virtual_alias_maps"], + capture_output=True, + text=True, + timeout=30, + ) + if proc.returncode != 0: + print( + "aviso: postconf falhou; confirme manualmente que virtual_alias_maps inclui " + f"hash:{target}", + file=sys.stderr, + ) + return + maps = (proc.stdout or "").strip() + needle = f"hash:{target}" + if needle in maps.replace(" ", ""): + return + print( + f"aviso: virtual_alias_maps actual não referencia {needle}\n" + f" actual: {maps or '(vazio)'}\n" + " adicione (exemplo):\n" + f' postconf -e "virtual_alias_maps = ${{virtual_alias_maps}}, hash:{target}"\n' + " ou inclua o ficheiro na configuração existente (MySQL/LDAP/etc.).", + file=sys.stderr, + ) + + +def sync_postfix_hash(*, dry_run: bool = False, cfg: dict[str, Any] | None = None) -> int: + data = cfg if cfg is not None else load_config() + if not data.get("enabled"): + rc.friendly_exit( + f"sincronização desactivada; defina enabled=true em {config_path()}" + ) + backend = str(data.get("backend", "postfix-hash")).strip().lower() + if backend != "postfix-hash": + rc.friendly_exit(f"backend não suportado: {backend!r}") + + target = Path(str(data.get("virtual_alias_file", "/etc/postfix/runv-member-aliases"))) + file_mode = int(str(data.get("file_mode", "0o644")), 8) + rows = active_forwarding_rows() + body = render_postfix_virtual(rows) + + _atomic_write(target, body, mode=file_mode, dry_run=dry_run) + print(f"mapa Postfix: {target} ({len(rows)} alias(es) activo(s))") + + if data.get("check_maps", True): + check_postfix_maps_include(target, dry_run=dry_run) + + if data.get("run_postmap", True): + postmap = data.get("postmap_command") + if isinstance(postmap, list) and postmap: + cmd = [str(x) for x in postmap] + else: + cmd = ["postmap", str(target)] + _run_cmd(cmd, dry_run=dry_run) + + if data.get("reload_postfix", True): + reload_cmd = data.get("reload_command") + if isinstance(reload_cmd, list) and reload_cmd: + cmd = [str(x) for x in reload_cmd] + else: + cmd = ["systemctl", "reload", "postfix"] + _run_cmd(cmd, dry_run=dry_run) + + return 0 + + +def maybe_sync_after_approve(*, dry_run: bool = False) -> None: + cfg = load_config() + if not cfg.get("enabled") or not cfg.get("auto_sync_on_approve"): + return + sync_postfix_hash(dry_run=dry_run, cfg=cfg)