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:
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)