runv-server

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

commit 847add7d9a103a6ea5070c1780a3589c12fc9561
parent 921c4a438eebd1b6d4928088310bcb32881e9852
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 19:38:39 -0300

changing a lot of stuff

Diffstat:
MINSTALL.md | 18++++++++++--------
Memail/README.md | 59+++++++++++++++++++++++++++++++++++------------------------
Memail/config/msmtprc.example | 2+-
Aemail/configure_mailgun.py | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Memail/configure_msmtp.py | 463++-----------------------------------------------------------------------------
Aemail/configure_msmtp_legacy.py | 477+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Memail/docs/ADMIN.md | 60+++++++++++++++++++++++++++++++-----------------------------
Memail/docs/INSTALL.md | 132++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Memail/docs/INTEGRATION.md | 104++++++++++++++++++++++++-------------------------------------------------------
Memail/docs/TROUBLESHOOTING.md | 67++++++++++++++++++++++++++++++++++++++-----------------------------
Memail/lib/mailer.py | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Aemail/lib/mailgun_client.py | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Memail/templates/system_test.txt | 5+++--
Memail/templates/user_account_created.txt | 18+++++++++++++++---
Aemail/tests/test_mailgun_client.py | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpatches/patch_irc.py | 19+++++++++----------
Dpatches/patch_permissions.py | 356-------------------------------------------------------------------------------
Mscripts/admin/create_runv_user.py | 298++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mscripts/admin/del-user.py | 32+++++---------------------------
Ascripts/admin/perm1.md | 14++++++++++++++
Ascripts/admin/perm1.py | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/runv_jail.py | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/admin/setup_alt_protocols.py | 11++---------
Mscripts/create_runv_user.md | 31+++++++++++++++++++++++--------
Msite/public/assets/app.js | 32+++++++++++++++++++-------------
Msite/public/assets/style.css | 9++-------
Msite/public/faq/index.html | 37+++++++++++++++++++------------------
Asite/public/favicon.svg | 11+++++++++++
Msite/public/index.html | 4++--
Msite/public/junte-se/index.html | 1+
Msite/public/news/index.html | 1+
Msite/public/wiki/contas-e-acesso.html | 13++++++++-----
Msite/public/wiki/faq.html | 9+++++----
Msite/public/wiki/index.html | 37+++----------------------------------
Msite/public/wiki/privacidade-e-seguranca.html | 5+++--
Msite/public/wiki/punicoes-e-moderacao.html | 1+
Msite/public/wiki/regras-da-comunidade.html | 1+
Msite/public/wiki/visao-geral.html | 5+++--
Msite/wiki/01_index.txt | 26+-------------------------
Msite/wiki/02_visao-geral.txt | 4++--
Msite/wiki/03_contas-e-acesso.txt | 18++++++++++--------
Msite/wiki/06_privacidade-e-seguranca.txt | 6+++---
Msite/wiki/07_faq.txt | 8++++----
Msite/wiki/build_wiki.py | 14++++----------
Mterminal/entre_core.py | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtools/docs/INSTALL.md | 23++++++++++++++++-------
Mtools/docs/USER_EXPERIENCE.md | 10+++++-----
Mtools/manifests/apt_packages.txt | 5+++++
Mtools/motd/60-runv | 2+-
Atools/sshd/90-runv-jailed.conf | 7+++++++
Mtools/tools.py | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
51 files changed, 2298 insertions(+), 1341 deletions(-)

diff --git a/INSTALL.md b/INSTALL.md @@ -25,7 +25,7 @@ Este documento descreve a **ordem recomendada** para preparar um servidor Debian 2. **Ferramentas e ficheiros globais** — `tools/tools.py` (MOTD, skel, binários, pacotes do manifest). 3. **Site Apache / landing** — `site/genlanding.py`. 4. **Dados públicos da landing** — `site/build_directory.py` (idealmente em **cron**). -5. **Email de saída (msmtp)** — `email/configure_msmtp.py` (+ documentação em `email/docs/`). +5. **Email de saída (Mailgun API, predefinido)** — `email/configure_mailgun.py` (+ documentação em `email/docs/`; legado SMTP: `configure_msmtp_legacy.py`). 6. **SSH restrito «entre»** — `terminal/setup_entre.py`. 7. **Operação** — `create_runv_user.py`, `update_user.py`, `del-user.py` (e só em cenários controlados: `scripts/doom/doom.py`). @@ -43,7 +43,7 @@ sudo python3 starthere.py --help # rever opções sudo python3 starthere.py # ou com flags que precisares ``` -**Nota:** este script **não** configura msmtp nem o utilizador `entre`. Consulta também os Markdown em `scripts/docs/` se existirem no teu clone. +**Nota:** este script **não** configura email (Mailgun/msmtp) nem o utilizador `entre`. Consulta também os Markdown em `scripts/docs/` se existirem no teu clone. --- @@ -94,24 +94,26 @@ Ajusta intervalo e caminhos conforme a política do servidor. --- -## 5. Email (msmtp + mail): `email/configure_msmtp.py` +## 5. Email (Mailgun API + opcional legado SMTP): `email/configure_mailgun.py` -**Objetivo:** pacotes (`msmtp-mta`, `bsd-mailx` ou equivalente), `msmtprc`, `~/.netrc` ou segredo adequado, aliases, testes. +**Objetivo:** estado em `/etc/runv-email.json`, segredos em `/etc/runv-email.secrets.json`, envio via **API HTTP Mailgun** (sem msmtp no caminho predefinido). Modo **SMTP/msmtp** apenas com `--legacy-smtp` ou `configure_msmtp_legacy.py`. ```bash cd REPO/email -sudo python3 configure_msmtp.py --help -sudo python3 configure_msmtp.py --dry-run # simular -sudo python3 configure_msmtp.py # aplicar (root) +sudo python3 configure_mailgun.py --help +sudo python3 configure_mailgun.py --dry-run # simular +sudo python3 configure_mailgun.py # aplicar (root) ``` +O ficheiro `configure_msmtp.py` apenas indica os comandos actualizados (Mailgun ou legado). + Documentação completa: - `email/docs/INSTALL.md` - `email/docs/ADMIN.md`, `TROUBLESHOOTING.md`, `INTEGRATION.md` - `email/README.md` -Scripts auxiliares: `email/scripts/send_test_mail.sh`, `email/scripts/diagnose_msmtp.sh`. +Scripts auxiliares (legado / diagnóstico): `email/scripts/send_test_mail.sh`, `email/scripts/diagnose_msmtp.sh`. --- diff --git a/email/README.md b/email/README.md @@ -1,57 +1,68 @@ -# Email runv.club — envio via msmtp + sendmail +# Email runv.club — envio via Mailgun HTTP API (predefinido) -Subsistema **só de envio** para Debian 13: instala `msmtp`, `msmtp-mta`, `ca-certificates` e `bsd-mailx`, configura `/etc/msmtprc` e credenciais em `/root/.netrc`, e oferece biblioteca Python reutilizável com templates em texto puro. +**Aviso: o configurador predefinido foi feito para Mailgun.** Não pré-configura credenciais. -## O que faz +Subsistema **só de envio** para Debian 13: por omissão grava estado em `/etc/runv-email.json` e segredos em `/etc/runv-email.secrets.json`, e envia mensagens pela **API HTTP Mailgun** (Basic Auth `api` + API key). Opcionalmente mantém um modo **legado** com `msmtp` + `sendmail`. -- Coloca `/usr/sbin/sendmail` compatível (via **msmtp-mta**) a apontar para um **SMTP externo** configurável. -- **Não** instala Postfix, Exim, Dovecot nem qualquer MTA completo. +## O que faz (predefinido) + +- Configura envio **sem** Postfix/Exim/Dovecot — **não** é um MTA completo. - **Não** recebe email (sem IMAP, sem caixa local). +- Biblioteca Python reutilizável (`lib/mailer.py`) com templates em texto puro; suporte opcional a corpo **HTML** no `send_mail`. -## O que instala (APT) +## O que instala (APT) — só modo legado SMTP | Pacote | Papel | -|--------|--------| +|--------|-------| | `msmtp` | Cliente SMTP. | | `msmtp-mta` | Fornece `/usr/sbin/sendmail`. | | `ca-certificates` | Confiança TLS. | -| `bsd-mailx` | Comando `mail` para testes em CLI (evita `mailutils`, que pode recomendar MTA local). | +| `bsd-mailx` | Comando `mail` para testes em CLI. | + +**Mailgun API (predefinido)** não exige estes pacotes. ## Execução rápida ```bash cd /caminho/runv-server/email -sudo python3 configure_msmtp.py +sudo python3 configure_mailgun.py ``` -Flags: `--dry-run`, `--verbose`, `--force`, `--test`, `--skip-apt`. Detalhes: [docs/INSTALL.md](docs/INSTALL.md). +Legado SMTP: + +```bash +sudo python3 configure_mailgun.py --legacy-smtp +# ou: sudo python3 configure_msmtp_legacy.py +``` + +O ficheiro `configure_msmtp.py` apenas **indica** estes comandos (substituição do antigo fluxo). + +Flags: `--dry-run`, `--verbose`, `--force`, `--test`, `--legacy-smtp`. Detalhes: [docs/INSTALL.md](docs/INSTALL.md). ## Documentação | Ficheiro | Conteúdo | |----------|-----------| -| [docs/INSTALL.md](docs/INSTALL.md) | Instalação, flags, verificação, testes. | -| [docs/ADMIN.md](docs/ADMIN.md) | Alterar SMTP, remetente, admin, aliases. | +| [docs/INSTALL.md](docs/INSTALL.md) | Mailgun vs legado, ficheiros, flags, testes, variáveis de ambiente. | +| [docs/ADMIN.md](docs/ADMIN.md) | Alterar remetente, admin, segredos. | | [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Falhas comuns. | -| [docs/INTEGRATION.md](docs/INTEGRATION.md) | `lib/mailer.py`, eventos, scripts existentes. | +| [docs/INTEGRATION.md](docs/INTEGRATION.md) | `lib/mailer.py`, eventos, `entre`. | ## Biblioteca -Defina `RUNV_EMAIL_ROOT` para a pasta `email/` do repositório (onde estão `lib/` e `templates/`) e importe `lib.mailer`. +Defina `RUNV_EMAIL_ROOT` para a pasta `email/` do repositório (onde estão `lib/` e `templates/`) e importe `lib.mailer`. O configurador grava também `email_package_root` em `/etc/runv-email.json` para o serviço `entre` encontrar o módulo sem variável de ambiente. -## Checklist manual de verificação +## Checklist manual de verificação (Mailgun) -- [ ] `dpkg -l msmtp msmtp-mta` — pacotes instalados. -- [ ] `readlink -f /usr/sbin/sendmail` — aponta para msmtp. -- [ ] `ls -l /etc/msmtprc /root/.netrc` — permissões 600, root. -- [ ] `sudo python3 configure_msmtp.py --test` — email de teste recebido. -- [ ] `echo corpo | mail -s assunto root` — chega ao alias do admin (se aliases configurados). -- [ ] Fluxo `entre` com `admin_email` no `config.toml` — notificação ao admin (regressão). +- [ ] `sudo ls -l /etc/runv-email.json /etc/runv-email.secrets.json` — **0600**, root. +- [ ] `sudo python3 configure_mailgun.py --test` — email de teste recebido. +- [ ] `email_package_root` no JSON aponta para a pasta `email/` do deploy (para notificações `entre`). +- [ ] Fluxo `entre` com `admin_email` no `config.toml` — notificação ao admin (Mailgun ou sendmail de fallback). -## Scripts auxiliares +## Scripts auxiliares (legado / diagnóstico) -- `scripts/diagnose_msmtp.sh` — diagnóstico sem segredos. +- `scripts/diagnose_msmtp.sh` — diagnóstico msmtp (modo SMTP). - `scripts/send_test_mail.sh` — teste via `mail`. -- `scripts/netrc_password.py` — usado por `passwordeval` no msmtp (instalado em `/usr/local/lib/runv-email/`). +- `scripts/netrc_password.py` — usado por `passwordeval` no msmtp (só legado). Versão do módulo alinhada ao repositório runv-server. diff --git a/email/config/msmtprc.example b/email/config/msmtprc.example @@ -1,5 +1,5 @@ # Exemplo de /etc/msmtprc (runv.club — msmtp, sem MTA local). -# Gerado normalmente por email/configure_msmtp.py — não copie credenciais para exemplos. +# Exemplo msmtp (modo legado). Gerado por email/configure_msmtp_legacy.py — não copie credenciais para exemplos. defaults auth on diff --git a/email/configure_mailgun.py b/email/configure_mailgun.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +Configurador de email runv — Mailgun HTTP API (predefinido). + +Aviso: este script foi feito para Mailgun. Não pré-configura nenhuma credencial. + +Executar como root. Ver email/docs/INSTALL.md. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +import time +from getpass import getpass +from pathlib import Path +from typing import Any + +MODULE_ROOT = Path(__file__).resolve().parent +STATE_PATH = Path("/etc/runv-email.json") +SECRETS_PATH = Path("/etc/runv-email.secrets.json") + +sys.path.insert(0, str(MODULE_ROOT)) +from lib.mailgun_client import ( # noqa: E402 + build_mailgun_messages_url, + mailgun_base_url, + mask_secret, + validate_mailgun_inputs, +) + + +def setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + +def log() -> logging.Logger: + return logging.getLogger("runv-email-mailgun") + + +def require_root() -> None: + if os.geteuid() != 0: + print("Execute como root (sudo).", file=sys.stderr) + raise SystemExit(1) + + +def prompt_line(msg: str, default: str = "") -> str: + d = f" [{default}]" if default else "" + r = input(f"{msg}{d}: ").strip() + return r if r else default + + +def prompt_yes_no(msg: str, default_no: bool = True) -> bool: + suf = " [s/N]: " if default_no else " [S/n]: " + r = input(msg + suf).strip().lower() + if not r: + return not default_no + return r in ("s", "sim", "y", "yes") + + +def interactive_config(*, email_package_root: str) -> tuple[dict[str, Any], dict[str, str]]: + print() + print("=== Configurador de email para Mailgun API ===") + print() + print("Aviso: este script foi feito para Mailgun. Não pré-configura nenhuma credencial.") + print() + + print("Tipo de chave Mailgun (recomendado: domain sending key — menor privilégio):") + print(" 1) Domain sending key (recomendado)") + print(" 2) Primary account API key") + choice = prompt_line("Escolha [1/2]", "1").strip() + api_key_kind = "domain_sending" if choice != "2" else "primary" + + domain = prompt_line("Domínio de envio Mailgun (ex.: mg.exemplo.com ou exemplo.com)") + region = prompt_line("Região da API (us ou eu)", "us").strip().lower() + if region not in ("us", "eu"): + raise ValueError("Região deve ser 'us' ou 'eu'.") + + key = getpass("Mailgun API key (não ecoa): ").strip() + key2 = getpass("Repita a API key: ").strip() + if key != key2: + raise ValueError("As chaves não coincidem.") + default_from = prompt_line("Remetente padrão (From)") + admin_email = prompt_line("Email do administrador (notificações / teste)") + + validated = validate_mailgun_inputs( + domain=domain, + region=region, + from_addr=default_from, + admin_email=admin_email, + api_key=key, + ) + + base = mailgun_base_url(validated["region"]) + public: dict[str, Any] = { + "backend": "mailgun", + "provider": "mailgun", + "mailgun_domain": validated["domain"], + "mailgun_region": validated["region"], + "api_base_url": base, + "default_from": validated["from_addr"], + "admin_email": validated["admin_email"], + "api_key_kind": api_key_kind, + "api_key_source": "file", + "secrets_path": str(SECRETS_PATH), + "email_package_root": email_package_root, + } + secrets = {"mailgun_api_key": key} + return public, secrets + + +def write_json_atomic(path: Path, data: dict[str, Any], *, mode: int, dry_run: bool) -> None: + if dry_run: + log().info("[dry-run] escreveria %s (modo %o)", path, mode) + return + path.parent.mkdir(parents=True, exist_ok=True) + text = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + tmp = path.with_name(f".{path.name}.{os.getpid()}.{int(time.time())}.tmp") + tmp.write_text(text, encoding="utf-8") + os.chmod(tmp, mode) + try: + os.chown(tmp, 0, 0) + except OSError: + pass + tmp.replace(path) + log().info("Escrito %s (%o)", path, mode) + + +def run_test_send(*, dry_run: bool) -> None: + pub = json.loads(STATE_PATH.read_text(encoding="utf-8")) + admin = str(pub.get("admin_email", "")).strip() + from_addr = str(pub.get("default_from", "")).strip() + if not admin or not from_addr: + raise ValueError("admin_email ou default_from em falta no estado") + + from lib.mailer import render_template, send_mail + + body = render_template( + "system_test", + admin_email=admin, + default_from=from_addr, + host=pub.get("mailgun_domain", ""), + api_base_url=pub.get("api_base_url", ""), + timestamp=str(int(time.time())), + ) + subj = "[runv.club] Email de teste do sistema (Mailgun API)" + if dry_run: + log().info("[dry-run] enviaria teste via Mailgun API para %s", admin) + return + try: + send_mail(admin, subj, body, from_addr=from_addr, _state=pub) + except MailgunHTTPError: + raise + except Exception as e: + log().debug("detalhe (sem segredos): %s", type(e).__name__) + raise + log().info("Email de teste enviado para %s", admin) + + +def print_summary(public: dict[str, Any], *, dry_run: bool) -> None: + print() + print("=== Resumo ===") + print(f" provider: Mailgun API") + print(f" domain: {public.get('mailgun_domain', '')}") + print(f" region: {public.get('mailgun_region', '')}") + print(f" api base URL: {public.get('api_base_url', '')}") + print(f" messages URL: {build_mailgun_messages_url(base_url=str(public.get('api_base_url','')), domain=str(public.get('mailgun_domain','')))}") + print(f" default from: {public.get('default_from', '')}") + print(f" admin email: {public.get('admin_email', '')}") + print(f" estado (meta): {STATE_PATH}") + print(f" segredos: {SECRETS_PATH} (API key — não partilhar; não impressa aqui)") + print(f" email_pkg_root: {public.get('email_package_root', '')}") + if dry_run: + print(" (dry-run — ficheiros não gravados)") + print() + print("Documentação: email/docs/INSTALL.md") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Configura envio de email via Mailgun HTTP API (predefinido).", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--verbose", "-v", action="store_true") + parser.add_argument("--force", "-f", action="store_true", help="sobrescrever sem perguntar") + parser.add_argument( + "--test", + action="store_true", + help="enviar apenas email de teste (requer estado em /etc/runv-email.json)", + ) + parser.add_argument( + "--legacy-smtp", + action="store_true", + help="usar o configurador SMTP/msmtp legado (desativado por predefinição)", + ) + args = parser.parse_args() + + if args.legacy_smtp: + import configure_msmtp_legacy as leg + + argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a != "--legacy-smtp"] + sys.argv = argv + return leg.main() + + setup_logging(args.verbose) + require_root() + + print() + print("Aviso: este script foi feito para Mailgun. Não pré-configura nenhuma credencial.") + + try: + if args.test: + if not STATE_PATH.is_file(): + log().error("Estado não encontrado: %s — execute o configurador primeiro.", STATE_PATH) + return 1 + try: + run_test_send(dry_run=args.dry_run) + except Exception as e: + log().error("%s", e) + return 1 + print("Teste concluído.") + return 0 + + default_pkg = str(MODULE_ROOT) + root_guess = prompt_line( + "Caminho da pasta `email/` do repositório (importações, ex. entre)", + default_pkg, + ).strip() + if not root_guess: + root_guess = default_pkg + ep_root = str(Path(root_guess).resolve()) + + public, secrets = interactive_config(email_package_root=ep_root) + + if STATE_PATH.is_file() and not args.force and not args.dry_run: + if not prompt_yes_no(f"Sobrescrever {STATE_PATH} e segredos?", default_no=True): + print("Cancelado.") + return 1 + + write_json_atomic(STATE_PATH, public, mode=0o600, dry_run=args.dry_run) + write_json_atomic(SECRETS_PATH, secrets, mode=0o600, dry_run=args.dry_run) + + if not args.dry_run: + log().info("API key armazenada em %s (mascarado: %s)", SECRETS_PATH, mask_secret(secrets["mailgun_api_key"])) + + if not args.dry_run and prompt_yes_no("\nEnviar email de teste agora?", default_no=True): + try: + run_test_send(dry_run=False) + log().info("Teste enviado.") + except Exception as e: + log().warning("Teste falhou: %s", e) + + print_summary(public, dry_run=args.dry_run) + print("Teste posterior: sudo python3 email/configure_mailgun.py --test") + return 0 + + except (KeyboardInterrupt, EOFError): + print("\nInterrompido.", file=sys.stderr) + return 130 + except Exception as e: + log().error("%s", e) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/email/configure_msmtp.py b/email/configure_msmtp.py @@ -1,467 +1,24 @@ #!/usr/bin/env python3 """ -Instalador/configurador runv.club — envio de email via msmtp + sendmail (Debian 13). +Encaminhamento: o configurador predefinido passou a ser Mailgun API. -Executar como root. Ver email/docs/INSTALL.md. +Use ``configure_mailgun.py`` (recomendado) ou ``configure_msmtp_legacy.py`` (SMTP/msmtp). """ from __future__ import annotations -import argparse -import json -import logging -import os -import re -import shutil -import subprocess import sys -import time -from getpass import getpass -from pathlib import Path -from typing import Any - -# Caminhos no sistema -MSMPTRC_PATH = Path("/etc/msmtprc") -ALIASES_PATH = Path("/etc/msmtp_aliases") -NETRC_PATH = Path("/root/.netrc") -STATE_PATH = Path("/etc/runv-email.json") -PASS_SCRIPT_DIR = Path("/usr/local/lib/runv-email") -PASS_SCRIPT_DEST = PASS_SCRIPT_DIR / "netrc_password.py" -LOGFILE_MSMT = Path("/var/log/msmtp.log") - -MODULE_ROOT = Path(__file__).resolve().parent -SOURCE_PASS_SCRIPT = MODULE_ROOT / "scripts" / "netrc_password.py" - -APT_PACKAGES = ("msmtp", "msmtp-mta", "ca-certificates", "bsd-mailx") - -ACCOUNT_NAME = "runv" - - -def setup_logging(verbose: bool) -> None: - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(levelname)s: %(message)s") - - -def log() -> logging.Logger: - return logging.getLogger("runv-email") - - -def require_root() -> None: - if os.geteuid() != 0: - print("Execute como root (sudo).", file=sys.stderr) - raise SystemExit(1) - - -def run_cmd( - cmd: list[str], - *, - dry_run: bool, - timeout: int = 600, -) -> subprocess.CompletedProcess[str] | None: - log().debug("exec: %s", " ".join(cmd)) - if dry_run: - log().info("[dry-run] %s", " ".join(cmd)) - return None - return subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout, - ) - - -def apt_install(dry_run: bool) -> None: - r = run_cmd(["apt-get", "update", "-qq"], dry_run=dry_run) - if r is not None and r.returncode != 0: - log().warning("apt-get update: código %s — %s", r.returncode, r.stderr.strip()) - r2 = run_cmd( - ["apt-get", "install", "-y", *APT_PACKAGES], - dry_run=dry_run, - ) - if r2 is not None and r2.returncode != 0: - raise RuntimeError(f"apt-get install falhou: {r2.stderr or r2.stdout}") - - -def backup_if_exists(path: Path, *, dry_run: bool, force: bool) -> Path | None: - if not path.is_file(): - return None - bak = path.with_name(f"{path.name}.bak.{int(time.time())}") - if dry_run: - log().info("[dry-run] backup seria: %s -> %s", path, bak) - return bak - shutil.copy2(path, bak) - log().info("Backup: %s", bak) - return bak - - -def confirm_overwrite(path: Path, *, force: bool) -> bool: - if force: - return True - if not path.is_file(): - return True - r = input(f"O ficheiro {path} já existe. Sobrescrever? [s/N]: ").strip().lower() - return r in ("s", "sim", "y", "yes") - - -def _remove_netrc_machine_block(text: str, host: str) -> str: - """Remove o bloco que começa em 'machine <host>' até à linha antes do próximo 'machine '.""" - host_line = re.compile(rf"^machine\s+{re.escape(host)}\s*$", re.MULTILINE) - next_machine = re.compile(r"^machine\s+", re.MULTILINE) - lines = text.splitlines() - out: list[str] = [] - i = 0 - while i < len(lines): - if host_line.match(lines[i]): - i += 1 - while i < len(lines) and not next_machine.match(lines[i]): - i += 1 - continue - out.append(lines[i]) - i += 1 - return "\n".join(out) - - -def upsert_netrc_machine(host: str, login: str, password: str, *, dry_run: bool) -> None: - """Atualiza ou acrescenta bloco machine HOST em /root/.netrc.""" - block = f"machine {host}\nlogin {login}\npassword {password}\n" - if dry_run: - log().info("[dry-run] atualizaria .netrc para machine %s", host) - return - - existing = "" - if NETRC_PATH.is_file(): - existing = NETRC_PATH.read_text(encoding="utf-8", errors="replace") - - stripped = _remove_netrc_machine_block(existing, host).rstrip() - new_text = (stripped + "\n\n" + block if stripped else block).rstrip() + "\n" - - NETRC_PATH.parent.mkdir(parents=True, exist_ok=True) - NETRC_PATH.write_text(new_text, encoding="utf-8") - os.chmod(NETRC_PATH, 0o600) - try: - os.chown(NETRC_PATH, 0, 0) - except OSError: - pass - log().info("Escrito %s (0600)", NETRC_PATH) - - -def install_passwordeval_script(*, dry_run: bool) -> None: - if not SOURCE_PASS_SCRIPT.is_file(): - raise FileNotFoundError(f"script em falta no módulo: {SOURCE_PASS_SCRIPT}") - if dry_run: - log().info("[dry-run] copiaria netrc_password.py para %s", PASS_SCRIPT_DEST) - return - PASS_SCRIPT_DIR.mkdir(parents=True, mode=0o755, exist_ok=True) - shutil.copy2(SOURCE_PASS_SCRIPT, PASS_SCRIPT_DEST) - PASS_SCRIPT_DEST.chmod(0o755) - try: - os.chown(PASS_SCRIPT_DEST, 0, 0) - except OSError: - pass - log().info("Instalado %s", PASS_SCRIPT_DEST) - - -def build_msmtprc( - *, - host: str, - port: int, - tls_on: bool, - starttls_on: bool, - auth_on: bool, - user: str, - default_from: str, - use_aliases: bool, -) -> str: - lines = [ - "# Gerido por runv.club configure_msmtp.py — não editar à mão sem cópia de segurança", - "", - "defaults", - f"tls_trust_file /etc/ssl/certs/ca-certificates.crt", - f"logfile {LOGFILE_MSMT}", - "", - f"account {ACCOUNT_NAME}", - f"host {host}", - f"port {port}", - f"from {default_from}", - "tls " + ("on" if tls_on else "off"), - "tls_starttls " + ("on" if starttls_on else "off"), - ] - if auth_on and user: - lines.append("auth on") - lines.append(f"user {user}") - lines.append(f"passwordeval {PASS_SCRIPT_DEST} {host}") - else: - lines.append("auth off") - - if use_aliases: - lines.append(f"aliases {ALIASES_PATH}") - - lines.extend( - [ - "", - f"account default : {ACCOUNT_NAME}", - "", - ] - ) - return "\n".join(lines) - - -def write_msmtprc(content: str, *, dry_run: bool) -> None: - if dry_run: - log().info("[dry-run] escreveria %s", MSMPTRC_PATH) - log().debug("%s", content) - return - MSMPTRC_PATH.write_text(content, encoding="utf-8") - os.chmod(MSMPTRC_PATH, 0o600) - try: - os.chown(MSMPTRC_PATH, 0, 0) - except OSError: - pass - log().info("Escrito %s (0600)", MSMPTRC_PATH) - - -def write_aliases(admin_email: str, *, dry_run: bool) -> None: - body = ( - f"# Gerido por runv.club configure_msmtp.py — formato msmtp (não Sendmail)\n" - f"root: {admin_email}\n" - f"cron: {admin_email}\n" - f"default: {admin_email}\n" - ) - if dry_run: - log().info("[dry-run] escreveria %s", ALIASES_PATH) - return - backup_if_exists(ALIASES_PATH, dry_run=False, force=True) - ALIASES_PATH.write_text(body, encoding="utf-8") - os.chmod(ALIASES_PATH, 0o644) - try: - os.chown(ALIASES_PATH, 0, 0) - except OSError: - pass - log().info("Escrito %s (0644)", ALIASES_PATH) - - -def write_state(data: dict[str, Any], *, dry_run: bool) -> None: - if dry_run: - log().info("[dry-run] escreveria %s", STATE_PATH) - return - STATE_PATH.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - os.chmod(STATE_PATH, 0o600) - try: - os.chown(STATE_PATH, 0, 0) - except OSError: - pass - log().info("Metadados em %s (sem segredos)", STATE_PATH) - - -def touch_logfile(*, dry_run: bool) -> None: - if dry_run: - return - LOGFILE_MSMT.parent.mkdir(parents=True, exist_ok=True) - if not LOGFILE_MSMT.exists(): - LOGFILE_MSMT.touch(mode=0o640) - try: - os.chown(LOGFILE_MSMT, 0, 0) - except OSError: - pass - - -def load_state() -> dict[str, Any]: - if not STATE_PATH.is_file(): - raise FileNotFoundError( - f"Estado não encontrado: {STATE_PATH}. Execute configure_msmtp.py sem --test primeiro.", - ) - return json.loads(STATE_PATH.read_text(encoding="utf-8")) - - -def run_test_send(*, dry_run: bool) -> None: - state = load_state() - admin = str(state.get("admin_email", "")).strip() - from_addr = str(state.get("default_from", "")).strip() - if not admin or not from_addr: - raise ValueError("admin_email ou default_from em falta no estado") - - sys.path.insert(0, str(MODULE_ROOT)) - from lib.mailer import render_template, send_mail - - body = render_template( - "system_test", - admin_email=admin, - default_from=from_addr, - host=state.get("smtp_host", ""), - timestamp=str(int(time.time())), - ) - subj = "[runv.club] Email de teste do sistema" - if dry_run: - log().info("[dry-run] enviaria teste para %s", admin) - return - send_mail(admin, subj, body, from_addr=from_addr) - log().info("Email de teste enviado para %s", admin) - - -def prompt_yes_no(msg: str, default_no: bool = True) -> bool: - suf = " [s/N]: " if default_no else " [S/n]: " - r = input(msg + suf).strip().lower() - if not r: - return not default_no - return r in ("s", "sim", "y", "yes") - - -def prompt_line(msg: str, default: str = "") -> str: - d = f" [{default}]" if default else "" - r = input(f"{msg}{d}: ").strip() - return r if r else default - - -def interactive_config() -> dict[str, Any]: - print("\n=== Configuração SMTP (genérico — nenhum provedor assumido) ===\n") - host = prompt_line("Host SMTP") - if not host: - raise ValueError("Host SMTP obrigatório.") - - port_s = prompt_line("Porta SMTP", "587") - port = int(port_s) if port_s.isdigit() else 587 - - tls_on = prompt_yes_no("Usar TLS (tls)?", default_no=False) - starttls_on = prompt_yes_no("Usar STARTTLS (tls_starttls)?", default_no=False) - auth_on = prompt_yes_no("Autenticação SMTP (usuário/senha)?", default_no=False) - - user = "" - if auth_on: - user = prompt_line("Utilizador SMTP (login)") - if not user: - raise ValueError("Com auth on, o utilizador SMTP é obrigatório.") - - default_from = prompt_line("Remetente padrão (From)") - if not default_from or "@" not in default_from: - raise ValueError("Remetente (From) deve ser um endereço de email válido.") - - admin_email = prompt_line("Email do administrador (notificações)") - if not admin_email or "@" not in admin_email: - raise ValueError("Email do admin inválido.") - - password = "" - if auth_on: - p1 = getpass("Senha ou token SMTP (não ecoa): ") - p2 = getpass("Repita a senha: ") - if p1 != p2: - raise ValueError("Senhas não coincidem.") - password = p1 - - return { - "smtp_host": host, - "smtp_port": port, - "tls_on": tls_on, - "starttls_on": starttls_on, - "auth_on": auth_on, - "smtp_user": user, - "smtp_password": password, - "default_from": default_from, - "admin_email": admin_email, - } def main() -> int: - parser = argparse.ArgumentParser( - description="Instala msmtp/sendmail e configura envio de email runv.club.", - ) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--verbose", "-v", action="store_true") - parser.add_argument("--force", "-f", action="store_true", help="sobrescrever sem perguntar") - parser.add_argument( - "--test", - action="store_true", - help="enviar apenas email de teste (requer config e %s)" % STATE_PATH, - ) - parser.add_argument("--skip-apt", action="store_true", help="não executar apt-get") - args = parser.parse_args() - - setup_logging(args.verbose) - require_root() - - try: - if args.test: - run_test_send(dry_run=args.dry_run) - print("Teste concluído.") - return 0 - - if not args.skip_apt: - apt_install(args.dry_run) - - touch_logfile(dry_run=args.dry_run) - install_passwordeval_script(dry_run=args.dry_run) - - cfg = interactive_config() - - if not confirm_overwrite(MSMPTRC_PATH, force=args.force): - print("Cancelado.") - return 1 - backup_if_exists(MSMPTRC_PATH, dry_run=args.dry_run, force=args.force) - - if cfg["auth_on"]: - if not cfg.get("smtp_password"): - raise ValueError("Com autenticação ligada, a senha/token é obrigatório.") - if not confirm_overwrite(NETRC_PATH, force=args.force): - print("Cancelado.") - return 1 - backup_if_exists(NETRC_PATH, dry_run=args.dry_run, force=args.force) - upsert_netrc_machine( - cfg["smtp_host"], - cfg["smtp_user"], - cfg["smtp_password"], - dry_run=args.dry_run, - ) - - mc = build_msmtprc( - host=cfg["smtp_host"], - port=int(cfg["smtp_port"]), - tls_on=bool(cfg["tls_on"]), - starttls_on=bool(cfg["starttls_on"]), - auth_on=bool(cfg["auth_on"]), - user=cfg["smtp_user"], - default_from=cfg["default_from"], - use_aliases=True, - ) - write_msmtprc(mc, dry_run=args.dry_run) - - if not confirm_overwrite(ALIASES_PATH, force=args.force): - print("Cancelado.") - return 1 - write_aliases(cfg["admin_email"], dry_run=args.dry_run) - - state_public = { - "admin_email": cfg["admin_email"], - "default_from": cfg["default_from"], - "smtp_host": cfg["smtp_host"], - "smtp_port": cfg["smtp_port"], - } - write_state(state_public, dry_run=args.dry_run) - - if not args.dry_run and prompt_yes_no("\nEnviar email de teste agora?", default_no=True): - try: - run_test_send(dry_run=False) - log().info("Teste enviado.") - except Exception as e: - log().warning("Teste falhou (config pode estar correta mesmo assim): %s", e) - - print("\n=== Resumo ===") - print(f" msmtp: {MSMPTRC_PATH}") - print(f" aliases: {ALIASES_PATH}") - print(f" netrc: {NETRC_PATH} (credenciais — não partilhar)") - print(f" estado: {STATE_PATH}") - print(f" sendmail: /usr/sbin/sendmail (msmtp-mta)") - print("\nDocumentação: email/docs/INSTALL.md") - print("Teste posterior: sudo python3 email/configure_msmtp.py --test") - return 0 - - except (KeyboardInterrupt, EOFError): - print("\nInterrompido.", file=sys.stderr) - return 130 - except Exception as e: - log().error("%s", e) - return 1 + print( + "Este comando foi substituído.\n" + " Mailgun (API, predefinido): sudo python3 email/configure_mailgun.py\n" + " SMTP legado (msmtp): sudo python3 email/configure_msmtp_legacy.py\n" + " ou: sudo python3 email/configure_mailgun.py --legacy-smtp", + file=sys.stderr, + ) + return 1 if __name__ == "__main__": diff --git a/email/configure_msmtp_legacy.py b/email/configure_msmtp_legacy.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +""" +LEGADO — Instalador/configurador runv.club: envio via msmtp + sendmail (Debian 13). + +O caminho predefinido do projeto é Mailgun API (`configure_mailgun.py`). +Use este script apenas se precisar de SMTP local/msmtp. + +Executar como root. Ver email/docs/INSTALL.md. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import time +from getpass import getpass +from pathlib import Path +from typing import Any + +# Caminhos no sistema +MSMPTRC_PATH = Path("/etc/msmtprc") +ALIASES_PATH = Path("/etc/msmtp_aliases") +NETRC_PATH = Path("/root/.netrc") +STATE_PATH = Path("/etc/runv-email.json") +PASS_SCRIPT_DIR = Path("/usr/local/lib/runv-email") +PASS_SCRIPT_DEST = PASS_SCRIPT_DIR / "netrc_password.py" +LOGFILE_MSMT = Path("/var/log/msmtp.log") + +MODULE_ROOT = Path(__file__).resolve().parent +SOURCE_PASS_SCRIPT = MODULE_ROOT / "scripts" / "netrc_password.py" + +APT_PACKAGES = ("msmtp", "msmtp-mta", "ca-certificates", "bsd-mailx") + +ACCOUNT_NAME = "runv" + + +def setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + +def log() -> logging.Logger: + return logging.getLogger("runv-email-legacy-smtp") + + +def require_root() -> None: + if os.geteuid() != 0: + print("Execute como root (sudo).", file=sys.stderr) + raise SystemExit(1) + + +def run_cmd( + cmd: list[str], + *, + dry_run: bool, + timeout: int = 600, +) -> subprocess.CompletedProcess[str] | None: + log().debug("exec: %s", " ".join(cmd)) + if dry_run: + log().info("[dry-run] %s", " ".join(cmd)) + return None + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def apt_install(dry_run: bool) -> None: + r = run_cmd(["apt-get", "update", "-qq"], dry_run=dry_run) + if r is not None and r.returncode != 0: + log().warning("apt-get update: código %s — %s", r.returncode, r.stderr.strip()) + r2 = run_cmd( + ["apt-get", "install", "-y", *APT_PACKAGES], + dry_run=dry_run, + ) + if r2 is not None and r2.returncode != 0: + raise RuntimeError(f"apt-get install falhou: {r2.stderr or r2.stdout}") + + +def backup_if_exists(path: Path, *, dry_run: bool, force: bool) -> Path | None: + if not path.is_file(): + return None + bak = path.with_name(f"{path.name}.bak.{int(time.time())}") + if dry_run: + log().info("[dry-run] backup seria: %s -> %s", path, bak) + return bak + shutil.copy2(path, bak) + log().info("Backup: %s", bak) + return bak + + +def confirm_overwrite(path: Path, *, force: bool) -> bool: + if force: + return True + if not path.is_file(): + return True + r = input(f"O ficheiro {path} já existe. Sobrescrever? [s/N]: ").strip().lower() + return r in ("s", "sim", "y", "yes") + + +def _remove_netrc_machine_block(text: str, host: str) -> str: + """Remove o bloco que começa em 'machine <host>' até à linha antes do próximo 'machine '.""" + host_line = re.compile(rf"^machine\s+{re.escape(host)}\s*$", re.MULTILINE) + next_machine = re.compile(r"^machine\s+", re.MULTILINE) + lines = text.splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + if host_line.match(lines[i]): + i += 1 + while i < len(lines) and not next_machine.match(lines[i]): + i += 1 + continue + out.append(lines[i]) + i += 1 + return "\n".join(out) + + +def upsert_netrc_machine(host: str, login: str, password: str, *, dry_run: bool) -> None: + """Atualiza ou acrescenta bloco machine HOST em /root/.netrc.""" + block = f"machine {host}\nlogin {login}\npassword {password}\n" + if dry_run: + log().info("[dry-run] atualizaria .netrc para machine %s", host) + return + + existing = "" + if NETRC_PATH.is_file(): + existing = NETRC_PATH.read_text(encoding="utf-8", errors="replace") + + stripped = _remove_netrc_machine_block(existing, host).rstrip() + new_text = (stripped + "\n\n" + block if stripped else block).rstrip() + "\n" + + NETRC_PATH.parent.mkdir(parents=True, exist_ok=True) + NETRC_PATH.write_text(new_text, encoding="utf-8") + os.chmod(NETRC_PATH, 0o600) + try: + os.chown(NETRC_PATH, 0, 0) + except OSError: + pass + log().info("Escrito %s (0600)", NETRC_PATH) + + +def install_passwordeval_script(*, dry_run: bool) -> None: + if not SOURCE_PASS_SCRIPT.is_file(): + raise FileNotFoundError(f"script em falta no módulo: {SOURCE_PASS_SCRIPT}") + if dry_run: + log().info("[dry-run] copiaria netrc_password.py para %s", PASS_SCRIPT_DEST) + return + PASS_SCRIPT_DIR.mkdir(parents=True, mode=0o755, exist_ok=True) + shutil.copy2(SOURCE_PASS_SCRIPT, PASS_SCRIPT_DEST) + PASS_SCRIPT_DEST.chmod(0o755) + try: + os.chown(PASS_SCRIPT_DEST, 0, 0) + except OSError: + pass + log().info("Instalado %s", PASS_SCRIPT_DEST) + + +def build_msmtprc( + *, + host: str, + port: int, + tls_on: bool, + starttls_on: bool, + auth_on: bool, + user: str, + default_from: str, + use_aliases: bool, +) -> str: + lines = [ + "# Gerido por runv.club configure_msmtp_legacy.py — não editar à mão sem cópia de segurança", + "", + "defaults", + f"tls_trust_file /etc/ssl/certs/ca-certificates.crt", + f"logfile {LOGFILE_MSMT}", + "", + f"account {ACCOUNT_NAME}", + f"host {host}", + f"port {port}", + f"from {default_from}", + "tls " + ("on" if tls_on else "off"), + "tls_starttls " + ("on" if starttls_on else "off"), + ] + if auth_on and user: + lines.append("auth on") + lines.append(f"user {user}") + lines.append(f"passwordeval {PASS_SCRIPT_DEST} {host}") + else: + lines.append("auth off") + + if use_aliases: + lines.append(f"aliases {ALIASES_PATH}") + + lines.extend( + [ + "", + f"account default : {ACCOUNT_NAME}", + "", + ] + ) + return "\n".join(lines) + + +def write_msmtprc(content: str, *, dry_run: bool) -> None: + if dry_run: + log().info("[dry-run] escreveria %s", MSMPTRC_PATH) + log().debug("%s", content) + return + MSMPTRC_PATH.write_text(content, encoding="utf-8") + os.chmod(MSMPTRC_PATH, 0o600) + try: + os.chown(MSMPTRC_PATH, 0, 0) + except OSError: + pass + log().info("Escrito %s (0600)", MSMPTRC_PATH) + + +def write_aliases(admin_email: str, *, dry_run: bool) -> None: + body = ( + f"# Gerido por runv.club configure_msmtp_legacy.py — formato msmtp (não Sendmail)\n" + f"root: {admin_email}\n" + f"cron: {admin_email}\n" + f"default: {admin_email}\n" + ) + if dry_run: + log().info("[dry-run] escreveria %s", ALIASES_PATH) + return + backup_if_exists(ALIASES_PATH, dry_run=False, force=True) + ALIASES_PATH.write_text(body, encoding="utf-8") + os.chmod(ALIASES_PATH, 0o644) + try: + os.chown(ALIASES_PATH, 0, 0) + except OSError: + pass + log().info("Escrito %s (0644)", ALIASES_PATH) + + +def write_state(data: dict[str, Any], *, dry_run: bool) -> None: + if dry_run: + log().info("[dry-run] escreveria %s", STATE_PATH) + return + STATE_PATH.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + os.chmod(STATE_PATH, 0o600) + try: + os.chown(STATE_PATH, 0, 0) + except OSError: + pass + log().info("Metadados em %s (sem segredos SMTP em texto claro — use .netrc)", STATE_PATH) + + +def touch_logfile(*, dry_run: bool) -> None: + if dry_run: + return + LOGFILE_MSMT.parent.mkdir(parents=True, exist_ok=True) + if not LOGFILE_MSMT.exists(): + LOGFILE_MSMT.touch(mode=0o640) + try: + os.chown(LOGFILE_MSMT, 0, 0) + except OSError: + pass + + +def load_state() -> dict[str, Any]: + if not STATE_PATH.is_file(): + raise FileNotFoundError( + f"Estado não encontrado: {STATE_PATH}. Execute configure_msmtp_legacy.py sem --test primeiro.", + ) + return json.loads(STATE_PATH.read_text(encoding="utf-8")) + + +def run_test_send(*, dry_run: bool) -> None: + state = load_state() + admin = str(state.get("admin_email", "")).strip() + from_addr = str(state.get("default_from", "")).strip() + if not admin or not from_addr: + raise ValueError("admin_email ou default_from em falta no estado") + + sys.path.insert(0, str(MODULE_ROOT)) + from lib.mailer import render_template, send_mail + + body = render_template( + "system_test", + admin_email=admin, + default_from=from_addr, + host=state.get("smtp_host", ""), + api_base_url="(modo SMTP legado — não aplicável)", + timestamp=str(int(time.time())), + ) + subj = "[runv.club] Email de teste do sistema (SMTP legado)" + if dry_run: + log().info("[dry-run] enviaria teste para %s", admin) + return + send_mail(admin, subj, body, from_addr=from_addr, _state=state) + log().info("Email de teste enviado para %s", admin) + + +def prompt_yes_no(msg: str, default_no: bool = True) -> bool: + suf = " [s/N]: " if default_no else " [S/n]: " + r = input(msg + suf).strip().lower() + if not r: + return not default_no + return r in ("s", "sim", "y", "yes") + + +def prompt_line(msg: str, default: str = "") -> str: + d = f" [{default}]" if default else "" + r = input(f"{msg}{d}: ").strip() + return r if r else default + + +def interactive_config() -> dict[str, Any]: + print("\n=== LEGADO: Configuração SMTP (msmtp + sendmail) ===\n") + print("Nota: o caminho recomendado é Mailgun API (configure_mailgun.py).\n") + host = prompt_line("Host SMTP") + if not host: + raise ValueError("Host SMTP obrigatório.") + + port_s = prompt_line("Porta SMTP", "587") + port = int(port_s) if port_s.isdigit() else 587 + + tls_on = prompt_yes_no("Usar TLS (tls)?", default_no=False) + starttls_on = prompt_yes_no("Usar STARTTLS (tls_starttls)?", default_no=False) + auth_on = prompt_yes_no("Autenticação SMTP (usuário/senha)?", default_no=False) + + user = "" + if auth_on: + user = prompt_line("Utilizador SMTP (login)") + if not user: + raise ValueError("Com auth on, o utilizador SMTP é obrigatório.") + + default_from = prompt_line("Remetente padrão (From)") + if not default_from or "@" not in default_from: + raise ValueError("Remetente (From) deve ser um endereço de email válido.") + + admin_email = prompt_line("Email do administrador (notificações)") + if not admin_email or "@" not in admin_email: + raise ValueError("Email do admin inválido.") + + password = "" + if auth_on: + p1 = getpass("Senha ou token SMTP (não ecoa): ") + p2 = getpass("Repita a senha: ") + if p1 != p2: + raise ValueError("Senhas não coincidem.") + password = p1 + + return { + "smtp_host": host, + "smtp_port": port, + "tls_on": tls_on, + "starttls_on": starttls_on, + "auth_on": auth_on, + "smtp_user": user, + "smtp_password": password, + "default_from": default_from, + "admin_email": admin_email, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="LEGADO: instala msmtp/sendmail e configura SMTP runv.club.", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--verbose", "-v", action="store_true") + parser.add_argument("--force", "-f", action="store_true", help="sobrescrever sem perguntar") + parser.add_argument( + "--test", + action="store_true", + help="enviar apenas email de teste (requer config e %s)" % STATE_PATH, + ) + parser.add_argument("--skip-apt", action="store_true", help="não executar apt-get") + args = parser.parse_args() + + setup_logging(args.verbose) + require_root() + + try: + if args.test: + run_test_send(dry_run=args.dry_run) + print("Teste concluído.") + return 0 + + if not args.skip_apt: + apt_install(args.dry_run) + + touch_logfile(dry_run=args.dry_run) + install_passwordeval_script(dry_run=args.dry_run) + + cfg = interactive_config() + + if not confirm_overwrite(MSMPTRC_PATH, force=args.force): + print("Cancelado.") + return 1 + backup_if_exists(MSMPTRC_PATH, dry_run=args.dry_run, force=args.force) + + if cfg["auth_on"]: + if not cfg.get("smtp_password"): + raise ValueError("Com autenticação ligada, a senha/token é obrigatório.") + if not confirm_overwrite(NETRC_PATH, force=args.force): + print("Cancelado.") + return 1 + backup_if_exists(NETRC_PATH, dry_run=args.dry_run, force=args.force) + upsert_netrc_machine( + cfg["smtp_host"], + cfg["smtp_user"], + cfg["smtp_password"], + dry_run=args.dry_run, + ) + + mc = build_msmtprc( + host=cfg["smtp_host"], + port=int(cfg["smtp_port"]), + tls_on=bool(cfg["tls_on"]), + starttls_on=bool(cfg["starttls_on"]), + auth_on=bool(cfg["auth_on"]), + user=cfg["smtp_user"], + default_from=cfg["default_from"], + use_aliases=True, + ) + write_msmtprc(mc, dry_run=args.dry_run) + + if not confirm_overwrite(ALIASES_PATH, force=args.force): + print("Cancelado.") + return 1 + write_aliases(cfg["admin_email"], dry_run=args.dry_run) + + state_public: dict[str, Any] = { + "backend": "sendmail", + "provider": "smtp_msmtp", + "email_package_root": str(MODULE_ROOT), + "admin_email": cfg["admin_email"], + "default_from": cfg["default_from"], + "smtp_host": cfg["smtp_host"], + "smtp_port": cfg["smtp_port"], + } + write_state(state_public, dry_run=args.dry_run) + + if not args.dry_run and prompt_yes_no("\nEnviar email de teste agora?", default_no=True): + try: + run_test_send(dry_run=False) + log().info("Teste enviado.") + except Exception as e: + log().warning("Teste falhou (config pode estar correta mesmo assim): %s", e) + + print("\n=== Resumo (backend legado: SMTP / sendmail) ===") + print(f" msmtp: {MSMPTRC_PATH}") + print(f" aliases: {ALIASES_PATH}") + print(f" netrc: {NETRC_PATH} (credenciais — não partilhar)") + print(f" estado: {STATE_PATH}") + print(f" sendmail: /usr/sbin/sendmail (msmtp-mta)") + print("\nDocumentação: email/docs/INSTALL.md") + print("Teste posterior: sudo python3 email/configure_msmtp_legacy.py --test") + print("Mailgun (recomendado): sudo python3 email/configure_mailgun.py") + return 0 + + except (KeyboardInterrupt, EOFError): + print("\nInterrompido.", file=sys.stderr) + return 130 + except Exception as e: + log().error("%s", e) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/email/docs/ADMIN.md b/email/docs/ADMIN.md @@ -1,50 +1,52 @@ # Administração — email runv.club -## Alterar remetente padrão (From) +**Predefinição:** Mailgun HTTP API (`configure_mailgun.py`). Secção final: **legado SMTP/msmtp**. -1. Edite `/etc/msmtprc` na conta `runv`: linha `from ...`. -2. Actualize `/etc/runv-email.json` campo `default_from` (consistência com `--test` e documentação interna). -3. Valide com `sudo python3 configure_msmtp.py --test` ou envio manual via `mail`. - -Faça **cópia de segurança** antes: `sudo cp /etc/msmtprc /etc/msmtprc.bak.$(date +%s)`. +## Mailgun — alterar remetente (From) -## Alterar email do administrador +1. Edite `/etc/runv-email.json` — campo `default_from`. +2. O endereço deve estar autorizado no domínio Mailgun configurado. +3. Valide: `sudo python3 configure_mailgun.py --test`. -1. Edite `/etc/msmtp_aliases` — linhas `root:`, `cron:`, `default:` para o novo endereço. -2. Actualize `admin_email` em `/etc/runv-email.json`. -3. Actualize também `admin_email` em `/opt/runv/terminal/config.toml` se usar o fluxo **entre**. +**Não** coloque a API key neste ficheiro. -## Trocar host, porta ou TLS +## Mailgun — alterar email do administrador -1. Edite `/etc/msmtprc` (`host`, `port`, `tls`, `tls_starttls`, `user` se aplicável). -2. Se mudar o **hostname** SMTP, actualize `/root/.netrc`: - - a linha `machine` deve coincidir com o novo `host`; - - ou volte a correr `configure_msmtp.py` (com `--force`) para regenerar de forma coerente. +1. Edite `admin_email` em `/etc/runv-email.json`. +2. Actualize também `admin_email` em `/opt/runv/terminal/config.toml` se usar o fluxo **entre**. -## Credenciais +## Mailgun — rodar API key ou região -- Senha/token **só** em `/root/.netrc` (ou volte a correr `configure_msmtp.py` para reprompt seguro). -- **Nunca** coloque senhas em `/etc/runv-email.json` nem em `msmtprc` em claro. +1. Para nova key: edite `/etc/runv-email.secrets.json` (0600) **ou** defina `RUNV_MAILGUN_API_KEY` no ambiente do processo. +2. Para mudar domínio/região: edite `/etc/runv-email.json` (`mailgun_domain`, `mailgun_region`, `api_base_url` coerente: `https://api.mailgun.net` vs `https://api.eu.mailgun.net`). +3. Recomendado: voltar a correr `sudo python3 configure_mailgun.py --force` para prompts guiados. -## Reenviar email de teste +## Mailgun — reenviar teste ```bash -sudo python3 /caminho/runv-server/email/configure_msmtp.py --test +sudo python3 /caminho/runv-server/email/configure_mailgun.py --test ``` -Requer `/etc/runv-email.json` e configuração msmtp válida. +## Legado SMTP — alterar remetente (From) + +1. Edite `/etc/msmtprc` na conta `runv`: linha `from ...`. +2. Actualize `/etc/runv-email.json` campo `default_from`. +3. Valide com `sudo python3 configure_msmtp_legacy.py --test` ou envio via `mail`. + +## Legado SMTP — credenciais + +- Senha/token **só** em `/root/.netrc` (ou `configure_msmtp_legacy.py` com `--force`). +- **Nunca** coloque senhas em `/etc/runv-email.json` em claro. ## Integrar outros scripts -Ver [INTEGRATION.md](INTEGRATION.md). Resumo: definir `RUNV_EMAIL_ROOT` e usar `lib.mailer.send_mail` ou funções de template. +Ver [INTEGRATION.md](INTEGRATION.md). Resumo: `RUNV_EMAIL_ROOT` ou `email_package_root` no JSON; usar `lib.mailer.send_mail`. -## Aliases e limitações +## Aliases msmtp (só legado) -- **msmtp** expande aliases conforme o seu ficheiro `aliases` — útil para `mail root` redirecionar para o admin. -- Isto **não** substitui um servidor de correio completo: endereços locais fictícios só funcionam na medida em que o `mail`/pipeline os passa e o msmtp resolve via aliases. -- **`newaliases`** (estilo Sendmail) **não** actualiza este ficheiro. +- **msmtp** expande aliases — útil para `mail root` → admin. +- **`newaliases`** (estilo Sendmail) **não** actualiza `/etc/msmtp_aliases`. -## Log +## Log (legado) -- Ficheiro configurado em `msmtprc`: por defeito `/var/log/msmtp.log`. -- Permissões: criado pelo instalador; ajuste se necessário para rotação (logrotate). +- `/var/log/msmtp.log` quando usa msmtp. diff --git a/email/docs/INSTALL.md b/email/docs/INSTALL.md @@ -1,95 +1,112 @@ # Instalação — módulo email runv.club -Debian 13 (ou próximo). **Apenas envio** — SMTP externo via **msmtp**; interface **`/usr/sbin/sendmail`**. +**Aviso: o configurador predefinido foi feito para Mailgun.** Não embute credenciais, domínios nem chaves — tudo é pedido em tempo de configuração. -## Dependências +Debian 13 (ou próximo). **Apenas envio** — caminho predefinido **Mailgun HTTP API** (Basic Auth: utilizador `api`, palavra-passe = API key). O modo **SMTP/msmtp + sendmail** permanece disponível como **legado**, desativado por predefinição. -Instaladas automaticamente por `configure_msmtp.py`: +## O que o predefinido faz (Mailgun) -- `msmtp`, `msmtp-mta`, `ca-certificates`, `bsd-mailx` +- Grava metadados em **`/etc/runv-email.json`** (0600, root): domínio Mailgun, região `us` ou `eu`, URL base da API, remetente padrão, email do admin, tipo de chave, caminho da pasta `email/` do repositório (`email_package_root`), etc. **Sem API key neste ficheiro.** +- Grava segredos em **`/etc/runv-email.secrets.json`** (0600, root): apenas `mailgun_api_key`. **Não partilhar nem fazer backup deste ficheiro para repositórios públicos.** -**Porque `bsd-mailx` e não `mailutils`?** O meta-pacote `mailutils` no Debian **recomenda** `default-mta`, o que em instalações interativas pode puxar **Postfix ou Exim**. O objetivo aqui é **não** ter servidor de correio local — só um cliente que invoque `sendmail` (na prática msmtp). +### API key em variável de ambiente (opcional) -## Pré-requisitos +Em tempo de execução, **`RUNV_MAILGUN_API_KEY`** (se definida) **tem prioridade** sobre o ficheiro de segredos. Útil para systemd ou contentores; o estado público pode continuar a referir `api_key_source: file` — o runtime usa na mesma a env quando presente. -- Acesso **root** ao servidor. -- Conta SMTP relay (qualquer fornecedor — não é assumido no código). -- Firewall a permitir saída TCP para o host/porta SMTP. +### Mailgun: SMTP vs HTTP API -## Executar o instalador +- **Credenciais SMTP** do painel Mailgun são para clientes SMTP (ex.: msmtp); **não** são o mesmo fluxo que a API HTTP. +- A **HTTP API** usa autenticação **HTTP Basic**: username fixo **`api`**, password = **API key** (primary ou domain sending key). +- **US:** `https://api.mailgun.net/v3/<domínio>/messages` +- **EU:** `https://api.eu.mailgun.net/v3/<domínio>/messages` + +Escolha a região no painel Mailgun (conta EU vs US); o script **pergunta explicitamente** `us` ou `eu` — não adivinha em silêncio. + +### Obter uma API key + +1. Painel Mailgun → domínio → **Domain settings** / **Sending API keys**. +2. Preferir **domain sending key** (menor privilégio) se só precisar de enviar desse domínio; **primary API key** também funciona se tiver permissão de envio. + +## Executar o configurador (predefinido) ```bash cd /caminho/para/runv-server/email -sudo python3 configure_msmtp.py +sudo python3 configure_mailgun.py ``` -O script pergunta (de forma genérica): +No arranque é mostrado o aviso de que o script foi feito para Mailgun e **não** pré-configura credenciais. + +O script pergunta: -- host e porta SMTP; -- TLS e STARTTLS (sim/não); -- autenticação (sim/não), utilizador e senha/token (**não ecoa**); +- tipo de chave (domain sending vs primary); +- domínio de envio Mailgun (ex.: `mg.exemplo.com`); +- região da API: **`us`** ou **`eu`**; +- API key (**não ecoa**); - remetente padrão (From); -- email do administrador. +- email do administrador (notificações / teste); +- caminho da pasta **`email/`** do repositório (para importações, ex. fluxo `entre` — por omissão é a pasta onde está o script). -Gera (com backup se já existir): +## Ficheiros criados (Mailgun) | Ficheiro | Descrição | |----------|-----------| -| `/etc/msmtprc` | Conta `runv`, `default : runv`, log, aliases. **0600** root. | -| `/root/.netrc` | Entrada `machine <host>` com `login` e `password`. **0600** root. | -| `/etc/msmtp_aliases` | `root`, `cron`, `default` → email do admin. **0644** root. | -| `/etc/runv-email.json` | Metadados **sem segredos** (`admin_email`, `default_from`, host) para `--test`. **0600** root. | -| `/usr/local/lib/runv-email/netrc_password.py` | Helper para `passwordeval` ler a senha do `.netrc`. | +| `/etc/runv-email.json` | Metadados **sem** API key. **0600** root. | +| `/etc/runv-email.secrets.json` | `mailgun_api_key`. **0600** root. **World-readable proibido.** | -## Flags +## Flags (`configure_mailgun.py`) | Flag | Efeito | |------|--------| -| `--dry-run` | Mostra acções; não grava ficheiros nem apt (exceto prompts interactivos). | -| `--verbose` / `-v` | Log DEBUG. | -| `--force` / `-f` | Sobrescreve sem confirmar. | -| `--test` | Só envia [system_test.txt](../templates/system_test.txt) usando estado existente. | -| `--skip-apt` | Não corre `apt-get` (útil se pacotes já instalados). | +| `--dry-run` | Não grava ficheiros; mostra acções. | +| `--verbose` / `-v` | Log DEBUG (nunca inclui a API key). | +| `--force` / `-f` | Sobrescreve estado/segredos sem confirmar. | +| `--test` | Só envia `templates/system_test.txt` via **Mailgun API** (requer estado existente). | +| `--legacy-smtp` | Delega no configurador **SMTP/msmtp** (`configure_msmtp_legacy.py`). | -Exemplo de teste após configuração: +## Teste de envio (API) ```bash -sudo python3 configure_msmtp.py --test +sudo python3 configure_mailgun.py --test ``` -## Verificar `/etc/msmtprc` +Em caso de falha, mensagens típicas: -```bash -sudo ls -l /etc/msmtprc -sudo msmtp --version -# Conteúdo (sem partilhar publicamente): -# sudo cat /etc/msmtprc -``` +- **401 / 403** — API key inválida ou sem permissão para o domínio/região. +- **400** — payload inválido; From não autorizado no domínio; campos em falta. +- **404** — domínio errado ou URL/região incorreta (US vs EU). +- **Timeout / erro de rede** — DNS, firewall ou TLS. -Deve conter `tls_trust_file`, `account runv`, `account default : runv`, e se usar auth, `passwordeval` apontando para `/usr/local/lib/runv-email/netrc_password.py HOST`. +## Modo legado: SMTP + msmtp + sendmail -## Verificar `/root/.netrc` +Apenas se precisar de relay SMTP clássico: ```bash -sudo ls -l /root/.netrc # deve ser -rw------- root root +sudo python3 configure_mailgun.py --legacy-smtp +# ou directamente: +sudo python3 configure_msmtp_legacy.py ``` -A linha `machine` deve ser **exactamente** o mesmo hostname que o campo `host` no msmtprc (o helper recebe esse host como argumento). +Instala `msmtp`, `msmtp-mta`, `ca-certificates`, `bsd-mailx`, gera `/etc/msmtprc`, `/root/.netrc`, `/etc/msmtp_aliases`, e grava `/etc/runv-email.json` com **`backend: sendmail`**. + +**`configure_msmtp.py`** (sem `_legacy`) é apenas um **encaminhamento** com mensagem a indicar os comandos correctos. -## Verificar `sendmail` +## Verificação rápida (Mailgun) ```bash -ls -l /usr/sbin/sendmail -readlink -f /usr/sbin/sendmail +sudo ls -l /etc/runv-email.json /etc/runv-email.secrets.json +# Ambos devem ser -rw------- root root +sudo python3 configure_mailgun.py --test ``` -Deve resolver para o binário **msmtp** (pacote `msmtp-mta`). +Nunca imprima o conteúdo de `runv-email.secrets.json` em chats ou logs públicos. + +## Biblioteca Python (`lib/mailer.py`) -## Testar envio +Com **`backend: mailgun`** no estado, `send_mail` usa a API Mailgun (urllib, stdlib). Com **`backend: sendmail`** ou estado antigo só com `smtp_host`, usa `sendmail -t -i`. -1. `sudo python3 configure_msmtp.py --test` -2. Ou: `sudo sh scripts/send_test_mail.sh admin@seu-dominio` -3. Ou linha directa (Python), com `RUNV_EMAIL_ROOT`: +Defina **`RUNV_EMAIL_ROOT`** para a pasta `email/` ao importar em scripts (ou use `email_package_root` em `/etc/runv-email.json` — o fluxo `entre` tenta ambos). + +Exemplo: ```bash sudo RUNV_EMAIL_ROOT=/caminho/runv-server/email python3 -c " @@ -100,16 +117,13 @@ send_mail('voce@exemplo.com', 'Teste', 'Corpo.', from_addr='noreply@exemplo.com' " ``` -## Aliases msmtp - -O ficheiro `/etc/msmtp_aliases` usa o formato **msmtp** (`local: email@externo`). **Não** é o mesmo que aliases Sendmail; **`newaliases` não aplica** aqui. Qualquer alteração: editar o ficheiro e manter coerência com a directiva `aliases` no `msmtprc`. - -## Checklist pós-instalação +## Variáveis de ambiente úteis -- [ ] Pacotes `msmtp`, `msmtp-mta`, `ca-certificates`, `bsd-mailx` instalados. -- [ ] `/usr/sbin/sendmail` → msmtp. -- [ ] Permissões 600 em `/etc/msmtprc` e `/root/.netrc`. -- [ ] Email de teste recebido. -- [ ] [INTEGRATION.md](INTEGRATION.md) lido se for integrar com `entre` ou scripts admin. +| Variável | Uso | +|----------|-----| +| `RUNV_EMAIL_ROOT` | Caminho da pasta `email/` (import `lib.*`). | +| `RUNV_EMAIL_STATE_PATH` | Alternativa a `/etc/runv-email.json` (testes). | +| `RUNV_EMAIL_SECRETS_PATH` | Alternativa ao caminho de segredos indicado no estado. | +| `RUNV_MAILGUN_API_KEY` | API key em memória/ambiente (sobrepor ficheiro de segredos). | Próximo: [ADMIN.md](ADMIN.md) para operação corrente. diff --git a/email/docs/INTEGRATION.md b/email/docs/INTEGRATION.md @@ -1,5 +1,7 @@ # Integração — email com o resto do runv-server +**Predefinição:** envio via **Mailgun HTTP API** quando `/etc/runv-email.json` indica `backend: mailgun` (ou contém `mailgun_domain` + `mailgun_region` sem `backend: sendmail`). Caso contrário, `lib.mailer` usa **sendmail** (msmtp legado). + ## Variável de ambiente Defina **`RUNV_EMAIL_ROOT`** como caminho absoluto para a pasta **`email/`** do repositório (a que contém `lib/` e `templates/`). @@ -8,6 +10,8 @@ Defina **`RUNV_EMAIL_ROOT`** como caminho absoluto para a pasta **`email/`** do export RUNV_EMAIL_ROOT=/srv/runv-server/email ``` +O configurador Mailgun grava também **`email_package_root`** em `/etc/runv-email.json`. O fluxo **`entre`** usa esse campo (ou `RUNV_EMAIL_ROOT`) para importar `lib.mailer` e enviar via API quando Mailgun está activo. + Em Python, antes de importar: ```python @@ -25,103 +29,57 @@ from lib.mailer import ( from lib import templates as T ``` -**Nunca** use `shell=True` em `subprocess` para envio; a biblioteca já invoca `sendmail` com lista de argumentos. +**Nunca** use `shell=True` em `subprocess` para envio; a biblioteca usa urllib (Mailgun) ou `sendmail` com lista de argumentos. ## API resumida (`lib/mailer.py`) | Função | Uso | |--------|-----| | `render_template(nome, **kwargs)` | Lê `templates/<nome>.txt` e substitui `{placeholders}`. | -| `send_mail(to, subject, body, from_addr=..., sendmail=..., headers=...)` | Mensagem texto; `to` pode ser string ou lista. | -| `send_admin_notice(template, admin_email, subject=..., from_addr=..., **kwargs)` | Template → admin. | -| `send_user_notice(template, user_email, subject=..., from_addr=..., **kwargs)` | Template → utilizador. | +| `send_mail(to, subject, body, from_addr=..., html=..., sendmail=..., headers=..., _state=...)` | Texto; `html` opcional (Mailgun). `to` string ou lista. `_state` evita reler disco (testes / entre). | +| `send_admin_notice(..., html_body=...)` | Template → admin. | +| `send_user_notice(..., html_body=...)` | Template → utilizador. | -`sendmail` por defeito: `/usr/sbin/sendmail` (msmtp-mta). +Com **Mailgun**, `sendmail` é ignorado para o transporte (usa API). Com **legado**, `sendmail` por defeito: `/usr/sbin/sendmail`. ## Mapa evento → template → script | Evento | Template(s) | Onde disparar | |--------|-------------|----------------| -| Novo pedido na fila `entre` | `admin_new_request` → admin; opcional `user_request_received` → email do visitante | Após `save_request_json` em [`terminal/entre_core.py`](../../terminal/entre_core.py) / [`entre_app.py`](../../terminal/entre_app.py). **Hoje** só há email admin via `sendmail_notify` + `admin_mail.txt` — manter compatível ou migrar para templates deste módulo. | -| Pedido aprovado (manual) | `user_approved` | Processo admin / script que marca pedido aprovado. | +| Novo pedido na fila `entre` | `admin_new_request` → admin; opcional `user_request_received` → visitante | Após `save_request_json` em [`terminal/entre_core.py`](../../terminal/entre_core.py) / [`entre_app.py`](../../terminal/entre_app.py). **Hoje** email admin via `sendmail_notify` + `admin_mail.txt` — com Mailgun, `sendmail_notify` tenta **primeiro** a API se o estado global o indicar. | +| Pedido aprovado (manual) | `user_approved` | Processo admin. | | Pedido rejeitado | `user_rejected` (+ `reason`) | Idem. | -| Conta criada | `admin_user_created`, `user_account_created` | Final com sucesso de [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py). | -| Conta removida | `admin_user_deleted`, `user_account_removed` | Final de [`scripts/admin/del-user.py`](../../scripts/admin/del-user.py) (se tiver email em metadados para o utilizador). | -| Erro operacional | `admin_error` | Blocos `except` em scripts admin ou cron. | -| Quota | `user_quota_warning` | Monitorização de disco / `update_user` / quotas. | -| Teste | `system_test` | `configure_msmtp.py --test` ou scripts de CI manual. | +| Conta criada | `admin_user_created`, `user_account_created` | [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py). | +| Conta removida | `admin_user_deleted`, `user_account_removed` | [`scripts/admin/del-user.py`](../../scripts/admin/del-user.py). | +| Erro operacional | `admin_error` | Scripts admin / cron. | +| Quota | `user_quota_warning` | Monitorização / quotas. | +| Teste | `system_test` | `configure_mailgun.py --test` (API) ou legado. | ## Fluxo **entre** (terminal) -- **Comportamento actual:** [`entre_core.sendmail_notify`](../../terminal/entre_core.py) envia corpo já montado a partir de `templates/admin_mail.txt`, via `sendmail -t -i`. -- **Compatibilidade:** Depois de configurar este módulo, `/usr/sbin/sendmail` passa a ser o msmtp — **nenhuma alteração obrigatória** no código `entre` se `sendmail_path` em `config.toml` for `/usr/sbin/sendmail`. -- **Opcional:** Unificar com `send_admin_notice(T.ADMIN_NEW_REQUEST, ...)` e placeholders alinhados ao JSON do pedido — exige refactor pequeno em `entre_app.py` e testes. - -### Exemplo (opcional) — notificação admin com template do módulo +- **`entre_core.sendmail_notify`** tenta primeiro envio **Mailgun** se `/etc/runv-email.json` for compatível e se `email_package_root` ou `RUNV_EMAIL_ROOT` permitir importar `lib.mailer`. +- Se Mailgun não aplicável ou falhar o ramo API, usa **`sendmail -t -i`** como antes (requer msmtp-mta no modo legado). -```python -# Pseudocódigo: após gravação do pedido -send_admin_notice( - T.ADMIN_NEW_REQUEST, - admin_email, - subject="[runv.club] Novo pedido", - from_addr=mail_from, - request_id=request_id, - timestamp=..., - username=username, - email=email, - fingerprint=fingerprint, -) -``` +### Coerência de configuração -Para email ao **visitante** (`user_request_received`), é preciso endereço válido do utilizador (já recolhido no fluxo). - -## `create_runv_user.py` - -Após criação bem-sucedida da conta (e sabendo `email` metadado e `admin_email` de config ou estado): - -```python -send_admin_notice( - T.ADMIN_USER_CREATED, - admin_email, - subject="[runv.club] Conta criada", - from_addr=default_from, - username=username, - email=user_email, - operator_info="create_runv_user.py", - timestamp=str(int(time.time())), -) -send_user_notice( - T.USER_ACCOUNT_CREATED, - user_email, - subject="[runv.club] A sua conta", - from_addr=default_from, - username=username, - email=user_email, -) -``` - -Obtenha `admin_email` / `default_from` de `/etc/runv-email.json` ou de variáveis de ambiente definidas pelo operador — **não** hardcodar. - -## `del-user.py` +| Ficheiro | Campos | +|----------|--------| +| `/etc/runv-email.json` | `backend`, `admin_email`, `default_from`, Mailgun (`mailgun_domain`, …) ou SMTP (`smtp_host`, …), `email_package_root`. | +| `/opt/runv/terminal/config.toml` | `admin_email`, `mail_from`, `sendmail_path` — fluxo entre. | -Após remoção bem-sucedida: +Recomenda-se o **mesmo** `admin_email` e remetente coerente com o Mailgun/domínio verificado. -- `send_admin_notice(T.ADMIN_USER_DELETED, ...)` -- Se existir email de contacto em metadados: `send_user_notice(T.USER_ACCOUNT_REMOVED, ...)`. +## `create_runv_user.py` / `del-user.py` -## Configuração paralela com `entre` +O **`create_runv_user.py`** envia por omissão um email de **boas-vindas** ao utilizador (`user_account_created`), com instruções para aceder por SSH com a **chave privada** correspondente à chave pública registada. Requer `/etc/runv-email.json` e módulo `email/` acessível; `--no-welcome-email` para desactivar; `--welcome-ssh-host` ou `RUNV_WELCOME_SSH_HOST` para um comando `ssh` explícito. -| Ficheiro | Campos | -|----------|--------| -| `/etc/runv-email.json` | `admin_email`, `default_from` — estado global runv. | -| `/opt/runv/terminal/config.toml` | `admin_email`, `mail_from`, `sendmail_path` — fluxo entre. | +Obtenha `admin_email` / `default_from` de `/etc/runv-email.json` — **não** hardcodar. -Recomenda-se manter **o mesmo** `admin_email` e remetente coerente entre ambos. +Ver exemplos na versão anterior deste documento para `send_admin_notice` / `send_user_notice` adicionais. ## Checklist de integração -- [ ] `RUNV_EMAIL_ROOT` definido em cron/systemd que invoque scripts Python. -- [ ] `sendmail` = msmtp testado com `configure_msmtp.py --test`. +- [ ] `RUNV_EMAIL_ROOT` ou `email_package_root` correcto para serviços Python e **entre**. +- [ ] `sudo python3 configure_mailgun.py --test` (ou legado) com sucesso. - [ ] Templates revistos (português, placeholders). -- [ ] Nenhum segredo em logs ou `print()`. +- [ ] Nenhum segredo em logs ou `print()` (API key só em ficheiro 0600 ou env). diff --git a/email/docs/TROUBLESHOOTING.md b/email/docs/TROUBLESHOOTING.md @@ -1,53 +1,62 @@ # Resolução de problemas — email runv.club -## Autenticação SMTP falha +## Mailgun API — 401 / 403 -- Confirme `user` no `msmtprc` e `login` no `.netrc` para o mesmo `machine <host>` que o **host** SMTP. -- Teste credenciais com outro cliente (mesmo host/porta/TLS) para isolar. -- Ver `/var/log/msmtp.log` (sem publicar conteúdo com dados sensíveis). +- API key errada ou revogada; ou key sem permissão para o **domínio** indicado. +- Confirme região **US vs EU** (URL base no JSON deve coincidir com a conta). -## TLS / STARTTLS a falhar +## Mailgun API — 400 -- Combinações típicas: porta **587** + `tls on` + `tls_starttls on`; porta **465** muitas vezes `tls on` + `tls_starttls off`. -- Confirme `tls_trust_file /etc/ssl/certs/ca-certificates.crt` e pacote `ca-certificates` instalado. +- `From` não autorizado para o domínio; campos obrigatórios em falta; domínio não verificado no Mailgun. -## Erro de certificado +## Mailgun API — 404 -- Relógio do sistema correcto (`timedatectl`). -- Se o servidor usar certificado não padrão, a política de msmtp pode exigir ajuste (documentação msmtp — fora do âmbito normal runv). +- Domínio incorrecto no path `/v3/.../messages` ou região trocada (US/EU). -## `sendmail` não encontrado +## Mailgun — timeout / rede -- Instale `msmtp-mta`: `apt-get install -y msmtp-mta`. -- Verifique `ls -l /usr/sbin/sendmail`. +- Firewall de saída, DNS, ou problemas TLS. Teste conectividade HTTPS ao host `api.mailgun.net` ou `api.eu.mailgun.net`. -## `mail` não funciona +## Mailgun — `entre` não envia -- Instale `bsd-mailx` (não confundir com ausência total de `mail`). -- Sem `sendmail` funcional, `mail` também falha. +- Confirme `email_package_root` em `/etc/runv-email.json` aponta para a pasta `email/` do deploy **ou** defina `RUNV_EMAIL_ROOT` no ambiente do serviço. +- Confirme `backend` é `mailgun` (ou domínio+região presentes sem `backend: sendmail`). -## Template ausente (`lib/mailer.py`) +## Legado — autenticação SMTP falha + +- Confirme `user` no `msmtprc` e `login` no `.netrc` para o mesmo `machine <host>` que o **host** SMTP. +- Ver `/var/log/msmtp.log` (sem publicar dados sensíveis). + +## Legado — TLS / STARTTLS -- Defina `RUNV_EMAIL_ROOT` para a pasta **`email/`** do repositório (que contém `templates/`). -- Ou execute scripts a partir da árvore completa do repositório. +- Porta **587** + `tls on` + `tls_starttls on`; **465** muitas vezes `tls on` + `tls_starttls off`. +- Confirme `ca-certificates` instalado. -## Permissões em `/root/.netrc` +## `sendmail` não encontrado (modo legado) + +- Instale `msmtp-mta`: `apt-get install -y msmtp-mta`. +- Em modo **Mailgun**, `sendmail` não é necessário para `lib.mailer.send_mail`. + +## `mail` não funciona (legado) + +- Instale `bsd-mailx`. + +## Template ausente (`lib/mailer.py`) -- Deve ser **600** e dono **root**. Corrigir: `sudo chmod 600 /root/.netrc && sudo chown root:root /root/.netrc`. +- Defina `RUNV_EMAIL_ROOT` para a pasta **`email/`** do repositório. -## Permissões em `/etc/msmtprc` +## Permissões em `/root/.netrc` (legado) -- Recomendado **600** root. `msmtp` em modo system-wide exige que o ficheiro não seja legível por utilizadores não privilegiados. +- **600**, root. `sudo chmod 600 /root/.netrc && sudo chown root:root /root/.netrc`. -## `passwordeval` / `netrc_password.py` +## Permissões em segredos Mailgun -- Deve existir `/usr/local/lib/runv-email/netrc_password.py` executável. -- Reinstale com `configure_msmtp.py` ou copie manualmente desde `email/scripts/netrc_password.py`. +- `/etc/runv-email.secrets.json` deve ser **0600** root. Nunca world-readable. -## Senha com caracteres especiais no `.netrc` +## `passwordeval` / `netrc_password.py` (legado) -- O formato `.netrc` clássico **não** trata bem todos os caracteres; senhas muito complexas podem exigir escape ou outro método (ver documentação netrc). Em caso de dúvida, use token SMTP dedicado com caracteres seguros para ficheiros texto. +- `/usr/local/lib/runv-email/netrc_password.py` — reinstalar com `configure_msmtp_legacy.py`. ## `--test` diz que falta estado -- Corra primeiro `configure_msmtp.py` sem `--test` para criar `/etc/runv-email.json`. +- Corra primeiro `configure_mailgun.py` (ou `configure_msmtp_legacy.py` no modo SMTP) **sem** `--test` para criar `/etc/runv-email.json`. diff --git a/email/lib/mailer.py b/email/lib/mailer.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """ -Envio de email via interface sendmail compatível (msmtp-mta). +Envio de correio: Mailgun por HTTP por defeito; se não houver estado, cai para sendmail/msmtp. -Sem shell=True. Sem dependências PyPI — apenas stdlib. +Stdlib só; nada de shell=True. """ from __future__ import annotations +import json +import logging import os import subprocess from email.message import EmailMessage @@ -14,9 +16,17 @@ from email.utils import formataddr from pathlib import Path from typing import Mapping, Sequence -from . import templates as T +from .mailgun_client import ( + MailgunHTTPError, + build_mailgun_runtime_config, + format_mailgun_failure, + load_public_state, + send_via_mailgun_api, + state_path, +) _DEFAULT_SENDMAIL = "/usr/sbin/sendmail" +_LOG = logging.getLogger("runv.mailer") def _email_root() -> Path: @@ -31,10 +41,7 @@ def templates_dir() -> Path: def render_template(name: str, **kwargs: object) -> str: - """ - Lê templates/<name>.txt e substitui {chaves} pelos kwargs. - Chaves em falta deixam o placeholder visível (não falha silenciosamente). - """ + """Lê ``templates/<name>.txt`` e faz ``.format``. Placeholder sem valor fica lá à mostra.""" base = name.removesuffix(".txt") path = templates_dir() / f"{base}.txt" if not path.is_file(): @@ -47,26 +54,84 @@ def render_template(name: str, **kwargs: object) -> str: raise KeyError(f"placeholder em falta no template {name}: {e}") from e +def _resolve_backend( + injected: dict | None, + *, + sendmail: str | None, +) -> tuple[str, dict]: + """Tuple (mailgun|sendmail, dict lido de runv-email.json ou injectado).""" + if injected is not None: + state = injected + else: + sp = state_path() + if not sp.is_file(): + return "sendmail", {} + state = json.loads(sp.read_text(encoding="utf-8")) + + be = str(state.get("backend") or "").strip().lower() + if be == "mailgun": + return "mailgun", state + if be == "sendmail": + return "sendmail", state + if state.get("smtp_host"): # json velho do configure_msmtp + return "sendmail", state + if state.get("mailgun_domain") and state.get("mailgun_region"): # mailgun sem campo backend + return "mailgun", state + return "sendmail", state + + def send_mail( to_addrs: str | Sequence[str], subject: str, body: str, *, from_addr: str, - sendmail: str = _DEFAULT_SENDMAIL, + sendmail: str | None = None, + html: str | None = None, headers: Mapping[str, str] | None = None, timeout: int = 120, + _state: dict | None = None, ) -> None: - """ - Envia mensagem texto puro via sendmail -t -i. - - :param to_addrs: um endereço ou lista de endereços (cabeçalho To). - :raises FileNotFoundError: sendmail inexistente. - :raises RuntimeError: sendmail devolveu código != 0. - """ - sm = Path(sendmail) + """Mailgun se o estado pedir; senão ``sendmail -t -i``. ``html`` só interessa mesmo no ramo Mailgun.""" + sm_path = sendmail if sendmail is not None else _DEFAULT_SENDMAIL + backend, st = _resolve_backend(_state, sendmail=sendmail) + + if backend == "mailgun": + try: + pub = st + if not pub: + pub = load_public_state() + cfg = build_mailgun_runtime_config(pub) + except FileNotFoundError: + raise + except Exception as e: + raise RuntimeError(f"configuração Mailgun inválida: {e}") from e + + try: + code, _raw = send_via_mailgun_api( + base_url=cfg["api_base_url"], + domain=cfg["domain"], + api_key=cfg["api_key"], + from_addr=from_addr, + to_addrs=to_addrs, + subject=subject, + text=body, + html=html, + timeout=timeout, + ) + _LOG.debug("mailgun envio OK status=%s", code) + except MailgunHTTPError as e: + msg = format_mailgun_failure(e.status, e.body_snippet) + raise RuntimeError(msg) from e + return + + # --- sendmail --- + sm = Path(sm_path) if not sm.is_file(): - raise FileNotFoundError(f"sendmail não encontrado: {sendmail}") + raise FileNotFoundError( + f"sendmail não encontrado: {sm_path} " + f"(modo legado). Configure Mailgun com configure_mailgun.py ou instale msmtp-mta.", + ) if isinstance(to_addrs, str): recipients: list[str] = [to_addrs.strip()] @@ -86,6 +151,8 @@ def send_mail( continue msg[k] = v msg.set_content(body, subtype="plain", charset="utf-8") + if html: + msg.add_alternative(html, subtype="html", charset="utf-8") try: proc = subprocess.run( @@ -112,7 +179,8 @@ def send_admin_notice( *, subject: str, from_addr: str, - sendmail: str = _DEFAULT_SENDMAIL, + sendmail: str | None = None, + html_body: str | None = None, **kwargs: object, ) -> None: """Renderiza template administrativo e envia para admin_email.""" @@ -123,6 +191,7 @@ def send_admin_notice( body, from_addr=from_addr, sendmail=sendmail, + html=html_body, ) @@ -132,7 +201,8 @@ def send_user_notice( *, subject: str, from_addr: str, - sendmail: str = _DEFAULT_SENDMAIL, + sendmail: str | None = None, + html_body: str | None = None, **kwargs: object, ) -> None: """Renderiza template para utilizador e envia para user_email.""" @@ -143,6 +213,7 @@ def send_user_notice( body, from_addr=from_addr, sendmail=sendmail, + html=html_body, ) diff --git a/email/lib/mailgun_client.py b/email/lib/mailgun_client.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +Cliente HTTP Mailgun (API de envio) — stdlib apenas. + +Basic Auth: utilizador fixo ``api``, palavra-passe = API key. +Documentação: https://documentation.mailgun.com/en/latest/api-sending.html +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import ssl +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Final, Mapping, Sequence + +DEFAULT_STATE_PATH = Path("/etc/runv-email.json") +DEFAULT_SECRETS_PATH = Path("/etc/runv-email.secrets.json") + +# Placeholder neutro para testes/documentação — nunca credenciais reais. +EXAMPLE_DOMAIN: Final[str] = "example.com" + +_REGIONS: Final[frozenset[str]] = frozenset({"us", "eu"}) + +# Domínio verificável: hostname ou subdomínio típico Mailgun (mg.example.com) +_DOMAIN_RE: Final[re.Pattern[str]] = re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$" +) + + +class MailgunConfigError(ValueError): + """Configuração inválida ou incompleta.""" + + +class MailgunHTTPError(RuntimeError): + """Resposta HTTP não-sucesso da API Mailgun.""" + + def __init__(self, message: str, *, status: int, body_snippet: str) -> None: + super().__init__(message) + self.status = status + self.body_snippet = body_snippet + + +def mailgun_base_url(region: str) -> str: + """ + URL base da API (sem ``/v3/...``) para a região escolhida. + ``region`` deve ser ``us`` ou ``eu`` (minúsculas). + """ + r = region.strip().lower() + if r not in _REGIONS: + raise MailgunConfigError(f"região inválida: {region!r} (use 'us' ou 'eu')") + if r == "eu": + return "https://api.eu.mailgun.net" + return "https://api.mailgun.net" + + +def build_mailgun_messages_url(*, base_url: str, domain: str) -> str: + """URL completa ``POST .../v3/{domain}/messages``.""" + b = base_url.rstrip("/") + d = domain.strip().lower() + if not d: + raise MailgunConfigError("domínio Mailgun vazio") + return f"{b}/v3/{urllib.parse.quote(d, safe='.')}/messages" + + +def mask_secret(value: str | None, *, visible_tail: int = 4) -> str: + """Mascara segredos para logs ou mensagens de diagnóstico.""" + if value is None: + return "(não definido)" + s = value.strip() + if not s: + return "(vazio)" + if len(s) <= visible_tail + 3: + return "***" + return s[:3] + "…" + s[-visible_tail:] + + +def validate_mailgun_inputs( + *, + domain: str, + region: str, + from_addr: str, + admin_email: str, + api_key: str, +) -> dict[str, str]: + """ + Valida entradas interactivas / ficheiro. Devolve dict normalizado + (domain, region, from_addr, admin_email) — não devolve a key. + """ + core = validate_mailgun_send_fields( + domain=domain, + region=region, + from_addr=from_addr, + api_key=api_key, + ) + ad = admin_email.strip() + if not ad or "@" not in ad: + raise MailgunConfigError("email do administrador inválido.") + return {**core, "admin_email": ad} + + +def validate_mailgun_send_fields( + *, + domain: str, + region: str, + from_addr: str, + api_key: str, +) -> dict[str, str]: + """Valida domínio, região, From e API key (envio em tempo de execução).""" + d = domain.strip().lower() + if not d: + raise MailgunConfigError("domínio de envio obrigatório (não pode estar vazio).") + if not _DOMAIN_RE.match(d): + raise MailgunConfigError( + f"domínio inválido: {domain!r} — use um hostname FQDN (ex.: {EXAMPLE_DOMAIN}).", + ) + + r = region.strip().lower() + mailgun_base_url(r) # valida região + + fa = from_addr.strip() + if not fa or "@" not in fa: + raise MailgunConfigError("remetente (From) deve ser um endereço de email válido.") + + key = api_key.strip() + if not key: + raise MailgunConfigError("API key Mailgun obrigatória (não pode estar vazia).") + + return { + "domain": d, + "region": r, + "from_addr": fa, + } + + +def state_path() -> Path: + raw = os.environ.get("RUNV_EMAIL_STATE_PATH", "").strip() + return Path(raw) if raw else DEFAULT_STATE_PATH + + +def secrets_path_from_state(public: Mapping[str, Any]) -> Path: + raw = str(public.get("secrets_path") or "").strip() + if raw: + return Path(raw) + raw_env = os.environ.get("RUNV_EMAIL_SECRETS_PATH", "").strip() + if raw_env: + return Path(raw_env) + return DEFAULT_SECRETS_PATH + + +def load_public_state(path: Path | None = None) -> dict[str, Any]: + p = path or state_path() + if not p.is_file(): + raise FileNotFoundError( + f"Estado de email não encontrado: {p}. Execute o configurador Mailgun.", + ) + return json.loads(p.read_text(encoding="utf-8")) + + +def load_mailgun_api_key(public: Mapping[str, Any]) -> tuple[str, str]: + """ + Carrega API key. Ordem: ``RUNV_MAILGUN_API_KEY``, depois ficheiro de segredos. + Devolve (api_key, fonte_descritiva) — fonte nunca contém a key. + """ + env_key = os.environ.get("RUNV_MAILGUN_API_KEY", "").strip() + if env_key: + return env_key, "environment" + + sp = secrets_path_from_state(public) + if not sp.is_file(): + raise MailgunConfigError( + f"API key em falta: defina RUNV_MAILGUN_API_KEY ou crie {sp} (0600) com mailgun_api_key.", + ) + try: + sec = json.loads(sp.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise MailgunConfigError(f"ficheiro de segredos JSON inválido: {sp}: {e}") from e + key = str(sec.get("mailgun_api_key", "")).strip() + if not key: + raise MailgunConfigError(f"mailgun_api_key vazio em {sp}") + return key, f"file:{sp}" + + +def build_mailgun_runtime_config(public: Mapping[str, Any]) -> dict[str, Any]: + """Junta estado público + key (em memória) para envio.""" + if public.get("backend") == "sendmail": + raise MailgunConfigError("estado explícito backend=sendmail — não usar Mailgun") + domain = str(public.get("mailgun_domain", "")).strip() + region = str(public.get("mailgun_region", "")).strip().lower() + default_from = str(public.get("default_from", "")).strip() + api_key, _src = load_mailgun_api_key(public) + base = str(public.get("api_base_url") or mailgun_base_url(region)) + validate_mailgun_send_fields( + domain=domain, + region=region, + from_addr=default_from, + api_key=api_key, + ) + return { + "domain": domain, + "region": region, + "api_base_url": base, + "default_from": default_from, + "api_key": api_key, + } + + +def send_via_mailgun_api( + *, + base_url: str, + domain: str, + api_key: str, + from_addr: str, + to_addrs: str | Sequence[str], + subject: str, + text: str, + html: str | None = None, + timeout: int = 120, +) -> tuple[int, str]: + """ + POST application/x-www-form-urlencoded para ``/v3/{domain}/messages``. + + :return: (status_code, body_text) em sucesso 200. + :raises MailgunHTTPError: status não 2xx. + :raises MailgunConfigError: destinatários vazios. + """ + if isinstance(to_addrs, str): + recipients = [to_addrs.strip()] + else: + recipients = [a.strip() for a in to_addrs if a and str(a).strip()] + if not recipients: + raise MailgunConfigError("lista de destinatários vazia") + + url = build_mailgun_messages_url(base_url=base_url, domain=domain) + pairs: list[tuple[str, str]] = [ + ("from", from_addr), + ("subject", subject), + ("text", text), + ] + for a in recipients: + pairs.append(("to", a)) + if html: + pairs.append(("html", html)) + + body = urllib.parse.urlencode(pairs).encode("utf-8") + token = base64.b64encode(f"api:{api_key}".encode("utf-8")).decode("ascii") + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Authorization", f"Basic {token}") + req.add_header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + + ctx = ssl.create_default_context() + try: + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + raw = resp.read().decode("utf-8", errors="replace") + return resp.getcode() or 200, raw + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") if e.fp else "" + snippet = err_body[:500].strip() + raise MailgunHTTPError( + f"Mailgun HTTP {e.code}", + status=e.code, + body_snippet=snippet, + ) from None + except urllib.error.URLError as e: + reason = getattr(e, "reason", e) + raise MailgunConfigError(f"rede/SSL ao contactar Mailgun: {reason}") from e + except TimeoutError as e: + raise MailgunConfigError("timeout ao contactar API Mailgun") from e + + +def format_mailgun_failure(status: int, body_snippet: str) -> str: + """Mensagem legível para operadores (sem expor segredos).""" + base = f"HTTP {status}" + if status in (401, 403): + return f"{base}: API key inválida ou sem permissão para este domínio/região." + if status == 400: + return f"{base}: pedido inválido — verifique domínio, From autorizado e campos obrigatórios. Resposta: {body_snippet[:200]}" + if status == 404: + return f"{base}: domínio ou URL/região incorretos (confirme US vs EU e o domínio no painel Mailgun). Resposta: {body_snippet[:200]}" + if status >= 500: + return f"{base}: erro no serviço Mailgun. Tente mais tarde. Resposta: {body_snippet[:200]}" + return f"{base}: {body_snippet[:300]}" diff --git a/email/templates/system_test.txt b/email/templates/system_test.txt @@ -4,7 +4,8 @@ Este é um email automático de verificação do subsistema de envio. Administrador: {admin_email} Remetente configurado: {default_from} -Host SMTP registado: {host} +Domínio / host registado: {host} +URL base API (se Mailgun): {api_base_url} Timestamp UNIX: {timestamp} -Se recebeu esta mensagem, msmtp/sendmail e a biblioteca estão operacionais. +Se recebeu esta mensagem, o backend configurado (Mailgun API ou SMTP legado) e a biblioteca estão operacionais. diff --git a/email/templates/user_account_created.txt b/email/templates/user_account_created.txt @@ -1,12 +1,24 @@ -runv.club — a sua conta foi criada +runv.club — bem-vindo(a) Olá {username}, A sua conta no runv.club foi criada. -Email associado (metadado administrativo): {email} +O endereço {email} é o contacto que temos em registo (metadado administrativo). -Instruções de acesso (SSH, palavra-passe inicial, etc.) seguem a política do servidor — consulte a documentação ou a mensagem que lhe foi enviada separadamente. +Acesso por SSH +---------------- +Para entrar no servidor, utilize a **chave privada OpenSSH** que corresponde à chave pública que registou. +Nunca envie nem partilhe essa chave privada com terceiros. + +Impressão digital da chave pública no nosso sistema: {fingerprint} + +{ssh_instructions} + +Espaço web +---------- +A sua página de membro deverá estar disponível em: +{member_url} Cumprimentos, Equipa runv.club diff --git a/email/tests/test_mailgun_client.py b/email/tests/test_mailgun_client.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Testes unitários — cliente Mailgun (stdlib).""" + +from __future__ import annotations + +import unittest + +from lib.mailgun_client import ( + MailgunConfigError, + build_mailgun_messages_url, + mailgun_base_url, + mask_secret, + validate_mailgun_inputs, + validate_mailgun_send_fields, +) + + +class TestMailgunBaseUrl(unittest.TestCase): + def test_us(self) -> None: + self.assertEqual(mailgun_base_url("us"), "https://api.mailgun.net") + + def test_eu(self) -> None: + self.assertEqual(mailgun_base_url("eu"), "https://api.eu.mailgun.net") + + def test_region_case_insensitive(self) -> None: + self.assertEqual(mailgun_base_url("EU"), "https://api.eu.mailgun.net") + + def test_invalid_region(self) -> None: + with self.assertRaises(MailgunConfigError): + mailgun_base_url("ap") + + +class TestBuildMessagesUrl(unittest.TestCase): + def test_build(self) -> None: + u = build_mailgun_messages_url( + base_url="https://api.mailgun.net", + domain="mg.example.com", + ) + self.assertEqual(u, "https://api.mailgun.net/v3/mg.example.com/messages") + + +class TestMaskSecret(unittest.TestCase): + def test_none(self) -> None: + self.assertIn("não definido", mask_secret(None)) + + def test_short(self) -> None: + self.assertEqual(mask_secret("ab"), "***") + + def test_long(self) -> None: + m = mask_secret("key-abcdefghijklmnopqrstuvwxyz") + self.assertNotIn("abcdefghijklmnopqrstuvwxyz", m) + self.assertTrue(m.startswith("key")) + + +class TestValidate(unittest.TestCase): + def test_send_fields_ok(self) -> None: + r = validate_mailgun_send_fields( + domain="mg.example.com", + region="us", + from_addr="hi@example.com", + api_key="secret", + ) + self.assertEqual(r["domain"], "mg.example.com") + + def test_empty_domain(self) -> None: + with self.assertRaises(MailgunConfigError): + validate_mailgun_send_fields( + domain="", + region="us", + from_addr="a@b.co", + api_key="k", + ) + + def test_full_inputs_admin(self) -> None: + r = validate_mailgun_inputs( + domain="example.com", + region="eu", + from_addr="from@example.com", + admin_email="admin@example.com", + api_key="x", + ) + self.assertEqual(r["admin_email"], "admin@example.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/patches/patch_irc.py b/patches/patch_irc.py @@ -12,7 +12,8 @@ bind mount em ``/var/gemini/users/<user>`` nem entram no menu Gopher/Gemini raiz MOTD e runv-help referem apenas **chat** (sem expor outros nomes de comando ao utilizador). -Executar como root no Debian. Ver scripts/docs/irc_patch.md. +Executar como root no Debian; detalhes em scripts/docs/irc_patch.md. +SASL/NickServ: ver constante ``SASL_WEECHAT_SNIPPETS`` e https://weechat.org/doc/ Versão 0.03 — runv.club """ @@ -31,15 +32,13 @@ import sys from pathlib import Path from typing import Final -# --------------------------------------------------------------------------- -# SASL / NickServ (futuro) -# --------------------------------------------------------------------------- -# Não gravar senhas em texto plano. Para SASL, usar depois comandos WeeChat + dados -# seguros (sec.conf), por exemplo: -# /set irc.server.<name>.sasl_mechanism plain -# /secure set runv_irc_senha ... -# /set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}" -# Documentação: https://weechat.org/doc/ +# SASL ainda não entra no patch; quando entrar, é na mão no WeeChat com sec.data (nada de +# password em claro neste repo). Isto é só o boneco dos comandos, para não ir buscar à memória. +SASL_WEECHAT_SNIPPETS: Final[tuple[str, ...]] = ( + "/set irc.server.<name>.sasl_mechanism plain", + "/secure set runv_irc_senha ...", + '/set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}"', +) VERSION: Final[str] = "0.03" diff --git a/patches/patch_permissions.py b/patches/patch_permissions.py @@ -1,356 +0,0 @@ -#!/usr/bin/env python3 -""" -runv.club — privacidade em /home e drop-in SSH para confinamento (ChrootDirectory). - -Dois níveis (resumo do modelo POSIX/OpenSSH): - -1. **Privacidade entre utilizadores** — não impede «cd ..»; apenas impede listar/entrar nas - homes alheias: ``chmod 711 /home`` e ``chmod 700`` em cada ``/home/<user>``. - **Incompatível com hospedagem runv em** ``public_html`` / ``public_gopher`` / - ``public_gemini``: serviços (Apache, gophernicus, molly-brown) precisam de **atravessar** - a home (mínimo ``o+x``, política runv ``755``). Após ``chmod 700`` por utilizador, - ``setup_alt_protocols`` (backfill) ou ``create_runv_user`` repõem ``755`` na home se - correr o fluxo de provisionamento; não misture este patch com ``public_*`` sem saber - o trade-off. - -2. **Confinamento real** — ``Match Group`` + ``ChrootDirectory /srv/jail/%u``; o caminho do - chroot e ascendentes devem ser **root-owned** e não graváveis por outros (requisito do - sshd). ``rbash`` sozinho não substitui jail/container. - -Este script **não** constrói um jail completo (libs, /dev, etc.); aplica ou documenta o -drop-in e as permissões de /home. Debian 13 · Python 3 stdlib · sem shell=True. - -Executar como root em produção. Ver ``--help``. -""" - -from __future__ import annotations - -import argparse -import grp -import os -import pwd -import shutil -import subprocess -import sys -import time -from pathlib import Path -from typing import Final - -VERSION: Final[str] = "0.02" - -GROUP_NAME: Final[str] = "runv-jailed" -SSHD_DROPIN: Final[str] = "/etc/ssh/sshd_config.d/runv-jailed.conf" -HOME_ROOT: Final[str] = "/home" -JAIL_ROOT: Final[str] = "/srv/jail" - -SSHD_BLOCK: Final[str] = f"""# runv.club — grupo {GROUP_NAME}: shell dentro de ChrootDirectory -# Requisitos sshd: {JAIL_ROOT}/<user> e todos os ascendentes owned por root, sem escrita -# para grupo/outros; dentro do jail é preciso árvore mínima executável (ex. /bin/sh). -# Validar: sshd -t && systemctl reload ssh - -Match Group {GROUP_NAME} - ChrootDirectory {JAIL_ROOT}/%u - ForceCommand /bin/sh - X11Forwarding no - AllowTcpForwarding no - AllowAgentForwarding no - PermitTunnel no - DisableForwarding yes -""" - -CHROOT_NOTES: Final[str] = """ -=== ChrootDirectory (OpenSSH) — notas === - -- «cd ..» com permissões Unix normais não se «proíbe»; ou restringe-se visibilidade - (r/x em diretórios) ou usa-se confinamento real (chroot, container, zone). - -- ChrootDirectory exige que o directório do chroot e **todos** os componentes do caminho - até à raiz sejam propriedade de root e **não** graváveis por grupo nem outros. - -- Não use ChrootDirectory apontando para a home do próprio utilizador se essa home for - dele e gravável — o sshd rejeita ou quebra o modelo de segurança. - -- Layout típico para utilizador «alice»: - - /srv/jail/alice root:root 0755 (raiz do chroot) - /srv/jail/alice/home alice:alice 0700 (área gravável; cd ~) - - O utilizador em passwd pode continuar a ter home «/home/alice» no sistema real, mas - dentro do chroot o shell vê a raiz em /srv/jail/alice; costuma montar-se ou replicar-se - binários, libs e dispositivos mínimos sob /srv/jail/alice (ou usar abordagem com - container em vez de chroot «manual»). - -- Quem **não** estiver no grupo runv-jailed não recebe este Match e mantém sessão normal - (ex.: administrador com conta fora do grupo). - -- rbash não é substituto de jail; ver manual do Bash (restricted shell). -""" - - -def eprint(msg: str) -> None: - print(msg, file=sys.stderr) - - -def require_root() -> None: - if os.geteuid() != 0: - eprint("Execute como root (sudo).") - raise SystemExit(1) - - -def run(cmd: list[str], *, timeout: int = 120) -> None: - r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) - if r.returncode != 0: - err = (r.stderr or r.stdout or "").strip() - raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") - - -def sshd_main_config_mentions_dropin() -> bool: - main = Path("/etc/ssh/sshd_config") - if not main.is_file(): - return False - try: - text = main.read_text(encoding="utf-8", errors="replace") - except OSError: - return False - return "sshd_config.d" in text and "Include" in text - - -def apply_sshd_dropin(*, dry_run: bool, no_reload: bool) -> None: - path = Path(SSHD_DROPIN) - if dry_run: - print(f"[dry-run] escreveria {path}") - print(SSHD_BLOCK) - print("[dry-run] sshd -t && systemctl reload ssh") - return - - if not sshd_main_config_mentions_dropin(): - eprint( - "AVISO: /etc/ssh/sshd_config pode não incluir /etc/ssh/sshd_config.d/*.conf.\n" - " Confirme uma linha «Include … sshd_config.d»." - ) - - path.parent.mkdir(parents=True, exist_ok=True) - backup: Path | None = None - if path.is_file(): - backup = path.with_name(f"{path.name}.bak.{int(time.time())}") - shutil.copy2(path, backup) - print(f"Backup: {backup}") - - path.write_text(SSHD_BLOCK, encoding="utf-8") - path.chmod(0o644) - print(f"Escrito {path}") - - def revert() -> None: - if backup is not None: - shutil.copy2(backup, path) - eprint(f"Revertido {path}") - else: - path.unlink(missing_ok=True) - eprint(f"Removido {path}") - - try: - run(["sshd", "-t"]) - except RuntimeError as e: - revert() - raise SystemExit(f"sshd -t falhou; configuração revertida.\n{e}") from e - print("sshd -t: OK.") - - if no_reload: - print("Saltado reload; execute: systemctl reload ssh") - return - try: - run(["systemctl", "reload", "ssh"], timeout=60) - except RuntimeError: - try: - run(["systemctl", "reload", "sshd"], timeout=60) - except RuntimeError as e2: - raise SystemExit( - "sshd -t OK mas falhou systemctl reload ssh/sshd; recarregue manualmente." - ) from e2 - print("Serviço SSH recarregado.") - - -def ensure_group(*, dry_run: bool) -> None: - try: - grp.getgrnam(GROUP_NAME) - print(f"Grupo «{GROUP_NAME}» já existe.") - return - except KeyError: - pass - if dry_run: - print(f"[dry-run] groupadd {GROUP_NAME}") - return - run(["groupadd", GROUP_NAME]) - print(f"Criado grupo «{GROUP_NAME}».") - - -def apply_home_privacy( - *, - dry_run: bool, - home_path: Path, - exclude: frozenset[str], -) -> None: - if not home_path.is_dir(): - raise SystemExit(f"Não é directório: {home_path}") - - if dry_run: - print(f"[dry-run] chmod 711 {home_path}") - else: - os.chmod(home_path, 0o711) - print(f"chmod 711 {home_path}") - - for child in sorted(home_path.iterdir(), key=lambda p: p.name): - if child.name in exclude: - print(f"Omitido (—exclude): {child}") - continue - if not child.is_dir(): - continue - if dry_run: - print(f"[dry-run] chmod 700 {child}") - else: - os.chmod(child, 0o700) - print(f"chmod 700 {child}") - - print() - print("Verificação sugerida: ls -ld /home /home/*") - - -def scaffold_jail_tree(username: str, *, dry_run: bool) -> None: - """Cria apenas a árvore mínima de directórios e donos; não copia binários/libs.""" - try: - pw = pwd.getpwnam(username) - except KeyError as e: - raise SystemExit(f"Utilizador «{username}» não existe em passwd.") from e - - jail = Path(JAIL_ROOT) / username - jail_home = jail / "home" - - if dry_run: - print(f"[dry-run] mkdir -p {jail_home}") - print(f"[dry-run] chown root:root {jail} && chmod 755 {jail}") - print( - f"[dry-run] chown {pw.pw_uid}:{pw.pw_gid} {jail_home} && chmod 700 {jail_home}" - ) - return - - jail_home.parent.mkdir(parents=True, exist_ok=True) - jail_home.mkdir(parents=True, exist_ok=True) - os.chown(jail, 0, 0) - os.chmod(jail, 0o755) - os.chown(jail_home, pw.pw_uid, pw.pw_gid) - os.chmod(jail_home, 0o700) - print(f"Criado {jail} (root:root 755) e {jail_home} ({username}, 700).") - eprint( - "Aviso: para shell interactivo no chroot ainda precisa de /bin/sh, libs e " - "normalmente /dev dentro do jail — este comando só cria directórios vazios." - ) - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Permissões /home + drop-in SSH Match Group runv-jailed (ChrootDirectory)." - ) - p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") - p.add_argument( - "--dry-run", - action="store_true", - help="Mostra acções sem escrever no sistema (exceto --print-chroot-notes).", - ) - p.add_argument( - "--apply-ssh", - action="store_true", - help=f"Instala {SSHD_DROPIN} com Match Group {GROUP_NAME}.", - ) - p.add_argument( - "--apply-home", - action="store_true", - help=f"chmod 711 {HOME_ROOT} e 700 em cada subdirectório (privacidade básica).", - ) - p.add_argument( - "--ensure-group", - action="store_true", - help=f"Cria o grupo {GROUP_NAME} se não existir (groupadd).", - ) - p.add_argument( - "--no-reload", - action="store_true", - help="Após sshd -t, não executa systemctl reload ssh.", - ) - p.add_argument( - "--home-root", - type=Path, - default=Path(HOME_ROOT), - help=f"Raiz das homes (omissão: {HOME_ROOT}).", - ) - p.add_argument( - "--exclude", - action="append", - default=[], - metavar="NAME", - help="Nome de entrada em /home a omitir no chmod 700 (repetível).", - ) - p.add_argument( - "--print-chroot-notes", - action="store_true", - help="Imprime notas sobre ChrootDirectory e layout /srv/jail.", - ) - p.add_argument( - "--scaffold-jail", - metavar="USER", - default=None, - help=f"Cria {JAIL_ROOT}/USER e .../home com donos mínimos (sem binários).", - ) - return p.parse_args() - - -def main() -> None: - args = parse_args() - if args.print_chroot_notes: - print(CHROOT_NOTES.strip()) - if not any( - [ - args.apply_ssh, - args.apply_home, - args.ensure_group, - args.scaffold_jail, - ] - ): - return - - want_any = ( - args.apply_ssh - or args.apply_home - or args.ensure_group - or args.scaffold_jail is not None - ) - if not want_any and not args.print_chroot_notes: - eprint( - "Indique pelo menos uma acção: --apply-ssh, --apply-home, --ensure-group, " - "--scaffold-jail USER, ou --print-chroot-notes." - ) - raise SystemExit(2) - - if want_any and not args.dry_run: - require_root() - - excl = frozenset(args.exclude) if args.exclude else frozenset() - - if args.ensure_group: - ensure_group(dry_run=args.dry_run) - - if args.apply_ssh: - apply_sshd_dropin(dry_run=args.dry_run, no_reload=args.no_reload) - - if args.apply_home: - apply_home_privacy( - dry_run=args.dry_run, - home_path=args.home_root, - exclude=excl, - ) - - if args.scaffold_jail is not None: - scaffold_jail_tree(args.scaffold_jail.strip(), dry_run=args.dry_run) - - -if __name__ == "__main__": - main() diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -11,12 +11,13 @@ Contrato de provisionamento (ordem garantida após validação): ``--force-gopher``); ``index.gmi`` só é criado se ainda não existir (nunca substituído); bind mount ``/var/gemini/users/<user>`` <- ``~/public_gemini`` quando o directório global existir (``--force-gemini`` força migração de symlink / remount). -5. **Copiar o skel** — o Debian copia ``/etc/skel`` para a home **durante** o passo 1; depois, - após os diretórios públicos, o script acrescenta ``README.md`` runv (português), sem apagar o que - veio do skel (use ``--force-readme`` para substituir). Prepare ``/etc/skel`` com ``tools.py`` - antes das contas, se for política do servidor. -6. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, sites públicos e README com modos - e donos corretos, antes da quota e da verificação final. +5. **Skel Debian** — copiado no passo 1; o skel runv (``tools.py``) **não** inclui ``README.md`` por + política. Opcionalmente ``--with-readme`` cria ``~/README.md`` (``--force-readme`` substitui se existir). +6. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, sites públicos e, se existir, + ``README.md``, antes da **jail** (grupo ``runv-jailed``, Jailkit, bind, fstab), quota e verificação final. +7. **Jail SSH** — por omissão: ``usermod -aG runv-jailed``, ``/srv/jail/<user>``, ``jk_init``, + bind de ``/home/<user>`` em ``/srv/jail/<user>/home/<user>``, fstab. Exclui ``entre`` e + ``pmurad-admin``. ``--no-jail`` desliga. Quota ext4, metadados JSON e logging seguem após estes passos. @@ -64,10 +65,9 @@ _REPO_ROOT = _SCRIPT_DIR.parent.parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) -# --------------------------------------------------------------------------- -# Constantes -# --------------------------------------------------------------------------- +import runv_jail +# constantes USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") # Email pragmático (não RFC completo) @@ -151,11 +151,7 @@ class QuotaNotAvailableError(ValidationError): """Sistema de quotas não preparado (ext4 usrquota ausente, ferramentas, etc.).""" -# --------------------------------------------------------------------------- -# Validação de username / email -# --------------------------------------------------------------------------- - - +# validação username / email def validate_username(username: str) -> str: """ Valida username conservador; rejeita vazio, reservados e contas existentes. @@ -200,11 +196,7 @@ def validate_email(email: str) -> str: return e -# --------------------------------------------------------------------------- -# Chave pública OpenSSH -# --------------------------------------------------------------------------- - - +# chave pública OpenSSH def normalize_public_key(raw: str) -> str: """ Aceita uma única linha OpenSSH authorized_keys. @@ -296,11 +288,7 @@ def read_public_key_from_args(pub: str | None, pub_file: Path | None) -> str: raise ValidationError("forneça --public-key ou --public-key-file") -# --------------------------------------------------------------------------- -# Caminhos seguros sob /home -# --------------------------------------------------------------------------- - - +# caminhos sob /home (sem sair da árvore) def home_directory(username: str) -> Path: p = Path(f"/home/{username}").resolve() home_root = Path("/home").resolve() @@ -313,11 +301,7 @@ def home_directory(username: str) -> Path: return p -# --------------------------------------------------------------------------- -# SSH authorized_keys -# --------------------------------------------------------------------------- - - +# authorized_keys def install_authorized_keys( home: Path, uid: int, @@ -354,11 +338,7 @@ def install_authorized_keys( raise SystemProvisionError(f"não foi possível ajustar dono de {auth}: {e}") from e -# --------------------------------------------------------------------------- # public_html -# --------------------------------------------------------------------------- - - def default_index_html(username: str) -> str: """HTML estático mínimo: sem JavaScript, sem CDN, sem conteúdo dinâmico.""" return f"""<!DOCTYPE html> @@ -372,7 +352,7 @@ def default_index_html(username: str) -> str: <h1>Olá, ~{username}</h1> <p>Bem-vindo(a) ao runv.club — espaço pubnix com shell e site pessoal.</p> <p>Edite este ficheiro em <code>~/public_html/index.html</code> (ficheiros estáticos apenas).</p> - <p>Leia também <code>~/README.md</code> na shell para instruções e permissões.</p> + <p>Na shell, use <code>runv-help</code> para instruções e boas práticas do runv.club.</p> </body> </html> """ @@ -596,11 +576,7 @@ def prepare_user_readme( raise SystemProvisionError(f"não foi possível ajustar dono de {readme}: {e}") from e -# --------------------------------------------------------------------------- -# Metadados JSON -# --------------------------------------------------------------------------- - - +# metadados JSON @dataclass class UserRecord: username: str @@ -693,11 +669,7 @@ def append_user_metadata( lock_f.close() -# --------------------------------------------------------------------------- -# adduser / rollback -# --------------------------------------------------------------------------- - - +# adduser e rollback def run_adduser(username: str, log: logging.Logger) -> None: env = os.environ.copy() env["DEBIAN_FRONTEND"] = "noninteractive" @@ -741,9 +713,9 @@ def run_deluser_remove_home(username: str, log: logging.Logger) -> bool: def apply_runv_permissions(home: Path, uid: int, gid: int) -> None: """ - Aplica modos e donos esperados na home e nos artefactos runv (passo 5 do contrato). + Aplica modos e donos esperados na home e nos artefactos runv. - Deve ser chamado após criar o utilizador, chave SSH, ``public_html`` e ``README.md``, + Deve ser chamado após criar o utilizador, chave SSH, ``public_html`` e opcionalmente ``README.md``, para garantir home ``755`` (Apache, Gophernicus e Molly-Brown atravessam até ``public_html`` / ``public_gopher`` / ``public_gemini``), ``.ssh`` ``700``, ``authorized_keys`` ``600``, site ``755``/``644``. @@ -818,10 +790,13 @@ def apply_runv_permissions(home: Path, uid: int, gid: int) -> None: raise SystemProvisionError(f"não foi possível ajustar permissões de {gmi}: {e}") from e -def verify_user_artifact_permissions(home: Path, uid: int, gid: int) -> None: - """ - Confirma existência, dono e modos esperados após o provisionamento (falha explícita se algo estiver errado). - """ +def verify_user_artifact_permissions( + home: Path, + uid: int, + gid: int, + *, + expect_readme: bool, +) -> None: checks: list[tuple[Path, int, str]] = [ (home, 0o755, "home"), (home / ".ssh", 0o700, ".ssh"), @@ -832,8 +807,9 @@ def verify_user_artifact_permissions(home: Path, uid: int, gid: int) -> None: (home / "public_gopher" / "gophermap", 0o644, "gophermap"), (home / "public_gemini", 0o755, "public_gemini"), (home / "public_gemini" / "index.gmi", 0o644, "index.gmi"), - (home / "README.md", 0o644, "README.md"), ] + if expect_readme: + checks.append((home / "README.md", 0o644, "README.md")) for path, want_mode, label in checks: if not path.exists(): raise SystemProvisionError(f"em falta após provisionamento ({label}): {path}") @@ -850,11 +826,7 @@ def verify_user_artifact_permissions(home: Path, uid: int, gid: int) -> None: ) -# --------------------------------------------------------------------------- -# Quota ext4 (setquota, usrquota) -# --------------------------------------------------------------------------- - - +# quota ext4 (setquota / usrquota) def quota_probe_path(home: Path) -> Path: """ Caminho existente no disco para descobrir o mount (antes de adduser, /home/user pode não existir). @@ -1075,11 +1047,7 @@ def try_apply_quota( ) -# --------------------------------------------------------------------------- -# CLI e main -# --------------------------------------------------------------------------- - - +# CLI def try_refresh_landing_members_json( *, document_root: Path, @@ -1204,8 +1172,19 @@ def interactive_fill(args: argparse.Namespace) -> None: "Forçar correção do bind mount Gemini (/var/gemini/users) se estiver errado ou em conflito (--force-gemini)?", default_no=True, ) - args.force_readme = prompt_yes_no( - "Se já existir ~/README.md, sobrescrever (--force-readme)?", + args.with_readme = prompt_yes_no( + "Criar ~/README.md com texto runv (--with-readme)?", + default_no=True, + ) + if args.with_readme: + args.force_readme = prompt_yes_no( + "Se já existir ~/README.md, sobrescrever (--force-readme)?", + default_no=True, + ) + else: + args.force_readme = False + args.no_jail = prompt_yes_no( + "Omitir jail SSH (runv-jailed /srv/jail) (--no-jail)?", default_no=True, ) else: @@ -1253,6 +1232,116 @@ def setup_logging(log_path: Path, verbose: bool) -> logging.Logger: return logger +def _resolve_email_package_root(state: dict[str, Any] | None) -> Path | None: + """Pasta ``email/`` do repositório para importar ``lib.mailer``.""" + env = os.environ.get("RUNV_EMAIL_ROOT", "").strip() + if env: + p = Path(env) + return p if p.is_dir() else None + if state: + er = str(state.get("email_package_root", "")).strip() + if er: + p = Path(er) + if p.is_dir(): + return p + cand = _REPO_ROOT / "email" + return cand if cand.is_dir() else None + + +def try_send_welcome_email( + *, + username: str, + user_email: str, + fingerprint: str, + base_url: str, + welcome_ssh_host: str | None, + no_welcome_email: bool, + dry_run: bool, + log: logging.Logger, +) -> None: + """ + Envia ``user_account_created`` ao email do utilizador se existir configuração global + (``/etc/runv-email.json``) e módulo ``email/`` acessível. Falhas são só registadas + em log — a conta já foi criada. + """ + if no_welcome_email: + log.info("email de boas-vindas: omitido (--no-welcome-email)") + return + if dry_run: + log.info("email de boas-vindas: omitido (--dry-run)") + return + + state_file = Path("/etc/runv-email.json") + if not state_file.is_file(): + log.info( + "email de boas-vindas: %s ausente — defina email ou use --no-welcome-email", + state_file, + ) + return + try: + state = json.loads(state_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + log.warning("email de boas-vindas: estado inválido (%s): %s", state_file, e) + return + + email_root = _resolve_email_package_root(state) + if email_root is None: + log.warning( + "email de boas-vindas: pasta email/ não encontrada " + "(RUNV_EMAIL_ROOT, email_package_root no JSON ou repositório em %s)", + _REPO_ROOT / "email", + ) + return + + root_s = str(email_root.resolve()) + if root_s not in sys.path: + sys.path.insert(0, root_s) + + try: + from lib.mailer import send_user_notice + from lib.templates import USER_ACCOUNT_CREATED + except ImportError as e: + log.warning("email de boas-vindas: import lib.mailer falhou: %s", e) + return + + from_addr = str(state.get("default_from", "")).strip() + if not from_addr: + log.warning("email de boas-vindas: default_from ausente em %s", state_file) + return + + member_url = f"{base_url.rstrip('/')}/~{username}/" + host = (welcome_ssh_host or "").strip() + if host: + ssh_instructions = ( + f"Comando sugerido: ssh {username}@{host}\n" + "Confirme no cliente SSH que está a usar a chave privada correta " + "(a que corresponde à impressão digital acima)." + ) + else: + ssh_instructions = ( + f"Comando típico: ssh {username}@<hostname>\n" + "Substitua <hostname> pelo endereço do servidor que o administrador lhe indicar. " + "No cliente SSH, seleccione a **chave privada** que corresponde à chave pública registada." + ) + + try: + send_user_notice( + USER_ACCOUNT_CREATED, + user_email, + subject="[runv.club] Bem-vindo(a) — a sua conta foi criada", + from_addr=from_addr, + username=username, + email=user_email, + fingerprint=fingerprint, + member_url=member_url, + ssh_instructions=ssh_instructions, + ) + log.info("email de boas-vindas enviado para %s", user_email) + print(f" boas-vindas: email enviado para {user_email}") + except Exception as e: + log.warning("email de boas-vindas falhou (conta já criada): %s", e) + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( description=( @@ -1287,9 +1376,19 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="sobrescrever ~/public_html/index.html se já existir", ) p.add_argument( + "--with-readme", + action="store_true", + help="criar ~/README.md com texto runv (por omissão não cria)", + ) + p.add_argument( "--force-readme", action="store_true", - help="sobrescrever ~/README.md se já existir", + help="com --with-readme: sobrescrever ~/README.md se já existir", + ) + p.add_argument( + "--no-jail", + action="store_true", + help="não adicionar a runv-jailed nem criar jail em /srv/jail", ) p.add_argument( "--force-gopher", @@ -1390,6 +1489,20 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="version", version=f"%(prog)s {VERSION} — desenvolvido por {AUTHOR}, {COPYRIGHT_YEAR}", ) + p.add_argument( + "--no-welcome-email", + action="store_true", + help="não enviar email de boas-vindas ao utilizador após criar a conta", + ) + p.add_argument( + "--welcome-ssh-host", + default=None, + metavar="HOST", + help=( + "hostname SSH para incluir no email de boas-vindas (ex.: runv.club); " + "alternativa: variável de ambiente RUNV_WELCOME_SSH_HOST" + ), + ) return p.parse_args(argv) @@ -1485,10 +1598,12 @@ def main(argv: list[str] | None = None) -> int: print(f" home: {home}") print(f" fingerprint: {fingerprint}") print( - " ações: (1) adduser + /etc/skel (2) authorized_keys (3) public_html " - "(4) public_gopher + public_gemini + bind Gemini (5) README.md " - "(6) permissões consolidadas + quota (se ativa) + metadados JSON" + " 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" ) + print(f" with-readme: {getattr(args, 'with_readme', False)} no-jail: {getattr(args, 'no_jail', False)}") if args.no_quota: print(" quota: desativada (--no-quota)") else: @@ -1529,12 +1644,26 @@ def main(argv: list[str] | None = None) -> int: prepare_public_gemini(home, user, uid, gid, log) ensure_gemini_user_symlink(user, home, log, force=args.force_gemini) - log.info("=== fase 4: README.md runv (após skel /etc/skel do adduser; texto em português)") - prepare_user_readme(home, user, uid, gid, args.base_url, args.force_readme, log) + if args.with_readme: + log.info("=== fase 4: README.md runv (--with-readme)") + prepare_user_readme(home, user, uid, gid, args.base_url, args.force_readme, log) + else: + log.info("=== fase 4: README.md omitido (use --with-readme para criar)") - log.info("=== fase 5: permissões consolidadas (home, .ssh, sites públicos, README)") + log.info("=== fase 5: permissões consolidadas (home, .ssh, sites públicos, README se existir)") apply_runv_permissions(home, uid, gid) + log.info("=== fase 6: jail SSH (runv-jailed) salvo --no-jail") + try: + runv_jail.ensure_runv_jail_for_user( + user, + home, + no_jail=bool(args.no_jail), + log=log, + ) + except RuntimeError as e: + raise SystemProvisionError(str(e)) from e + log.info("=== fase: quota (setquota em ext4 com usrquota)") if args.no_quota: qr = QuotaResult( @@ -1571,7 +1700,12 @@ def main(argv: list[str] | None = None) -> int: overall_status = "partial_quota" log.info("=== fase: verificação final de permissões e artefactos") - verify_user_artifact_permissions(home, uid, gid) + verify_user_artifact_permissions( + home, + uid, + gid, + expect_readme=bool(args.with_readme), + ) record = UserRecord( username=user, @@ -1625,7 +1759,14 @@ 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(" README.md: criado em ~/README.md (pt-BR)") + if args.with_readme: + print(" README.md: criado em ~/README.md (pt-BR)") + else: + print(" README.md: omitido (use --with-readme para criar)") + if args.no_jail: + print(" jail SSH: omitido (--no-jail)") + else: + print(" jail SSH: runv-jailed + /srv/jail/<user> (bind home)") print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") print(f" fingerprint: {fingerprint}") print(f" metadados: {args.metadata_file}") @@ -1651,6 +1792,19 @@ def main(argv: list[str] | None = None) -> int: if qr.mountpoint: print(f" quota mount: {qr.mountpoint} ({qr.filesystem or '?'})") + welcome_host = (args.welcome_ssh_host or os.environ.get("RUNV_WELCOME_SSH_HOST") or "").strip() + welcome_host_opt: str | None = welcome_host if welcome_host else None + try_send_welcome_email( + username=user, + user_email=email, + fingerprint=fingerprint, + base_url=args.base_url, + welcome_ssh_host=welcome_host_opt, + no_welcome_email=bool(args.no_welcome_email), + dry_run=bool(args.dry_run), + log=log, + ) + if not args.no_quota and qr.status in ("failed", "not_configured"): print( "\n*** AVISO: conta criada mas quota NÃO aplicada ou sistema não configurado. " diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py @@ -31,10 +31,7 @@ _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) -# --------------------------------------------------------------------------- -# Constantes -# --------------------------------------------------------------------------- - +# constantes USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") # Contas de sistema / serviço — nunca remover por engano @@ -73,11 +70,7 @@ EXIT_SYSTEM: Final[int] = 2 MIN_UID_NORMAL_USER: Final[int] = 1000 -# --------------------------------------------------------------------------- -# Validação e privilégios -# --------------------------------------------------------------------------- - - +# validação / root def validate_privileges() -> None: if os.geteuid() != 0: print( @@ -152,10 +145,7 @@ def confirm_interactive(username: str) -> bool: return typed == username -# --------------------------------------------------------------------------- -# Gemini (bind mount / symlink legado em /var/gemini/users) -# --------------------------------------------------------------------------- - +# Gemini (bind em /var/gemini/users) GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") FSTAB_PATH: Final[Path] = Path("/etc/fstab") _GEMINI_BIND_FSTAB_RE: Final[re.Pattern[str]] = re.compile( @@ -251,11 +241,7 @@ def remove_gemini_user_symlink(username: str, *, dry_run: bool, verbose: bool) - ) -# --------------------------------------------------------------------------- -# deluser -# --------------------------------------------------------------------------- - - +# deluser / quota def clear_user_quota_before_removal( username: str, home: Path, @@ -359,11 +345,7 @@ def run_deluser( print(r.stdout.rstrip()) -# --------------------------------------------------------------------------- -# Metadados runv (users.json) -# --------------------------------------------------------------------------- - - +# users.json def remove_user_metadata( metadata_path: Path, lock_path: Path, @@ -445,11 +427,7 @@ def remove_user_metadata( lock_f.close() -# --------------------------------------------------------------------------- # CLI -# --------------------------------------------------------------------------- - - def main() -> int: parser = argparse.ArgumentParser( description="Remove permanentemente um utilizador Unix (banimento, runv.club).", diff --git a/scripts/admin/perm1.md b/scripts/admin/perm1.md @@ -0,0 +1,14 @@ +# perm1 — jail para contas existentes + +Script **`scripts/admin/perm1.py`** (root): adiciona utilizadores **uid ≥ 1000** ao grupo **`runv-jailed`** e cria o layout Jailkit em **`/srv/jail/<user>`** com **bind mount** da home real, mais linha em **`/etc/fstab`** (idempotente). + +**Excluídos:** `nobody`, `pmurad-admin`, `entre`. + +**Pré-requisitos:** `tools/tools.py` já aplicado (pacote **jailkit**, drop-in **`90-runv-jailed.conf`**, grupo `runv-jailed`). + +```bash +sudo python3 scripts/admin/perm1.py --verbose +sudo python3 scripts/admin/perm1.py --only-user maria --dry-run +``` + +Após aplicar, teste SSH com um utilizador antes de confiar em produção. diff --git a/scripts/admin/perm1.py b/scripts/admin/perm1.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import logging +import os +import pwd +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +import runv_jail as rj + +EXCLUDE_NAMES = frozenset({"nobody", "pmurad-admin", "entre"}) + + +def setup_logging(verbose: bool) -> logging.Logger: + log = logging.getLogger("perm1") + log.setLevel(logging.DEBUG if verbose else logging.INFO) + log.handlers.clear() + h = logging.StreamHandler(sys.stderr) + h.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + log.addHandler(h) + return log + + +def iter_targets(only_user: str | None): + if only_user: + yield pwd.getpwnam(only_user) + return + for pw in pwd.getpwall(): + if pw.pw_uid < 1000: + continue + if pw.pw_name in EXCLUDE_NAMES or rj.jail_skip_username(pw.pw_name): + continue + yield pw + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Aplica runv-jailed + jail /srv/jail/<user> a contas existentes (uid>=1000).", + ) + p.add_argument("--dry-run", action="store_true", help="só listar utilizadores e ações previstas") + p.add_argument("--verbose", "-v", action="store_true", help="log detalhado") + p.add_argument( + "--only-user", + metavar="U", + default=None, + help="processar apenas este utilizador (ainda sujeito a exclusões)", + ) + args = p.parse_args(argv) + + log = setup_logging(args.verbose) + + if os.geteuid() != 0 and not args.dry_run: + log.error("execute como root (ou use --dry-run)") + return 2 + + if args.only_user: + u = args.only_user.strip() + if u in EXCLUDE_NAMES or rj.jail_skip_username(u): + log.error("utilizador %r está excluído desta ferramenta", u) + return 1 + + try: + targets = list(iter_targets(args.only_user)) + except KeyError as e: + log.error("utilizador desconhecido: %s", e) + return 1 + + if not targets: + log.warning("nenhum utilizador corresponde aos critérios") + return 0 + + for pw in targets: + home = Path(pw.pw_dir) + log.info("--- %s (uid=%s) home=%s", pw.pw_name, pw.pw_uid, home) + if args.dry_run: + if rj.jail_skip_username(pw.pw_name): + log.info("[dry-run] omitir (exclusão)") + else: + log.info("[dry-run] usermod -aG runv-jailed + jail em /srv/jail/%s", pw.pw_name) + continue + try: + rj.ensure_runv_jail_for_user(pw.pw_name, home, no_jail=False, log=log) + except Exception as e: + log.error("falha para %s: %s", pw.pw_name, e) + return 3 + + log.info("concluído (%d utilizador(es))", len(targets)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/runv_jail.py b/scripts/admin/runv_jail.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +from pathlib import Path + +RUNV_JAILED_GROUP = "runv-jailed" +JAIL_SKIP_USERNAMES = frozenset({"entre", "pmurad-admin"}) +JAIL_ROOT = Path("/srv/jail") +FSTAB_PATH = Path("/etc/fstab") + + +def jail_skip_username(username: str) -> bool: + return username in JAIL_SKIP_USERNAMES + + +def _run(cmd: list[str], *, log: logging.Logger) -> subprocess.CompletedProcess[str]: + log.debug("exec: %s", " ".join(cmd)) + return subprocess.run(cmd, capture_output=True, text=True, timeout=600) + + +def ensure_runv_jailed_group(log: logging.Logger) -> None: + r = _run(["groupadd", "-f", RUNV_JAILED_GROUP], log=log) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"groupadd -f {RUNV_JAILED_GROUP} falhou: {err}") + + +def ensure_user_in_jailed_group(username: str, log: logging.Logger) -> None: + ensure_runv_jailed_group(log) + r = _run(["getent", "group", RUNV_JAILED_GROUP], log=log) + if r.returncode != 0 or not (r.stdout or "").strip(): + raise RuntimeError("grupo runv-jailed não existe após groupadd") + line = (r.stdout or "").strip() + members_field = line.split(":")[-1] if ":" in line else "" + members = {m.strip() for m in members_field.split(",") if m.strip()} + if username in members: + log.debug("jail: %s já está em %s", username, RUNV_JAILED_GROUP) + return + r2 = _run(["usermod", "-aG", RUNV_JAILED_GROUP, username], log=log) + if r2.returncode != 0: + err = (r2.stderr or r2.stdout or "").strip() + raise RuntimeError(f"usermod -aG {RUNV_JAILED_GROUP} {username}: {err}") + log.info("jail: utilizador %s adicionado ao grupo %s", username, RUNV_JAILED_GROUP) + + +def fstab_bind_line(real_home: Path, jail_mount_point: Path) -> str: + src = str(real_home.resolve()) + dst = str(jail_mount_point.resolve()) + return f"{src}\t{dst}\tnone\tbind,nofail\t0\t0\n" + + +def fstab_has_bind(real_home: Path, jail_mount_point: Path) -> bool: + if not FSTAB_PATH.is_file(): + return False + text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace") + src = str(real_home.resolve()) + dst = str(jail_mount_point.resolve()) + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) >= 2 and parts[0] == src and parts[1] == dst: + return True + return False + + +def append_fstab_bind(real_home: Path, jail_mount_point: Path, log: logging.Logger) -> None: + if fstab_has_bind(real_home, jail_mount_point): + log.debug("jail: fstab já contém bind %s -> %s", real_home, jail_mount_point) + return + with open(FSTAB_PATH, "a", encoding="utf-8") as f: + f.write(fstab_bind_line(real_home, jail_mount_point)) + log.info("jail: fstab atualizado (bind %s)", real_home.name) + + +def ensure_jail_layout(username: str, home: Path, log: logging.Logger) -> Path: + """Cria /srv/jail/user, jk_init basicshell, mkdir home/user. Devolve caminho do mountpoint do bind.""" + if shutil.which("jk_init") is None: + raise RuntimeError("jk_init não encontrado — instale jailkit e corra tools/tools.py") + jail_root = JAIL_ROOT / username + jail_root.mkdir(parents=True, exist_ok=True) + os.chmod(jail_root, 0o755) + try: + os.chown(jail_root, 0, 0) + except OSError as e: + log.warning("jail: chown root em %s: %s", jail_root, e) + marker = jail_root / "bin" + if not marker.exists(): + r = _run(["jk_init", "-j", str(jail_root), "basicshell"], log=log) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"jk_init falhou: {err}") + log.info("jail: jk_init basicshell em %s", jail_root) + else: + log.debug("jail: %s já tem layout jk (bin presente)", jail_root) + inner = jail_root / "home" / username + inner.mkdir(parents=True, exist_ok=True) + hp = inner.parent + try: + os.chmod(hp, 0o755) + os.chown(hp, 0, 0) + os.chown(inner, 0, 0) + except OSError as e: + log.warning("jail: permissões em %s: %s", inner, e) + return inner + + +def ensure_bind_mount(real_home: Path, jail_home: Path, log: logging.Logger) -> None: + if os.path.ismount(jail_home): + log.debug("jail: %s já montado", jail_home) + return + r = _run( + ["mount", "--bind", str(real_home.resolve()), str(jail_home.resolve())], + log=log, + ) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"mount --bind falhou: {err}") + log.info("jail: bind mount %s -> %s", real_home, jail_home) + + +def ensure_runv_jail_for_user( + username: str, + home: Path, + *, + no_jail: bool, + log: logging.Logger, +) -> None: + if no_jail: + log.info("jail: omitido (--no-jail)") + return + if jail_skip_username(username): + log.info("jail: omitido (conta excluída: %s)", username) + return + home = home.resolve() + ensure_user_in_jailed_group(username, log) + jail_home = ensure_jail_layout(username, home, log) + ensure_bind_mount(home, jail_home, log) + append_fstab_bind(home, jail_home, log) diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -29,10 +29,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Final -# --------------------------------------------------------------------------- -# Constantes -# --------------------------------------------------------------------------- - +# constantes VERSION: Final[str] = "0.14" LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live") @@ -82,11 +79,7 @@ Bem-vindo ao runv.club no **Gemini**. Este é o teu espaço — escreve em `.gmi """ -# --------------------------------------------------------------------------- -# Utilitários -# --------------------------------------------------------------------------- - - +# utilitários def _path_resolved(p: Path) -> Path: """Resolve o caminho; com symlinks (ex. Let's Encrypt) alinha com o canónico.""" try: diff --git a/scripts/create_runv_user.md b/scripts/create_runv_user.md @@ -4,7 +4,7 @@ Ferramenta de linha de comando para **administradores** criarem contas Unix no servidor **Debian/Linux** (runv.club). Não é cadastro público. -É a **fonte principal** da política de provisionamento: usa `adduser`, mas o fluxo completo (SSH, `public_html`, `README.md`, permissões, quota, metadados, log) está centralizado aqui — sem depender de `adduser.local`, `QUOTAUSER` ou regras em `/etc/adduser.conf`. +É a **fonte principal** da política de provisionamento: usa `adduser`, mas o fluxo completo (SSH, `public_html`, jail `runv-jailed`, `README.md` opcional, permissões, quota, metadados, log) está centralizado aqui — sem depender de `adduser.local`, `QUOTAUSER` ou regras em `/etc/adduser.conf`. **Ambiente:** execute apenas no servidor (ou VM Debian). O script usa `pwd`, `fcntl`, `adduser`, `ssh-keygen`, `findmnt`/`setquota` — **não é suportado no Windows.** @@ -14,11 +14,22 @@ Ferramenta de linha de comando para **administradores** criarem contas Unix no s 2. **Instalar a chave** — `~/.ssh/authorized_keys` com chave validada e modos `700` / `600`. 3. **Preparar `public_html`** — diretório `755` e `~/public_html/index.html` estático (sem JavaScript, sem CDN); não sobrescreve sem `--force-index`. 4. **Preparar Gopher e Gemini** — `~/public_gopher/` com `gophermap` e `~/public_gemini/` com `index.gmi` (modelos em português); não sobrescreve sem **`--force-gopher`** / **`--force-gemini`**. Se existir **`/var/gemini/users`**, aplica **bind mount** **`/var/gemini/users/<user>`** ← **`~/public_gemini`** (via `setup_alt_protocols`; **`--force-gemini`** migra symlink legado). Se essa pasta global não existir, regista **aviso** no log — corra **[`setup_alt_protocols.py`](docs/alt_protocols.md)** no servidor. -5. **Copiar o skel** — o Debian **copia `/etc/skel` para a home no passo 1**. Depois, o script acrescenta `~/README.md` runv em português (runv.club, URL `~/username/`, permissões, comandos, aviso sobre arquivos públicos, Gopher/Gemini); não sobrescreve sem **`--force-readme`**. Se o skel do sistema já tiver um `README.md`, ele permanece até usar `--force-readme`. Para padronizar o skel do servidor, use **`tools/tools.py`** (ou `admin/skel.py`, conforme a política do servidor) antes de criar contas. -6. **Aplicar permissões** — `apply_runv_permissions` reforça home `755`, `.ssh` / `authorized_keys`, `public_html`, `public_gopher`, `public_gemini` e `README.md` com modos e donos corretos; em seguida quota (se ativa), verificação final e metadados. +5. **Skel** — o Debian **copia `/etc/skel` no passo 1**. O skel instalado por **`tools/tools.py`** **não** inclui `README.md`. Só é criado `~/README.md` com **`--with-readme`**; **`--force-readme`** só faz sentido em conjunto (substituir se já existir). +6. **Permissões** — `apply_runv_permissions` reforça home `755`, `.ssh`, sites públicos e, se existir, `README.md`. +7. **Jail SSH** — por omissão: grupo **`runv-jailed`**, **`/srv/jail/<user>`**, `jk_init basicshell`, **bind** de `/home/<user>` em `/srv/jail/<user>/home/<user>`, linha em **`/etc/fstab`** (idempotente). **Não** aplica a **`entre`** nem **`pmurad-admin`**. **`--no-jail`** desliga. Requer **`tools/tools.py`** já aplicado (jailkit + drop-in sshd). Contas **já existentes**: **[`admin/perm1.py`](admin/perm1.md)**. +8. **Quota** (se ativa), verificação final e metadados JSON. **Log** em arquivo (e stderr com `--verbose`) com estas fases numeradas, quota, metadados e verificação final. +### Email de boas-vindas ao utilizador + +Após criar a conta com sucesso (e antes do aviso de quota parcial, se aplicável), o script tenta enviar o template **`user_account_created`** para o endereço de metadado (`--email`), usando `lib.mailer` e o estado global em **`/etc/runv-email.json`** (Mailgun ou SMTP legado — ver `email/docs/INSTALL.md`). + +- Requisitos: configurador de email já executado; pasta `email/` encontrável (`RUNV_EMAIL_ROOT`, `email_package_root` no JSON, ou repositório ao lado de `scripts/`). +- **`--no-welcome-email`** — não envia. +- **`--welcome-ssh-host HOST`** ou **`RUNV_WELCOME_SSH_HOST`** — inclui no corpo um comando `ssh usuario@HOST` concreto; sem isso, o texto usa um placeholder `<hostname>` e pede ao utilizador que confirme o endereço com o administrador. +- Falhas de envio **não** revertem a criação da conta; ficam só no log. + ## Quota ext4 O `create_runv_user.py` descobre **automaticamente** o mount que contém `/home/username` (`findmnt` / `admin/runv_mount.py` no repositório) e aplica `setquota` nesse ponto — tanto se a home está na **raiz `/`** como se **`/home`** é um volume **ext4** separado. O filesystem tem de ser **ext4** com **`usrquota`** (ou **`usrjquota=`**) ativo nesse mount. @@ -94,11 +105,13 @@ Fluxo típico: 5. Se for criar de verdade: sobrescrever `index.html` existente — sim/não 6. Se for criar de verdade: sobrescrever `gophermap` existente (`--force-gopher`) — sim/não 7. Se for criar de verdade: sobrescrever `index.gmi` existente (`--force-gemini`) — sim/não -8. Se for criar de verdade: sobrescrever `README.md` existente — sim/não -9. Log verboso — sim/não -10. Criar **sem** quota (`--no-quota`) — sim/não (padrão não) -11. Se for com quota: exigir sistema pronto **antes** de criar (`--require-quota`) — sim/não (padrão não) -12. Confirmação final antes de executar +8. Se for criar de verdade: criar `~/README.md` (`--with-readme`) — sim/não (padrão não) +9. Se criar README: sobrescrever se já existir (`--force-readme`) — sim/não +10. Se for criar de verdade: omitir jail SSH (`--no-jail`) — sim/não (padrão não = aplicar jail) +11. Log verboso — sim/não +12. Criar **sem** quota (`--no-quota`) — sim/não (padrão não) +13. Se for com quota: exigir sistema pronto **antes** de criar (`--require-quota`) — sim/não (padrão não) +14. Confirmação final antes de executar `Ctrl+C` cancela. Se responder “não” na confirmação final, o script encerra sem alterar o sistema. @@ -115,6 +128,8 @@ sudo python3 admin/create_runv_user.py \ --public-key "ssh-ed25519 AAAA... comentario" ``` +Opcional: **`--with-readme`**, **`--no-jail`** (conta sem chroot SSH). + ### Sem quota ```bash diff --git a/site/public/assets/app.js b/site/public/assets/app.js @@ -1,8 +1,8 @@ /** * Landing runv.club — carrega members.json (só dados públicos) e coloca * pontos clicáveis (links) fora da coluna de texto; brilho ligado à data since. - * Pontos dentro de #starfield (fixed fullscreen); cada um position:absolute — sem transform no CSS. - * Não recalculam ao scroll — só em resize. + * Pontos no fundo do documento: #starfield é absolute dentro de .page-root (rolam com a página). + * Recalculam em resize e quando a altura do .page-root muda (ResizeObserver). * Array vazio: sem estrelas até build_directory.py gerar o JSON a partir de users.json. */ @@ -52,12 +52,11 @@ function pointInRect(x, y, rect) { } /** - * Rect da coluna de conteúdo fixo no viewport (mesma ideia que .wrap: 46rem + padding). - * Independente do scroll — evita que as bolinhas «saltem» ao rolar a página. + * Faixa central de texto (como .wrap) em coordenadas do contentor da página — altura total do documento. */ -function viewportFixedContentExcludeRect() { - const vw = window.innerWidth; - const vh = window.innerHeight; +function documentColumnExcludeRect(hostEl) { + const vw = hostEl ? hostEl.offsetWidth : window.innerWidth; + const h = hostEl ? hostEl.offsetHeight : window.innerHeight; const rootFs = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const maxBlock = 46 * rootFs; const pad = Math.min(Math.max(rootFs, vw * 0.04), 1.35 * rootFs); @@ -67,13 +66,13 @@ function viewportFixedContentExcludeRect() { left, top: 0, right: left + contentW, - bottom: vh, + bottom: h, }; } /** - * Posição para um ponto: fora da coluna central (viewport), com fallback para - * faixas laterais ou cantos quando o ecrã é estreito. + * Posição para um ponto: fora da coluna central (área w×h do contentor), com fallback + * para faixas laterais ou cantos quando o ecrã é estreito. */ function findStarPosition(w, h, seed, exclude) { const edge = 14; @@ -141,12 +140,13 @@ function renderStarLinks(container, members) { if (!container) return; - const w = window.innerWidth; - const h = window.innerHeight; + const host = container.parentElement; + const w = host ? host.offsetWidth : window.innerWidth; + const h = host ? host.offsetHeight : window.innerHeight; if (w < 32 || h < 32) return; const pad = 36; - const exclude = inflateRect(viewportFixedContentExcludeRect(), pad); + const exclude = inflateRect(documentColumnExcludeRect(host), pad); for (const m of validMembers(members)) { const seed = hashUsername(m.username); @@ -193,6 +193,12 @@ async function main() { scheduleStars(); window.addEventListener("resize", scheduleStars, { passive: true }); + + const host = starRoot?.parentElement; + if (host && typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => scheduleStars()); + ro.observe(host); + } } document.addEventListener("DOMContentLoaded", main); diff --git a/site/public/assets/style.css b/site/public/assets/style.css @@ -61,17 +61,13 @@ body { border: 0; } +/* Camada de pontos: acompanha o scroll (absolute dentro de .page-root, não fixed ao viewport). */ .starfield-root { - position: fixed; + position: absolute; inset: 0; - width: 100vw; - height: 100vh; - height: 100dvh; z-index: 0; pointer-events: none; overflow: visible; - transform: none; - isolation: isolate; } /* Membros como pontos: só em ecrãs largos (em mobile o texto ocupa o viewport). */ @@ -81,7 +77,6 @@ body { } } -/* Sem transform nas bolinhas: fixed + transform falha em alguns WebKit ao rolar. */ .star-member { --star-scale: 1; position: absolute; diff --git a/site/public/faq/index.html b/site/public/faq/index.html @@ -4,13 +4,14 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>FAQ — runv.club</title> - <meta name="description" content="Perguntas frequentes sobre o runv.club: cadastro, acesso, suporte e contato."> + <meta name="description" content="Perguntas frequentes sobre o runv.club: acesso SSH, pedido de conta, suporte e contato."> <link rel="canonical" href="https://runv.club/faq/"> <meta name="robots" content="index, follow"> <meta name="theme-color" content="#0c0b0f"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -43,57 +44,57 @@ <section class="faq-item" aria-labelledby="faq-3"> <h2 id="faq-3" class="faq-q">3. Como faço meu cadastro?</h2> - <p>Na tela de acesso, basta escolher a opção de cadastro e preencher os dados solicitados. Depois disso, siga as instruções exibidas na plataforma para concluir a criação da sua conta.</p> + <p>Não há formulário web automático. Siga a página <a href="/junte-se/">Junte-se</a>: gere um par de chaves SSH (Ed25519), ligue-se com <code>ssh</code> a <span class="ssh-identity">entre@runv.club</span> e envie a sua chave <strong>pública</strong> quando o sistema pedir. A equipa revê a fila e cria a conta Unix no servidor.</p> </section> <section class="faq-item" aria-labelledby="faq-4"> - <h2 id="faq-4" class="faq-q">4. Já tenho conta. Como faço login?</h2> - <p>Acesse a página de entrada do sistema, informe seu e-mail e senha e prossiga com o login. Se a plataforma oferecer outros métodos de entrada, como login social, eles aparecerão na própria tela.</p> + <h2 id="faq-4" class="faq-q">4. Já tenho conta. Como entro no servidor?</h2> + <p>O acesso à pubnix é por <strong>SSH com chave</strong> (não há login por senha SSH). No seu computador, use algo como <code>ssh -i ~/.ssh/sua_chave_runv <em>seuusuario</em>@runv.club</code> (ajuste o utilizador e o caminho da chave). O site estático (páginas no navegador) é só informação; a shell é no SSH.</p> </section> <section class="faq-item" aria-labelledby="faq-5"> - <h2 id="faq-5" class="faq-q">5. Esqueci minha senha. O que devo fazer?</h2> - <p>Use a opção de recuperação de senha disponível na tela de acesso. Se você não receber a mensagem de redefinição em poucos minutos, verifique a caixa de spam ou lixo eletrônico.</p> + <h2 id="faq-5" class="faq-q">5. Perdi a chave SSH ou não consigo entrar. E agora?</h2> + <p>Não existe “esqueci a senha” no SSH. Guarde a chave privada em local seguro. Se a perdeu ou precisa substituir a chave pública no servidor, escreva para <a href="mailto:admin@runv.club">admin@runv.club</a> explicando o caso.</p> </section> <section class="faq-item" aria-labelledby="faq-6"> <h2 id="faq-6" class="faq-q">6. Posso entrar com conta Google?</h2> - <p>Se essa opção estiver habilitada na tela de login, sim. O método disponível sempre será o que estiver exibido oficialmente no acesso da plataforma.</p> + <p>Não. A autenticação na pubnix é feita com o par de chaves SSH que você gera no seu equipamento, não com login social.</p> </section> <section class="faq-item" aria-labelledby="faq-7"> - <h2 id="faq-7" class="faq-q">7. Não recebi e-mail de confirmação ou recuperação. E agora?</h2> - <p>Primeiro, confira spam, promoções e lixo eletrônico. Depois, confirme se o e-mail informado foi digitado corretamente. Se o problema continuar, entre em contato pelo e-mail <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + <h2 id="faq-7" class="faq-q">7. Não recebi resposta por e-mail. E agora?</h2> + <p>Confira spam, promoções e lixo eletrónico. Confirme se o endereço que deixou no pedido está correto. Se continuar sem resposta ou se o assunto for urgente (acesso, chave), contacte <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> <section class="faq-item" aria-labelledby="faq-8"> - <h2 id="faq-8" class="faq-q">8. O sistema funciona no celular?</h2> - <p>Sim. O ideal é que o sistema funcione em navegador atualizado, tanto no celular quanto no computador. Se houver falha de carregamento, atualize a página, teste outro navegador e verifique sua conexão.</p> + <h2 id="faq-8" class="faq-q">8. O site e o servidor funcionam no celular?</h2> + <p>As páginas do runv.club abrem em navegador atualizado no telemóvel ou no computador. Já a shell na pubnix usa um cliente SSH (no telemóvel existem aplicações para isso). Se uma página não carregar, atualize, teste outra rede ou outro navegador.</p> </section> <section class="faq-item" aria-labelledby="faq-9"> - <h2 id="faq-9" class="faq-q">9. O que fazer se a plataforma estiver lenta, fora do ar ou com erro?</h2> - <p>Antes de assumir que o problema é do sistema, faça o básico: atualize a página, saia e entre novamente, limpe o cache do navegador e teste em outro dispositivo ou rede. Se o erro persistir, envie o máximo de detalhes para <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + <h2 id="faq-9" class="faq-q">9. O que fazer se o site estiver lento, fora do ar ou se o SSH falhar?</h2> + <p>Para o <strong>site</strong>: atualize a página, limpe cache, teste outro dispositivo ou rede. Para o <strong>SSH</strong>: confirme utilizador, chave (<code>-i</code>), rede e mensagem de erro completa. Se o problema persistir, envie detalhes (horário, comando, output) para <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> <section class="faq-item" aria-labelledby="faq-10"> <h2 id="faq-10" class="faq-q">10. Como pedir suporte de forma eficiente?</h2> - <p>Envie uma mensagem objetiva com seu nome, e-mail cadastrado, descrição do problema, horário aproximado em que ocorreu e, se possível, captura de tela. Suporte ruim começa com relato ruim.</p> + <p>Envie mensagem objetiva com o seu nome de utilizador Unix no runv.club, e-mail de contacto, descrição do problema, horário aproximado e, se for SSH, o texto de erro completo (ou captura). Quanto mais concreto, mais rápido de analisar.</p> </section> <section class="faq-item" aria-labelledby="faq-11"> <h2 id="faq-11" class="faq-q">11. Meus dados ficam protegidos?</h2> - <p>O objetivo da plataforma é operar com segurança e controle de acesso. Mesmo assim, a sua parte importa: use senha forte, não compartilhe credenciais e evite entrar em dispositivos públicos ou redes duvidosas.</p> + <p>A operação visa segurança e controlo de acesso. A sua parte importa: proteja a <strong>chave privada</strong> SSH, não a partilhe, não a envie por chat, e evite redes ou dispositivos em que não confie para acesso sensível.</p> </section> <section class="faq-item" aria-labelledby="faq-12"> - <h2 id="faq-12" class="faq-q">12. Posso acessar o sistema em mais de um dispositivo?</h2> - <p>Em geral, sim, desde que o acesso esteja dentro das regras da plataforma. Se houver limitação de sessões simultâneas, isso deve ser tratado pela própria interface ou pelo suporte.</p> + <h2 id="faq-12" class="faq-q">12. Posso usar mais de um dispositivo?</h2> + <p>Em geral sim: pode usar o mesmo par de chaves em vários equipamentos ou chaves diferentes se a política e a administração o permitirem — o importante é manter as regras da comunidade e não expor a chave privada.</p> </section> <section class="faq-item" aria-labelledby="faq-13"> <h2 id="faq-13" class="faq-q">13. Onde acompanho avisos, mudanças ou instabilidades?</h2> - <p>Sempre verifique os canais oficiais ligados ao sistema, como a própria plataforma, telas de aviso e comunicações de suporte. Quando tiver dúvida real, pare de adivinhar e pergunte em <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + <p>Consulte <a href="/news/">Notícias</a>, a <a href="/wiki/">wiki</a> e o site. Em dúvida, <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> <section class="faq-item" aria-labelledby="faq-14"> diff --git a/site/public/favicon.svg b/site/public/favicon.svg @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> + <!-- Favicon: emoji globo (🌐) — renderização depende das fontes emoji do sistema --> + <text + x="50" + y="54" + dominant-baseline="middle" + text-anchor="middle" + font-size="78" + font-family="Segoe UI Emoji, Apple Color Emoji, Noto Color Emoji, Twemoji Mozilla, sans-serif" + >🌐</text> +</svg> diff --git a/site/public/index.html b/site/public/index.html @@ -23,12 +23,12 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="assets/style.css"> </head> <body> - <div id="starfield" class="starfield-root" aria-hidden="true"></div> - <div class="page-root"> + <div id="starfield" class="starfield-root" aria-hidden="true"></div> <div class="wrap"> <header class="hero"> <div class="ascii-block"> diff --git a/site/public/junte-se/index.html b/site/public/junte-se/index.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> diff --git a/site/public/news/index.html b/site/public/news/index.html @@ -21,6 +21,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> diff --git a/site/public/wiki/contas-e-acesso.html b/site/public/wiki/contas-e-acesso.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -42,17 +43,19 @@ <h2>CADASTRO</h2> +<p>O pedido de conta segue o fluxo oficial do runv.club (por exemplo ligação SSH a entre@runv.club com a sua chave pública, conforme a página “Junte-se”); a equipa revê a fila e a conta Unix é criada pela administração — não há registo instantâneo automático na web.</p> + <p>O cadastro deve ser feito com informações verdadeiras e minimamente consistentes.<br> Criar conta com identidade falsa para enganar terceiros, burlar punição, cometer fraude ou simular legitimidade é motivo para ação da moderação.</p> <h2>LOGIN</h2> -<p>O acesso ao sistema é individual. Cada usuário é responsável por proteger seu e-mail, senha e meios de autenticação vinculados à conta.</p> +<p>O acesso ao sistema é individual e, na pubnix, baseia-se em SSH com chave pública (conta Unix sem senha de login SSH). Cada usuário é responsável por proteger seu e-mail, a chave privada SSH e quaisquer outros meios de autenticação vinculados à conta.</p> -<h2>RECUPERAÇÃO DE SENHA</h2> +<h2>RECUPERAÇÃO DE ACESSO</h2> -<p>Se o usuário perder acesso à conta, deve usar os meios oficiais de recuperação disponíveis no sistema.<br> -Se ainda assim não resolver, deve contatar:<br> +<p>Não há “esqueci a senha” no SSH: quem perde a chave privada, deixa de conseguir autenticar com o par que estava registado.<br> +Nesse caso (ou se precisar substituir a chave pública no servidor), contacte:<br> admin@runv.club</p> <h2>COMPARTILHAMENTO DE CONTA</h2> @@ -71,7 +74,7 @@ admin@runv.club</p> <h2>BOAS PRÁTICAS DE SEGURANÇA</h2> -<ul><li>usar senha forte;</li><li>não reutilizar senha vazada em outros serviços;</li><li>não enviar senha por mensagem;</li><li>não clicar em links suspeitos;</li><li>sair da conta em dispositivos públicos;</li><li>reportar acesso estranho imediatamente.</li></ul> +<ul><li>proteger a chave privada SSH (permissões restritas, sem cópias em pastas partilhadas ou repositórios);</li><li>não reutilizar a mesma chave privada em serviços em que não confia ou que possam vazar o material;</li><li>nunca enviar chave privada por mensagem (só a chave pública, quando for pedida por canal oficial);</li><li>não clicar em links suspeitos;</li><li>em dispositivos partilhados, encerrar a sessão SSH e não deixar a chave privada ou o agente SSH expostos sem critério;</li><li>reportar acesso estranho imediatamente.</li></ul> <h2>ACESSO SUSPENSO OU BLOQUEADO</h2> diff --git a/site/public/wiki/faq.html b/site/public/wiki/faq.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -47,10 +48,10 @@ O runv.club é um sistema de acesso, organização, suporte e uso em ambiente di Não.</p> <p>3. Como entro no sistema?<br> -O acesso depende do fluxo disponível no ambiente: cadastro, login e, quando aplicável, recuperação de senha ou outro método oficial de autenticação.</p> +Na pubnix runv.club o acesso é por SSH com chave (não por senha SSH). O pedido de conta segue o fluxo oficial (ver página “Junte-se” no site: chave Ed25519 e ligação a entre@runv.club); a equipa cria a conta após revisão.</p> -<p>4. Esqueci minha senha. O que faço?<br> -Use a opção oficial de recuperação de senha. Se o problema continuar, entre em contato por admin@runv.club.</p> +<p>4. Perdi a minha chave SSH ou não consigo entrar. O que faço?<br> +Não há recuperação por senha no SSH. Guarde bem a chave privada. Se a perdeu ou precisa de trocar a chave pública no servidor, escreva para admin@runv.club explicando o caso.</p> <p>5. Posso compartilhar minha conta com outra pessoa?<br> Não é recomendado e pode ser proibido, especialmente quando isso compromete segurança, auditoria, responsabilidade da conta ou regras do sistema.</p> @@ -77,7 +78,7 @@ Pode haver registro técnico e histórico operacional para fins de segurança, s Não. Exposição de dados pessoais, documentos, contatos, prints privados ou informações sensíveis sem autorização pode gerar punição grave.</p> <p>13. O que fazer se eu suspeitar que minha conta foi invadida?<br> -Troque a senha imediatamente, encerre sessões suspeitas se isso existir no sistema e comunique o caso para admin@runv.club.</p> +Se suspeitar que a chave privada foi copiada ou o seu PC comprometido: pare de usar essa chave, gere um par novo, não reutilize o material vazado e comunique de imediato admin@runv.club para a equipa avaliar substituição da chave pública no servidor e rever a conta.</p> <p>14. Como falar com a administração?<br> Pelo e-mail oficial: admin@runv.club</p> diff --git a/site/public/wiki/index.html b/site/public/wiki/index.html @@ -4,10 +4,11 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Wiki — runv.club</title> - <meta name="description" content="Mapa e índice da wiki runv.club."> + <meta name="description" content="Início da wiki runv.club: regras, contas, privacidade e FAQ."> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -38,41 +39,9 @@ </header> <main class="section prose-block subpage-main wiki-main"> -<nav class="wiki-toc" aria-label="Nesta wiki"><p class="wiki-toc-title">Páginas</p><ul> -<li><a href="/wiki/visao-geral.html">Visão geral</a></li> -<li><a href="/wiki/contas-e-acesso.html">Contas e acesso</a></li> -<li><a href="/wiki/regras-da-comunidade.html">Regras</a></li> -<li><a href="/wiki/punicoes-e-moderacao.html">Punições</a></li> -<li><a href="/wiki/privacidade-e-seguranca.html">Privacidade</a></li> -<li><a href="/wiki/faq.html">FAQ wiki</a></li> -</ul></nav> <h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - WIKI BASE (TXT)</h1> -<p>Esta pasta reúne uma base inicial de wiki para o sistema runv.club.<br> -O objetivo é organizar, de forma simples e reutilizável, as informações principais de operação, acesso, regras de convivência, moderação, privacidade e FAQ.</p> - -<h2>ARQUIVOS DESTA WIKI</h2> - -<p>1. 01_index.txt<br> - Visão geral da wiki e mapa dos arquivos.</p> - -<p>2. 02_visao-geral.txt<br> - Explica o que é o runv.club, para quem serve e quais são seus objetivos.</p> - -<p>3. 03_contas-e-acesso.txt<br> - Cadastro, login, recuperação de senha, acesso por terceiros e boas práticas de conta.</p> - -<p>4. 04_regras-da-comunidade.txt<br> - Regras de convivência, conduta aceitável e conduta proibida.</p> - -<p>5. 05_punicoes-e-moderacao.txt<br> - Causas para advertência, suspensão, banimento e processo de análise.</p> - -<p>6. 06_privacidade-e-seguranca.txt<br> - Diretrizes gerais de privacidade, segurança e responsabilidade do usuário.</p> - -<p>7. 07_faq.txt<br> - Perguntas frequentes do sistema, incluindo dúvidas de acesso, uso e suporte.</p> +<p>Esta wiki reúne, de forma simples, as informações principais de operação, acesso, regras de convivência, moderação, privacidade e FAQ do runv.club. Use os links no topo para ir a cada secção.</p> <h2>CONTATO OFICIAL</h2> diff --git a/site/public/wiki/privacidade-e-seguranca.html b/site/public/wiki/privacidade-e-seguranca.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -52,12 +53,12 @@ <h2>O USUÁRIO TAMBÉM TEM RESPONSABILIDADE</h2> -<p>Não adianta exigir segurança da plataforma e depois usar senha fraca, compartilhar conta, clicar em golpe e ignorar alertas.<br> +<p>Não adianta exigir segurança da plataforma e depois tratar a chave privada com descuido, compartilhar conta, clicar em golpe e ignorar alertas.<br> Segurança é responsabilidade dividida.</p> <h2>O QUE O USUÁRIO DEVE EVITAR</h2> -<ul><li>compartilhar credenciais;</li><li>publicar dados pessoais de terceiros;</li><li>usar dispositivos inseguros para acesso sensível;</li><li>ignorar indícios de invasão ou comprometimento;</li><li>instalar extensões suspeitas ou software duvidoso no dispositivo usado para login.</li></ul> +<ul><li>compartilhar chaves ou credenciais;</li><li>publicar dados pessoais de terceiros;</li><li>usar dispositivos inseguros para acesso sensível;</li><li>ignorar indícios de invasão ou comprometimento;</li><li>instalar extensões suspeitas ou software duvidoso no dispositivo usado para SSH ou e-mail.</li></ul> <h2>INCIDENTES DE SEGURANÇA</h2> diff --git a/site/public/wiki/punicoes-e-moderacao.html b/site/public/wiki/punicoes-e-moderacao.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> diff --git a/site/public/wiki/regras-da-comunidade.html b/site/public/wiki/regras-da-comunidade.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> diff --git a/site/public/wiki/visao-geral.html b/site/public/wiki/visao-geral.html @@ -8,6 +8,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -54,7 +55,7 @@ O usuário precisa entender onde entra, o que faz e onde resolve problemas.</p> <p>2. Acesso simples<br> - Cadastro, login, recuperação de senha e suporte devem ser diretos.</p> + Pedido de conta, autenticação por chave SSH e suporte devem ser claros e seguir os canais oficiais (sem depender de cadastro web automático nem de senha SSH).</p> <p>3. Segurança<br> A conta é individual e deve ser protegida.</p> @@ -71,7 +72,7 @@ <h2>O QUE O USUÁRIO ENCONTRA NO SISTEMA</h2> -<ul><li>área de acesso;</li><li>informações operacionais;</li><li>regras de uso;</li><li>canais de suporte;</li><li>respostas para dúvidas frequentes;</li><li>políticas de moderação e segurança.</li></ul> +<ul><li>acesso shell e espaço de utilizador na pubnix (tipicamente por SSH com chave);</li><li>informações operacionais;</li><li>regras de uso;</li><li>canais de suporte;</li><li>respostas para dúvidas frequentes;</li><li>políticas de moderação e segurança.</li></ul> <h2>O QUE O SISTEMA NÃO É</h2> diff --git a/site/wiki/01_index.txt b/site/wiki/01_index.txt @@ -1,30 +1,6 @@ RUNV.CLUB - WIKI BASE (TXT) -Esta pasta reúne uma base inicial de wiki para o sistema runv.club. -O objetivo é organizar, de forma simples e reutilizável, as informações principais de operação, acesso, regras de convivência, moderação, privacidade e FAQ. - -ARQUIVOS DESTA WIKI - -1. 01_index.txt - Visão geral da wiki e mapa dos arquivos. - -2. 02_visao-geral.txt - Explica o que é o runv.club, para quem serve e quais são seus objetivos. - -3. 03_contas-e-acesso.txt - Cadastro, login, recuperação de senha, acesso por terceiros e boas práticas de conta. - -4. 04_regras-da-comunidade.txt - Regras de convivência, conduta aceitável e conduta proibida. - -5. 05_punicoes-e-moderacao.txt - Causas para advertência, suspensão, banimento e processo de análise. - -6. 06_privacidade-e-seguranca.txt - Diretrizes gerais de privacidade, segurança e responsabilidade do usuário. - -7. 07_faq.txt - Perguntas frequentes do sistema, incluindo dúvidas de acesso, uso e suporte. +Esta wiki reúne, de forma simples, as informações principais de operação, acesso, regras de convivência, moderação, privacidade e FAQ do runv.club. Use os links no topo para ir a cada secção. CONTATO OFICIAL diff --git a/site/wiki/02_visao-geral.txt b/site/wiki/02_visao-geral.txt @@ -14,7 +14,7 @@ PRINCÍPIOS BÁSICOS O usuário precisa entender onde entra, o que faz e onde resolve problemas. 2. Acesso simples - Cadastro, login, recuperação de senha e suporte devem ser diretos. + Pedido de conta, autenticação por chave SSH e suporte devem ser claros e seguir os canais oficiais (sem depender de cadastro web automático nem de senha SSH). 3. Segurança A conta é individual e deve ser protegida. @@ -31,7 +31,7 @@ O runv.club não deve ser tratado como um ambiente fechado para um único perfil O QUE O USUÁRIO ENCONTRA NO SISTEMA -- área de acesso; +- acesso shell e espaço de utilizador na pubnix (tipicamente por SSH com chave); - informações operacionais; - regras de uso; - canais de suporte; diff --git a/site/wiki/03_contas-e-acesso.txt b/site/wiki/03_contas-e-acesso.txt @@ -2,17 +2,19 @@ RUNV.CLUB - CONTAS E ACESSO CADASTRO +O pedido de conta segue o fluxo oficial do runv.club (por exemplo ligação SSH a entre@runv.club com a sua chave pública, conforme a página “Junte-se”); a equipa revê a fila e a conta Unix é criada pela administração — não há registo instantâneo automático na web. + O cadastro deve ser feito com informações verdadeiras e minimamente consistentes. Criar conta com identidade falsa para enganar terceiros, burlar punição, cometer fraude ou simular legitimidade é motivo para ação da moderação. LOGIN -O acesso ao sistema é individual. Cada usuário é responsável por proteger seu e-mail, senha e meios de autenticação vinculados à conta. +O acesso ao sistema é individual e, na pubnix, baseia-se em SSH com chave pública (conta Unix sem senha de login SSH). Cada usuário é responsável por proteger seu e-mail, a chave privada SSH e quaisquer outros meios de autenticação vinculados à conta. -RECUPERAÇÃO DE SENHA +RECUPERAÇÃO DE ACESSO -Se o usuário perder acesso à conta, deve usar os meios oficiais de recuperação disponíveis no sistema. -Se ainda assim não resolver, deve contatar: +Não há “esqueci a senha” no SSH: quem perde a chave privada, deixa de conseguir autenticar com o par que estava registado. +Nesse caso (ou se precisar substituir a chave pública no servidor), contacte: admin@runv.club COMPARTILHAMENTO DE CONTA @@ -31,11 +33,11 @@ Tudo o que for feito pela conta poderá ser atribuído ao titular até que exist BOAS PRÁTICAS DE SEGURANÇA -- usar senha forte; -- não reutilizar senha vazada em outros serviços; -- não enviar senha por mensagem; +- proteger a chave privada SSH (permissões restritas, sem cópias em pastas partilhadas ou repositórios); +- não reutilizar a mesma chave privada em serviços em que não confia ou que possam vazar o material; +- nunca enviar chave privada por mensagem (só a chave pública, quando for pedida por canal oficial); - não clicar em links suspeitos; -- sair da conta em dispositivos públicos; +- em dispositivos partilhados, encerrar a sessão SSH e não deixar a chave privada ou o agente SSH expostos sem critério; - reportar acesso estranho imediatamente. ACESSO SUSPENSO OU BLOQUEADO diff --git a/site/wiki/06_privacidade-e-seguranca.txt b/site/wiki/06_privacidade-e-seguranca.txt @@ -12,16 +12,16 @@ A administração deve tratar os dados com cuidado, limitar acessos internos e r O USUÁRIO TAMBÉM TEM RESPONSABILIDADE -Não adianta exigir segurança da plataforma e depois usar senha fraca, compartilhar conta, clicar em golpe e ignorar alertas. +Não adianta exigir segurança da plataforma e depois tratar a chave privada com descuido, compartilhar conta, clicar em golpe e ignorar alertas. Segurança é responsabilidade dividida. O QUE O USUÁRIO DEVE EVITAR -- compartilhar credenciais; +- compartilhar chaves ou credenciais; - publicar dados pessoais de terceiros; - usar dispositivos inseguros para acesso sensível; - ignorar indícios de invasão ou comprometimento; -- instalar extensões suspeitas ou software duvidoso no dispositivo usado para login. +- instalar extensões suspeitas ou software duvidoso no dispositivo usado para SSH ou e-mail. INCIDENTES DE SEGURANÇA diff --git a/site/wiki/07_faq.txt b/site/wiki/07_faq.txt @@ -7,10 +7,10 @@ O runv.club é um sistema de acesso, organização, suporte e uso em ambiente di Não. 3. Como entro no sistema? -O acesso depende do fluxo disponível no ambiente: cadastro, login e, quando aplicável, recuperação de senha ou outro método oficial de autenticação. +Na pubnix runv.club o acesso é por SSH com chave (não por senha SSH). O pedido de conta segue o fluxo oficial (ver página “Junte-se” no site: chave Ed25519 e ligação a entre@runv.club); a equipa cria a conta após revisão. -4. Esqueci minha senha. O que faço? -Use a opção oficial de recuperação de senha. Se o problema continuar, entre em contato por admin@runv.club. +4. Perdi a minha chave SSH ou não consigo entrar. O que faço? +Não há recuperação por senha no SSH. Guarde bem a chave privada. Se a perdeu ou precisa de trocar a chave pública no servidor, escreva para admin@runv.club explicando o caso. 5. Posso compartilhar minha conta com outra pessoa? Não é recomendado e pode ser proibido, especialmente quando isso compromete segurança, auditoria, responsabilidade da conta ou regras do sistema. @@ -37,7 +37,7 @@ Pode haver registro técnico e histórico operacional para fins de segurança, s Não. Exposição de dados pessoais, documentos, contatos, prints privados ou informações sensíveis sem autorização pode gerar punição grave. 13. O que fazer se eu suspeitar que minha conta foi invadida? -Troque a senha imediatamente, encerre sessões suspeitas se isso existir no sistema e comunique o caso para admin@runv.club. +Se suspeitar que a chave privada foi copiada ou o seu PC comprometido: pare de usar essa chave, gere um par novo, não reutilize o material vazado e comunique de imediato admin@runv.club para a equipa avaliar substituição da chave pública no servidor e rever a conta. 14. Como falar com a administração? Pelo e-mail oficial: admin@runv.club diff --git a/site/wiki/build_wiki.py b/site/wiki/build_wiki.py @@ -117,6 +117,7 @@ def page_shell( <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="stylesheet" href="../assets/style.css"> </head> <body> @@ -237,18 +238,11 @@ def main() -> int: article = txt_to_article_body(raw) if slug == "index": - toc = ['<nav class="wiki-toc" aria-label="Nesta wiki"><p class="wiki-toc-title">Páginas</p><ul>'] - for s, lab in nav_pages: - if s == "index": - continue - toc.append( - f'<li><a href="/wiki/{html.escape(s, quote=True)}.html">{html.escape(lab)}</a></li>' - ) - toc.append("</ul></nav>") - body_main = "\n".join(toc) + "\n" + article + # Navegação já está em hero-nav; o corpo vem só de 01_index.txt. + body_main = article out_name = "index.html" current = "index" - desc = "Mapa e índice da wiki runv.club." + desc = "Início da wiki runv.club: regras, contas, privacidade e FAQ." else: body_main = article out_name = f"{slug}.html" diff --git a/terminal/entre_core.py b/terminal/entre_core.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 """ -Lógica partilhada do fluxo SSH «entre» (runv.club): validação, fila, log, email. +Coisas comuns ao login «entre»: validar username/email/chave, escrever JSON na fila, log, mail. -Mantido alinhado com as regras de ``scripts/admin/create_runv_user.py`` (username, -email, tipos de chave). Campo ``online_presence`` é texto livre na fila (não duplicado -em ``create_runv_user``). Sem dependências PyPI. +Regras de nome e chave batem com o que ``create_runv_user.py`` aceita; o texto ``online_presence`` +só existe aqui na fila. PyPI não entra. -Versão 0.02 — runv.club +v0.02 — runv.club """ from __future__ import annotations @@ -14,6 +13,7 @@ from __future__ import annotations import json import logging import os +import sys import time import pwd import re @@ -310,6 +310,66 @@ def log_session(logger: logging.Logger, msg: str, *, level: int = logging.INFO) logger.log(level, msg) +def _try_runv_mailgun_notify( + *, + admin_email: str, + mail_from: str, + subject: str, + body: str, + logger: logging.Logger, +) -> bool: + """ + Se ``/etc/runv-email.json`` indicar Mailgun, envia via ``lib.mailer.send_mail``. + Requer ``RUNV_EMAIL_ROOT`` ou ``email_package_root`` no JSON apontando à pasta ``email/``. + """ + state_path = Path("/etc/runv-email.json") + if not state_path.is_file(): + return False + try: + data = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + be = str(data.get("backend", "")).lower() + mailgun = be == "mailgun" or ( + bool(data.get("mailgun_domain")) + and bool(data.get("mailgun_region")) + and be != "sendmail" + ) + if not mailgun: + return False + root = os.environ.get("RUNV_EMAIL_ROOT", "").strip() + if not root: + root = str(data.get("email_package_root", "")).strip() + if not root: + logger.warning( + "notificação Mailgun: defina email_package_root em %s ou a variável RUNV_EMAIL_ROOT.", + state_path, + ) + return False + email_root = str(Path(root).resolve()) + if email_root not in sys.path: + sys.path.insert(0, email_root) + try: + from lib.mailer import send_mail + except ImportError as e: + logger.warning("notificação Mailgun: import lib.mailer falhou: %s", e) + return False + from_addr = mail_from.strip() or DEFAULT_MAIL_FROM + try: + send_mail( + admin_email.strip(), + subject, + body, + from_addr=from_addr, + _state=data, + ) + except Exception as e: + logger.warning("notificação Mailgun falhou: %s", e) + return False + logger.info("notificação por email (Mailgun API) enviada para %s", admin_email) + return True + + def sendmail_notify( *, admin_email: str, @@ -322,6 +382,14 @@ def sendmail_notify( if not admin_email.strip(): logger.info("notificação por email: admin_email vazio, ignorado.") return + if _try_runv_mailgun_notify( + admin_email=admin_email, + mail_from=mail_from, + subject=subject, + body=body, + logger=logger, + ): + return if not Path(sendmail_path).is_file(): logger.warning( "notificação por email: sendmail não encontrado em %s — pedido continua gravado.", diff --git a/tools/docs/INSTALL.md b/tools/docs/INSTALL.md @@ -18,13 +18,15 @@ Não é necessário Docker, banco de dados nem painel web. 3. Executa **`apt-get update -qq`** e **`apt-get install -y --no-install-recommends`** com esses pacotes. 4. Copia **`bin/runv-help`**, **`runv-links`**, **`runv-status`**, **`bin/chat`** → **`/usr/local/bin/`** com modo **755** (`chat` abre o IRC com config em `~/.config/weechat`; ver **`scripts/docs/irc_patch.md`**). 5. Copia **`motd/60-runv`** → **`/etc/update-motd.d/60-runv`** com modo **755**. -6. Copia o **`skel/`** do repositório para **`/etc/skel/`**: - - `README.md` → **644** +6. Garante **Jailkit + SSH chroot** (idempotente): grupo **`runv-jailed`**, remove **`pmurad-admin`** desse grupo se estiver, instala **`/etc/ssh/sshd_config.d/90-runv-jailed.conf`** a partir do repo, **`sshd -t`** e **`systemctl reload ssh`** (ou `sshd`). +7. Copia o **`skel/`** do repositório para **`/etc/skel/`**: - `.bash_aliases` → **644** - `public_html/index.html` → diretório **`public_html` 755**, arquivo **644** - `public_gopher/gophermap` → diretório **`public_gopher` 755**, arquivo **644** - `public_gemini/index.gmi` → diretório **`public_gemini` 755**, arquivo **644** + O **`README.md` não** é copiado para `/etc/skel` (política runv: contas novas sem README na home por defeito). Se existir **`/etc/skel/README.md`** de uma instalação antiga, o `tools.py` **remove** esse ficheiro ao sincronizar o skel. + O **`/etc/skel`** só afeta **contas novas** criadas depois da cópia (o Debian copia o skel no `adduser`). Utilizadores **já existentes** não recebem automaticamente estes ficheiros: use **[`scripts/admin/setup_alt_protocols.py`](../../scripts/docs/alt_protocols.md)** (backfill) ou crie `~/public_gopher` e `~/public_gemini` manualmente. Se o destino **já existir** e for **idêntico** (conteúdo byte-a-byte) à origem no repositório, a cópia é **ignorada**. Se o ficheiro no repo **mudou**, o `tools.py` **atualiza** o destino mesmo sem **`--force`**. Use **`--force`** para sobrescrever sempre (útil para repor permissões/mtime ou forçar cópia igual). @@ -54,13 +56,13 @@ sudo python3 tools/tools.py --dry-run --verbose ## Verificar pacotes instalados ```bash -dpkg -l byobu tmux lynx weechat weechat-headless mutt bsdgames tree less curl wget git +dpkg -l wtmpdb jailkit byobu tmux lynx weechat weechat-headless mutt bsdgames tree less curl wget git ``` Ou: ```bash -apt list --installed 2>/dev/null | grep -E 'byobu|tmux|lynx|weechat|mutt|bsdgames|tree|less|curl|wget|git' +apt list --installed 2>/dev/null | grep -E 'wtmpdb|jailkit|byobu|tmux|lynx|weechat|mutt|bsdgames|tree|less|curl|wget|git' ``` **Importante:** esses programas são **globais**. **Não** dependem do `/etc/skel`. Qualquer usuário com shell pode usá-los após a instalação (e após login, se o pacote estiver no `PATH`). @@ -100,12 +102,19 @@ ls -la /etc/skel/public_html/ Esperado: -- `README.md` e `.bash_aliases` com permissões **644** (arquivos). +- `.bash_aliases` com permissões **644** (arquivo). +- **Sem** `README.md` em `/etc/skel` após sincronização. - `public_html` como diretório **755**. - `public_html/index.html` **644**. Novas contas criadas com `adduser` **depois** desta instalação recebem esses arquivos na home (junto com o restante do skel padrão do Debian, como `.bashrc`, se existir no sistema). +## SSH: grupo `runv-jailed` (chroot) + +O drop-in **`90-runv-jailed.conf`** aplica `ChrootDirectory /srv/jail/%u` a utilizadores no grupo **`runv-jailed`**. A conta **`pmurad-admin`** **não** deve estar nesse grupo (administração fora do chroot). Novas contas normais recebem jail e grupo via **`scripts/admin/create_runv_user.py`**; contas já existentes via **`scripts/admin/perm1.py`**. + +Após alterar o sshd, confirme **`sshd -t`** e teste login com um utilizador de staging antes de aplicar em produção. + ## Instruções de teste (checklist) 1. **Dry-run:** `sudo python3 tools/tools.py --dry-run --verbose` — revisar saída. @@ -113,12 +122,12 @@ Novas contas criadas com `adduser` **depois** desta instalação recebem esses a 3. **Segunda execução** sem `--force` com repo **inalterado** — deve **pular** ficheiros já iguais; após **editar** MOTD/bin/skel no repo, a mesma execução deve **copiar de novo**. 4. **`runv-help` / `runv-links`** — qualquer utilizador; **`runv-status`** — apenas como **`pmurad-admin`**. 5. **MOTD:** rodar `/etc/update-motd.d/60-runv` ou novo login SSH. -6. **Skel:** criar usuário de teste com `adduser` e conferir `~usuario/README.md` e `~/public_html/index.html`. +6. **Skel:** criar usuário de teste com `adduser` e conferir **ausência** de `~usuario/README.md` e presença de `~/public_html/index.html` (se o skel runv tiver sido aplicado). ## Problemas comuns - **apt-get update falha:** corrija espelhos/rede; o script registra erro e ainda pode copiar bin/MOTD/skel. - **Permissão negada:** execute com `sudo` / root. - **MOTD não aparece:** em alguns setups o display do MOTD depende de `pam_motd` e SSH; confira configuração do `sshd` e PAM no Debian. -- **MOTD sem grelha `last`:** o fragmento `60-runv` usa `/usr/bin/last` quando o PATH mínimo não o expõe; confirme **util-linux** e permissões de leitura em `/var/log/wtmp`. A mensagem *sem registos recentes em wtmp* indica wtmp vazio, não falta do binário. +- **MOTD sem grelha `last`:** em **Debian 13+**, o comando **`last`** vem do pacote **`wtmpdb`**, não de `util-linux`. Instale com `apt install wtmpdb`. O fragmento `60-runv` tenta `last` em PATH, `/usr/bin/last` e `/bin/last`. A mensagem *sem registos recentes em wtmp* indica wtmp vazio, não falta do binário. - **Gemini (`molly-brown`) inactivo ou «activating»:** guia de diagnóstico (journalctl com `sudo`, porta 1965, permissões da chave TLS) em **`scripts/docs/alt_protocols.md`** — secção *Molly não sobe ou fica em «activating»*. diff --git a/tools/docs/USER_EXPERIENCE.md b/tools/docs/USER_EXPERIENCE.md @@ -8,7 +8,7 @@ Visão para **quem entra no servidor** pela primeira vez (e para quem documenta - logótipo **RUNV** (mesmo desenho UTF-8 da landing) **só nesse bloco** em verde; - tagline `.club — um computador para compartilhar` (sem estatísticas no MOTD; o comando **`runv-status`** existe mas **não** é listado aqui e só o utilizador **`pmurad-admin`** pode executá-lo); - **Comandos úteis** em lista, com nome a verde e descrição a cinza (ANSI), alinhada ao texto do `runv-help`; - - grelha **3×3** com os **primeiros campos** das **9** sessões mais recentes de **`last`** (wtmp; ignora linhas `reboot` / `wtmp`). O script tenta **`/usr/bin/last`** se o PATH de `update-motd.d` não incluir `last` (pacote **util-linux**). Se aparecer *sem registos recentes em wtmp*, o ficheiro de logins ainda não tem entradas (ex.: sem logins SSH registados). + - grelha **3×3** com os **primeiros campos** das **9** sessões mais recentes de **`last`** (wtmp; ignora linhas `reboot` / `wtmp`). Em **Debian 13+**, o binário **`last`** vem do pacote **`wtmpdb`** (o `tools.py` instala-o). O fragmento tenta **`/usr/bin/last`** se o PATH de `update-motd.d` não incluir `last`. Se aparecer *sem registos recentes em wtmp*, o ficheiro de logins ainda não tem entradas (ex.: sem logins SSH registados). - linha final: **digite `runv-help` para começar**. 2. **Prompt da shell** — Depende do shell padrão (geralmente Bash no Debian). O que o usuário **herda** da home vem do **`/etc/skel`** no momento em que a conta foi criada. @@ -25,12 +25,13 @@ Todos são **shell scripts** em **`/usr/local/bin`**, com cores ANSI simples, te ## O que o usuário recebe na home (contas novas) -Quando um administrador cria a conta com **`adduser`**, o Debian copia **`/etc/skel`** para a home. Depois de rodar o módulo **`tools/`**, o skel inclui: +Quando um administrador cria a conta com **`adduser`**, o Debian copia **`/etc/skel`** para a home. Depois de rodar o módulo **`tools/`**, o skel inclui (entre o que o Debian já traz, como `.bashrc` quando aplicável): -- **`README.md`** — explicação acolhedora: site em `~/public_html`, permissões, `runv-help`, aviso sobre arquivos públicos. - **`.bash_aliases`** — atalhos (`ll`, `la`, `l`, `help-runv`). - **`public_html/index.html`** — página inicial mínima em HTML estático (sem JS, sem CDN), em português. +**Não** há **`README.md`** no skel runv: orientação inicial está no **MOTD** e no comando **`runv-help`**. Quem quiser um README na home pode criar manualmente ou o admin pode usar **`create_runv_user.py --with-readme`** ao provisionar. + **Observação:** no Bash do Debian, o arquivo **`~/.bashrc`** costuma ter (por padrão) um bloco que carrega **`~/.bash_aliases`** se existir. Se o usuário remover esse trecho do `.bashrc`, os aliases deixam de carregar — isso é comportamento padrão do Debian, não do runv. ## Programas globais (apt) @@ -50,8 +51,7 @@ Pacotes listados em **`manifests/apt_packages.txt`** (incluindo ferramentas de t ## Como isso ajuda iniciantes - **MOTD** orienta na hora do login (**`runv-help`**). -- **`README.md`** na home repete conceitos com calma (site, permissões). -- **`runv-links`** centraliza URLs oficiais. +- **`runv-help`** e **`runv-links`** explicam o pubnix, permissões e URLs oficiais. - Administradores com a conta **`pmurad-admin`** podem usar **`runv-status`** para contexto do servidor (outros utilizadores recebem recusa explícita). Juntos, reduzem fricção para quem nunca usou pubnix ou SSH no dia a dia. diff --git a/tools/manifests/apt_packages.txt b/tools/manifests/apt_packages.txt @@ -2,6 +2,11 @@ # Um pacote por linha; linhas vazias e # são ignoradas. # Instalados para todo o sistema; não passam pelo /etc/skel. +# Debian 13+ (Trixie): o binário /usr/bin/last vem de wtmpdb, não de util-linux. +wtmpdb +# Chroot SSH por utilizador (jk_init, etc.) +jailkit + byobu tmux lynx diff --git a/tools/motd/60-runv b/tools/motd/60-runv @@ -34,7 +34,7 @@ print_last_sessions_3x3() { [ -z "$LAST_CMD" ] && [ -x /usr/bin/last ] && LAST_CMD=/usr/bin/last [ -z "$LAST_CMD" ] && [ -x /bin/last ] && LAST_CMD=/bin/last if [ -z "$LAST_CMD" ]; then - printf ' %b\n' "${D}(comando last indisponível — instale util-linux ou ajuste o PATH)${R}" + printf ' %b\n' "${D}(comando last indisponível — em Debian 13+ instale o pacote wtmpdb: apt install wtmpdb)${R}" return fi tf=$(mktemp -t runvmotd.XXXXXX 2>/dev/null) || tf=/tmp/runvmotd.$$ diff --git a/tools/sshd/90-runv-jailed.conf b/tools/sshd/90-runv-jailed.conf @@ -0,0 +1,7 @@ +Match Group runv-jailed + ChrootDirectory /srv/jail/%u + X11Forwarding no + AllowTcpForwarding no + AllowAgentForwarding no + PermitTunnel no + DisableForwarding yes diff --git a/tools/tools.py b/tools/tools.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -runv.club — ferramentas globais, MOTD, comandos em /usr/local/bin e /etc/skel. +runv.club — ferramentas globais, MOTD, Jailkit/SSH runv-jailed, comandos em /usr/local/bin e /etc/skel. Debian 13 · Python 3 stdlib apenas · sem shell=True. Execute como root. Ver tools/README.md e tools/docs/INSTALL.md. @@ -28,10 +28,12 @@ _APT_PACKAGE_ALIASES: dict[str, str] = { BIN_DIR: Path = TOOL_ROOT / "bin" MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv" SKEL_DIR: Path = TOOL_ROOT / "skel" +SSHD_DROPIN_SRC: Path = TOOL_ROOT / "sshd" / "90-runv-jailed.conf" DEST_BIN_DIR: Path = Path("/usr/local/bin") DEST_MOTD: Path = Path("/etc/update-motd.d/60-runv") DEST_SKEL: Path = Path("/etc/skel") +DEST_SSHD_DROPIN: Path = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf") @dataclass @@ -236,6 +238,105 @@ def install_motd( ) +def remove_obsolete_skel_readme( + *, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + stale = DEST_SKEL / "README.md" + if not stale.is_file(): + return + if dry_run: + log.info("[dry-run] removeria %s", stale) + summary.copied.append(f"rm {stale} (simulado)") + return + try: + stale.unlink() + log.info("Removido (política skel): %s", stale) + summary.copied.append(f"removido {stale}") + except OSError as e: + summary.errors.append(f"remover {stale}: {e}") + log.error("Não foi possível remover %s: %s", stale, e) + + +def ensure_jailkit_ssh_baseline( + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + if dry_run: + log.info("[dry-run] groupadd -f runv-jailed; gpasswd -d pmurad-admin; sshd drop-in; reload ssh") + return + r = subprocess.run( + ["groupadd", "-f", "runv-jailed"], + capture_output=True, + text=True, + timeout=60, + ) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + msg = f"groupadd -f runv-jailed falhou: {err}" + summary.errors.append(msg) + log.error("%s", msg) + return + log.info("Grupo runv-jailed garantido") + + r = subprocess.run( + ["gpasswd", "-d", "pmurad-admin", "runv-jailed"], + capture_output=True, + text=True, + timeout=60, + ) + if r.returncode != 0: + log.debug("gpasswd -d pmurad-admin (esperado se não estava no grupo): %s", (r.stderr or "").strip()) + + copy_one( + SSHD_DROPIN_SRC, + DEST_SSHD_DROPIN, + 0o644, + force=force, + dry_run=False, + log=log, + summary=summary, + ) + if summary.errors: + return + + test = subprocess.run( + ["sshd", "-t"], + capture_output=True, + text=True, + timeout=30, + ) + if test.returncode != 0: + err = (test.stderr or test.stdout or "").strip() + msg = f"sshd -t falhou após instalar drop-in: {err}" + summary.errors.append(msg) + log.error("%s", msg) + return + + reloaded = False + for unit in ("ssh", "sshd"): + rr = subprocess.run( + ["systemctl", "reload", unit], + capture_output=True, + text=True, + timeout=60, + ) + if rr.returncode == 0: + log.info("systemctl reload %s concluído", unit) + reloaded = True + break + log.debug("systemctl reload %s: %s", unit, (rr.stderr or rr.stdout or "").strip()) + if not reloaded: + msg = "systemctl reload ssh/sshd falhou — recarregue o sshd manualmente" + summary.errors.append(msg) + log.error("%s", msg) + + def install_skel( *, force: bool, @@ -247,8 +348,9 @@ def install_skel( if not dry_run: DEST_SKEL.mkdir(parents=True, exist_ok=True) + remove_obsolete_skel_readme(dry_run=dry_run, log=log, summary=summary) + skel_files: list[tuple[Path, Path, int]] = [ - (SKEL_DIR / "README.md", DEST_SKEL / "README.md", 0o644), (SKEL_DIR / ".bash_aliases", DEST_SKEL / ".bash_aliases", 0o644), ] for src, dst, mode in skel_files: @@ -426,6 +528,14 @@ def main(argv: list[str] | None = None) -> int: log.info("Instalando MOTD em %s", DEST_MOTD) install_motd(force=args.force, dry_run=args.dry_run, log=log, summary=summary) + log.info("Jailkit / SSH runv-jailed (grupo, drop-in, reload)") + ensure_jailkit_ssh_baseline( + force=args.force, + dry_run=args.dry_run, + log=log, + summary=summary, + ) + log.info("Sincronizando skel em %s", DEST_SKEL) install_skel(force=args.force, dry_run=args.dry_run, log=log, summary=summary)