commit e90c55a82bafe6d3ec8a5e5afd8b71c911732ed7
parent 6da121f22f0c41023ccbe5b2f46565287127c8b6
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 20:34:45 -0300
fixed a lot of stuff
Diffstat:
31 files changed, 716 insertions(+), 199 deletions(-)
diff --git a/email/docs/INTEGRATION.md b/email/docs/INTEGRATION.md
@@ -50,7 +50,7 @@ Com **Mailgun**, `sendmail` é ignorado para o transporte (usa API). Com **legad
| Pedido aprovado (manual) | `user_approved` | Processo admin (manual / futuro). |
| Pedido rejeitado | `user_rejected` (+ `reason`) | Idem. |
| Conta criada | `admin_user_created` → admin; `user_account_created` → utilizador | [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py): `--no-welcome-email` / `--no-admin-create-email` para desactivar cada ramo. |
-| Conta removida | `admin_user_deleted`, `user_account_removed` | Templates em `email/templates/`; [`scripts/admin/del-user.py`](../../scripts/admin/del-user.py) **ainda não** envia estes emails (processo manual ou extensão futura). |
+| Conta removida / banimento | `user_account_community_deactivated` → utilizador | [`scripts/admin/del-user.py`](../../scripts/admin/del-user.py): envia por omissão se existir email em `users.json` e `/etc/runv-email.json` válido; `--no-ban-notify-email` desactiva. Templates `admin_user_deleted` / `user_account_removed` existem mas **não** estão ligados a este script. |
| Erro operacional | `admin_error` | Scripts admin / cron. |
| Quota | `user_quota_warning` | Monitorização / quotas. |
| Teste | `system_test` | `configure_mailgun.py --test` (API) ou legado. |
@@ -80,6 +80,8 @@ Requer `/etc/runv-email.json` (com `default_from`, `admin_email` para o ramo adm
Obtenha `admin_email` / `default_from` de `/etc/runv-email.json` — **não** hardcodar.
+O **`del-user.py`** envia **`user_account_community_deactivated`** ao endereço no campo `email` do registo em `/var/lib/runv/users.json` (lido **antes** de apagar o registo), com texto de desativação por descumprimento das normas da comunidade. Requer `default_from` e pasta `email/` acessível (`RUNV_EMAIL_ROOT` ou `email_package_root`). Com `--skip-metadata` ainda tenta ler o ficheiro de metadados para obter o email.
+
## Checklist de integração
- [ ] `RUNV_EMAIL_ROOT` ou `email_package_root` correcto para serviços Python e **entre**.
diff --git a/email/docs/notes b/email/docs/notes
@@ -0,0 +1,2 @@
+1 - criar email de advertência
+2 - criar digest
+\ No newline at end of file
diff --git a/email/lib/templates.py b/email/lib/templates.py
@@ -2,7 +2,8 @@
Nomes canónicos dos templates de email (texto puro) em templates/.
Placeholders comuns: {username}, {email}, {request_id}, {admin_email},
-{default_from}, {host}, {reason}, {quota_info}, {timestamp}, {error_summary}
+{default_from}, {host}, {reason}, {quota_info}, {timestamp}, {error_summary}.
+O template ``user_request_received`` usa só ``{request_id}`` e ``{username}`` (rodapé fixo ``Equipe runv.club``).
"""
from __future__ import annotations
@@ -22,6 +23,7 @@ USER_REJECTED: Final[str] = "user_rejected"
USER_ACCOUNT_CREATED: Final[str] = "user_account_created"
USER_QUOTA_WARNING: Final[str] = "user_quota_warning"
USER_ACCOUNT_REMOVED: Final[str] = "user_account_removed"
+USER_ACCOUNT_COMMUNITY_DEACTIVATED: Final[str] = "user_account_community_deactivated"
# --- Sistema ---
SYSTEM_TEST: Final[str] = "system_test"
@@ -37,5 +39,6 @@ ALL_TEMPLATES: Final[tuple[str, ...]] = (
USER_ACCOUNT_CREATED,
USER_QUOTA_WARNING,
USER_ACCOUNT_REMOVED,
+ USER_ACCOUNT_COMMUNITY_DEACTIVATED,
SYSTEM_TEST,
)
diff --git a/email/templates/admin_new_request.txt b/email/templates/admin_new_request.txt
@@ -6,7 +6,7 @@ ID do pedido: {request_id}
Data (UTC): {timestamp}
Nome de utilizador desejado: {username}
-Email de contacto: {email}
+E-mail de contato: {email}
Fingerprint SHA256 da chave: {fingerprint}
---
diff --git a/email/templates/system_test.txt b/email/templates/system_test.txt
@@ -8,4 +8,4 @@ Domínio / host registado: {host}
URL base API (se Mailgun): {api_base_url}
Timestamp UNIX: {timestamp}
-Se recebeu esta mensagem, o backend configurado (Mailgun API ou SMTP legado) e a biblioteca estão operacionais.
+Se você recebeu esta mensagem, o backend configurado (Mailgun API ou SMTP legado) e a biblioteca estão operacionais.
diff --git a/email/templates/user_account_community_deactivated.txt b/email/templates/user_account_community_deactivated.txt
@@ -0,0 +1,10 @@
+runv.club — conta desativada
+
+Olá,
+
+A conta associada ao utilizador {username} ({email}) foi desativada por descumprimento das normas da comunidade do runv.club.
+
+Se você considera que esta decisão foi um erro, entre em contato com a administração com os detalhes que tiver.
+
+Cumprimentos,
+Equipe runv.club
diff --git a/email/templates/user_account_created.txt b/email/templates/user_account_created.txt
@@ -4,12 +4,12 @@ Olá {username},
A sua conta no runv.club foi criada.
-O endereço {email} é o contacto que temos em registo (metadado administrativo).
+O endereço {email} é o contato que temos cadastrado (metadado administrativo).
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.
+Para entrar no servidor, use a **chave privada OpenSSH** que corresponde à chave pública que você registrou.
+Nunca envie nem compartilhe essa chave privada com terceiros.
Impressão digital da chave pública no nosso sistema: {fingerprint}
@@ -21,4 +21,4 @@ A sua página de membro deverá estar disponível em:
{member_url}
Cumprimentos,
-Equipa runv.club
+Equipe runv.club
diff --git a/email/templates/user_account_removed.txt b/email/templates/user_account_removed.txt
@@ -4,7 +4,7 @@ Olá,
A conta associada ao utilizador {username} foi removida do runv.club.
-Se não esperava esta mensagem, contacte a administração.
+Se você não esperava esta mensagem, entre em contato com a administração.
Cumprimentos,
-Equipa runv.club
+Equipe runv.club
diff --git a/email/templates/user_approved.txt b/email/templates/user_approved.txt
@@ -4,7 +4,7 @@ Olá,
O seu pedido de entrada (referência {request_id}) foi aprovado pela administração.
-Os próximos passos ser-lhe-ão comunicados por email ou pelo processo habitual do serviço.
+O acesso ao runv.club é por **SSH**. Use a **chave privada OpenSSH** que corresponde à chave pública que você enviou no pedido. Não compartilhe a chave privada com ninguém.
Cumprimentos,
-Equipa runv.club
+Equipe runv.club
diff --git a/email/templates/user_quota_warning.txt b/email/templates/user_quota_warning.txt
@@ -6,7 +6,7 @@ O seu uso de disco aproxima-se ou excedeu o limite configurado.
Detalhes: {quota_info}
-Liberte espaço ou contacte a administração se precisar de assistência.
+Libere espaço ou entre em contato com a administração se precisar de ajuda.
Cumprimentos,
-Equipa runv.club
+Equipe runv.club
diff --git a/email/templates/user_rejected.txt b/email/templates/user_rejected.txt
@@ -7,7 +7,7 @@ O seu pedido de entrada (referência {request_id}) não foi aprovado.
Motivo indicado pela administração (se aplicável):
{reason}
-Para mais esclarecimentos, contacte a administração pelo canal habitual.
+Para mais esclarecimentos, fale com a administração pelo canal habitual.
Cumprimentos,
-Equipa runv.club
+Equipe runv.club
diff --git a/email/templates/user_request_received.txt b/email/templates/user_request_received.txt
@@ -2,12 +2,12 @@ runv.club — pedido recebido
Olá,
-O seu pedido de entrada foi registado com sucesso.
+O seu pedido de entrada foi registrado com sucesso.
Referência: {request_id}
Nome de utilizador pedido: {username}
-A equipa irá analisar o pedido. Não é necessário reenviar a mesma informação várias vezes.
+A equipe vai analisar o pedido. Não é necessário reenviar a mesma informação várias vezes.
Cumprimentos,
-{default_from}
+Equipe runv.club
diff --git a/patches/undoperm.py b/patches/undoperm.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+"""
+Reverte o efeito típico de ``scripts/admin/perm1.py`` para utilizadores uid>=1000:
+
+- remove o utilizador do grupo ``runv-jailed``;
+- desmonta o bind em ``/srv/jail/<user>/home/<user>`` se estiver montado;
+- remove a linha correspondente em ``/etc/fstab``;
+- opcionalmente ``--purge-jail-dir`` apaga ``/srv/jail/<user>`` (perigoso).
+
+**Não** restaura ficheiros alterados por ``jk_init``. Executar como root (salvo ``--dry-run``).
+
+Versão 0.01 — runv.club (patch na raiz do repositório)
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+import pwd
+import shutil
+import sys
+from pathlib import Path
+
+_PATCHES_DIR = Path(__file__).resolve().parent
+_REPO_ROOT = _PATCHES_DIR.parent
+_ADMIN_DIR = _REPO_ROOT / "scripts" / "admin"
+if str(_ADMIN_DIR) not in sys.path:
+ sys.path.insert(0, str(_ADMIN_DIR))
+
+import runv_jail as rj
+
+EXCLUDE_NAMES = frozenset({"nobody", "pmurad-admin", "entre"})
+VERSION = "0.01"
+
+
+def setup_logging(verbose: bool) -> logging.Logger:
+ log = logging.getLogger("undoperm")
+ 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="Reverte runv-jailed + bind + fstab aplicados por perm1 (uid>=1000).",
+ )
+ p.add_argument("--dry-run", action="store_true", help="só listar 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)",
+ )
+ p.add_argument(
+ "--purge-jail-dir",
+ action="store_true",
+ help="apaga /srv/jail/<user> após umount (DESTRUTIVO; default: não apagar)",
+ )
+ p.add_argument(
+ "--version",
+ action="version",
+ version=f"%(prog)s {VERSION} — runv.club",
+ )
+ 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:
+ username = pw.pw_name
+ real_home = Path(pw.pw_dir).resolve()
+ jail_home = rj.jail_bind_mountpoint(username)
+ jail_root = rj.JAIL_ROOT / username
+ log.info("--- %s (uid=%s) home=%s", username, pw.pw_uid, real_home)
+
+ if args.dry_run:
+ log.info(
+ "[dry-run] gpasswd -d %s runv-jailed; umount %s; remover bind fstab; purge_jail=%s",
+ username,
+ jail_home,
+ args.purge_jail_dir,
+ )
+ continue
+
+ try:
+ rj.remove_user_from_jailed_group(username, log)
+ rj.unbind_jail_home(jail_home, log)
+ rj.remove_fstab_bind(real_home, jail_home, log)
+ if args.purge_jail_dir:
+ if jail_root.is_dir():
+ shutil.rmtree(jail_root)
+ log.info("jail: removido diretório %s", jail_root)
+ else:
+ log.debug("jail: %s não existe — purge ignorado", jail_root)
+ except Exception as e:
+ log.error("falha para %s: %s", username, 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/create_runv_user.py b/scripts/admin/create_runv_user.py
@@ -15,8 +15,9 @@ Contrato de provisionamento (ordem garantida após validação):
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
+7. **Jail SSH** — por omissão: ``usermod -aG runv-jailed``, ``/srv/jail/<user>``, ``jk_init``
+ com perfil ``extendedshell`` (se ``bin/`` ainda não existir), 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.
@@ -340,19 +341,65 @@ def install_authorized_keys(
# public_html
def default_index_html(username: str) -> str:
- """HTML estático mínimo: sem JavaScript, sem CDN, sem conteúdo dinâmico."""
+ """HTML estático: boas-vindas inspiradoras, sem caminhos de sistema nem comandos (só marcação)."""
return f"""<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- <title>~{username} no runv.club</title>
+ <title>~{username} — runv.club</title>
+ <style>
+ :root {{
+ --bg: #0e0c12;
+ --fg: #e8e4f0;
+ --accent: #c4a1ff;
+ --muted: #9a90b0;
+ }}
+ * {{ box-sizing: border-box; }}
+ body {{
+ margin: 0;
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ font-family: Georgia, "Times New Roman", serif;
+ background: radial-gradient(ellipse 120% 80% at 50% 0%, #1a1428 0%, var(--bg) 55%);
+ color: var(--fg);
+ line-height: 1.65;
+ }}
+ main {{
+ max-width: 36rem;
+ text-align: center;
+ }}
+ h1 {{
+ font-weight: 400;
+ font-size: clamp(1.75rem, 4vw, 2.25rem);
+ letter-spacing: 0.02em;
+ margin-bottom: 1.25rem;
+ color: var(--accent);
+ }}
+ p {{
+ margin: 0 0 1.15rem;
+ font-size: 1.05rem;
+ }}
+ .lead {{
+ font-size: 1.15rem;
+ color: #f0ecf8;
+ }}
+ .soft {{
+ color: var(--muted);
+ font-size: 0.98rem;
+ }}
+ </style>
</head>
<body>
- <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>Na shell, use <code>runv-help</code> para instruções e boas práticas do runv.club.</p>
+ <main>
+ <h1>Bem-vindo ao runv.club</h1>
+ <p class="lead">Este é o espaço de <strong>~{username}</strong> na nossa pubnix — um canto da rede para publicar ideias, texto e silêncio com intenção.</p>
+ <p>A web ainda pode ser leve. Aqui vale experimentar, aprender em público e deixar a página crescer com o tempo, sem pressa de plataforma fechada.</p>
+ <p class="soft">Faça deste sítio o que quiser: um blog, um cartão de visitas, um arquivo. O runv.club é o que cada pessoa constrói em conjunto.</p>
+ </main>
</body>
</html>
"""
@@ -415,7 +462,7 @@ chmod 644 ~/public_html/index.html
Documentação do projeto (admin): repositório **runv-server**, script `create_runv_user.py`.
-— Equipa runv.club
+— Equipe runv.club
"""
@@ -450,18 +497,20 @@ def prepare_public_html(
def default_gophermap_text(username: str) -> str:
- return f"""iBem-vindo ao teu espaço Gopher no runv.club. fake NULL 0
-iEdita este ficheiro em ~/public_gopher/gophermap para personalizares o menu. fake NULL 0
-iDocumentação: man gophermap (no pacote gophernicus). fake NULL 0
+ return f"""iBem-vindo ao runv.club — espaço Gopher de ~{username}. fake NULL 0
+iGopher é linha a linha, menu e curiosidade: um protocolo simples para quem gosta de ir devagar. fake NULL 0
+iExplore, publique texto e deixe este buraco crescer ao seu ritmo. fake NULL 0
"""
def default_gemini_index_gmi(username: str) -> str:
return f"""# ~{username} — runv.club
-Bem-vindo ao runv.club no **Gemini**. Este é o teu espaço — escreve em `.gmi`, cria subpáginas e liga-as como quiseres.
+Bem-vindo ao **Gemini**: um espaço em texto puro, sem rastreio nem barulho de anúncios.
-`gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/`
+Esta cápsula é sua. Pode contar histórias, listar leituras, partilhar notas — tudo em páginas leves que abrem com calma.
+
+O runv.club acredita em protocolos abertos e em quem ainda gosta de ler no próprio ritmo. Boa estadia.
"""
diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py
@@ -7,7 +7,7 @@ Usa ``deluser`` com remoção da home. Opcionalmente remove o registro em
Executar como root. Não altera Apache nem SSH diretamente.
-Versão 0.02 — runv.club
+Versão 0.03 — runv.club
"""
from __future__ import annotations
@@ -24,7 +24,7 @@ import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
-from typing import Final
+from typing import Any, Final
# Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script não entra em sys.path.
_SCRIPT_DIR = Path(__file__).resolve().parent
@@ -61,7 +61,9 @@ RESERVED_USERNAMES: Final[frozenset[str]] = frozenset(
DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json")
DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock")
-VERSION: Final[str] = "0.02"
+VERSION: Final[str] = "0.03"
+
+_REPO_ROOT: Final[Path] = _SCRIPT_DIR.parent.parent
EXIT_OK: Final[int] = 0
EXIT_VALIDATION: Final[int] = 1
@@ -427,6 +429,125 @@ def remove_user_metadata(
lock_f.close()
+def read_user_email_from_metadata(metadata_path: Path, username: str) -> str | None:
+ """Lê o email do registo com mesmo ``username`` em ``users.json`` (lista de dicts)."""
+ if not metadata_path.is_file():
+ return None
+ raw = metadata_path.read_text(encoding="utf-8").strip()
+ if not raw:
+ return None
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(data, list):
+ return None
+ for x in data:
+ if isinstance(x, dict) and x.get("username") == username:
+ em = x.get("email")
+ if em is None:
+ return None
+ s = str(em).strip()
+ return s if s else None
+ return None
+
+
+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_community_ban_notice(
+ username: str,
+ user_email: str | None,
+ *,
+ no_ban_notify_email: bool,
+ dry_run: bool,
+ verbose: bool,
+) -> None:
+ """
+ Envia ``user_account_community_deactivated`` se existir ``/etc/runv-email.json`` e pasta ``email/``.
+ Falhas não abortam a remoção da conta.
+ """
+ if no_ban_notify_email:
+ if verbose:
+ print(" notificação ban: omitida (--no-ban-notify-email)")
+ return
+ if dry_run:
+ return
+ if not user_email:
+ if verbose:
+ print(" notificação ban: sem email nos metadados — não enviado")
+ return
+
+ state_file = Path("/etc/runv-email.json")
+ if not state_file.is_file():
+ if verbose:
+ print(
+ f" notificação ban: {state_file} ausente — email não enviado",
+ )
+ return
+ try:
+ state = json.loads(state_file.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ print(f"Aviso: notificação ban: estado inválido ({state_file}): {e}", file=sys.stderr)
+ return
+
+ email_root = _resolve_email_package_root(state)
+ if email_root is None:
+ print(
+ "Aviso: notificação ban: pasta email/ não encontrada "
+ f"(RUNV_EMAIL_ROOT, email_package_root no JSON ou {_REPO_ROOT / 'email'})",
+ file=sys.stderr,
+ )
+ 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_COMMUNITY_DEACTIVATED
+ except ImportError as e:
+ print(f"Aviso: notificação ban: import lib.mailer falhou: {e}", file=sys.stderr)
+ return
+
+ from_addr = str(state.get("default_from", "")).strip()
+ if not from_addr:
+ print(f"Aviso: notificação ban: default_from ausente em {state_file}", file=sys.stderr)
+ return
+
+ try:
+ send_user_notice(
+ USER_ACCOUNT_COMMUNITY_DEACTIVATED,
+ user_email,
+ subject="[runv.club] Conta desativada",
+ from_addr=from_addr,
+ _state=state,
+ username=username,
+ email=user_email,
+ )
+ print(f" notificação ban: email enviado para {user_email}")
+ except Exception as e:
+ print(f"Aviso: notificação ban falhou (conta já removida): {e}", file=sys.stderr)
+ if verbose:
+ import traceback
+
+ traceback.print_exc()
+
+
# CLI
def main() -> int:
parser = argparse.ArgumentParser(
@@ -484,6 +605,11 @@ def main() -> int:
help=f"ficheiro de lock flock (default: {DEFAULT_LOCK_PATH})",
)
parser.add_argument(
+ "--no-ban-notify-email",
+ action="store_true",
+ help="não envia email ao utilizador sobre desativação por normas da comunidade",
+ )
+ parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION} — runv.club",
@@ -521,6 +647,16 @@ def main() -> int:
dry_run=True,
verbose=args.verbose,
)
+ ban_email = read_user_email_from_metadata(args.metadata_file, username)
+ if args.no_ban_notify_email:
+ print(" notificação ban: omitida (--no-ban-notify-email)")
+ elif not ban_email:
+ print(" notificação ban: sem email nos metadados — nada a enviar")
+ else:
+ print(
+ f" notificação ban: enviaria para {ban_email!r} "
+ "(template user_account_community_deactivated)",
+ )
print("\nNada foi alterado. Execute sem --dry-run como root para aplicar.")
return EXIT_OK
@@ -531,6 +667,8 @@ def main() -> int:
validate_privileges()
+ ban_email = read_user_email_from_metadata(args.metadata_file, username)
+
print(f"\ndel-user.py — removendo {username!r} (UID {uid})\n")
clear_user_quota_before_removal(
@@ -559,6 +697,14 @@ def main() -> int:
verbose=args.verbose,
)
+ try_send_community_ban_notice(
+ username,
+ ban_email,
+ no_ban_notify_email=args.no_ban_notify_email,
+ dry_run=False,
+ verbose=args.verbose,
+ )
+
print("\n--- Resumo ---")
print(f" Conta removida: {username!r}")
print(" Próximo passo: verificar se não restam processos desse UID e revogar acessos externos se aplicável.")
diff --git a/scripts/admin/perm1.md b/scripts/admin/perm1.md
@@ -6,9 +6,29 @@ Script **`scripts/admin/perm1.py`** (root): adiciona utilizadores **uid ≥ 1000
**Pré-requisitos:** `tools/tools.py` já aplicado (pacote **jailkit**, drop-in **`90-runv-jailed.conf`**, grupo `runv-jailed`).
+## Opções Jailkit
+
+- **`--jk-profile`** — perfil passado a `jk_init` quando o jail **ainda não tem** `bin/` (default: **`extendedshell`**, mais completo que `basicshell`). Valores: `extendedshell`, `basicshell`.
+- **`--no-jk-init`** — **não** executa `jk_init`; só adiciona ao grupo, garante `home/<user>` no jail, bind e fstab. Exige que **`/srv/jail/<user>/bin`** já exista (jail pré-provisionado); caso contrário o script falha com mensagem explícita.
+
+Se `bin/` já existir, `jk_init` **não** é voltado a correr (idempotente).
+
+## Reverter (undo)
+
+O script **`patches/undoperm.py`** (na raiz do repositório) remove o utilizador de `runv-jailed`, desmonta o bind, apaga a linha em `/etc/fstab` e, só com **`--purge-jail-dir`**, remove `/srv/jail/<user>`. **Não** restaura ficheiros alterados por `jk_init`.
+
+```bash
+sudo python3 patches/undoperm.py --verbose --dry-run
+sudo python3 patches/undoperm.py --only-user maria
+```
+
+## Exemplos perm1
+
```bash
sudo python3 scripts/admin/perm1.py --verbose
sudo python3 scripts/admin/perm1.py --only-user maria --dry-run
+sudo python3 scripts/admin/perm1.py --jk-profile basicshell
+sudo python3 scripts/admin/perm1.py --no-jk-init --only-user maria
```
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
@@ -51,6 +51,18 @@ def main(argv: list[str] | None = None) -> int:
default=None,
help="processar apenas este utilizador (ainda sujeito a exclusões)",
)
+ p.add_argument(
+ "--jk-profile",
+ default="extendedshell",
+ metavar="P",
+ choices=("extendedshell", "basicshell"),
+ help="perfil Jailkit para jk_init quando o jail ainda não tem bin/ (default: extendedshell)",
+ )
+ p.add_argument(
+ "--no-jk-init",
+ action="store_true",
+ help="não executar jk_init; exige jail já com bin/ (só grupo + home no jail + bind + fstab)",
+ )
args = p.parse_args(argv)
log = setup_logging(args.verbose)
@@ -82,10 +94,23 @@ def main(argv: list[str] | None = None) -> int:
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)
+ log.info(
+ "[dry-run] usermod -aG runv-jailed + jail em /srv/jail/%s "
+ "(jk_profile=%s, no_jk_init=%s)",
+ pw.pw_name,
+ args.jk_profile,
+ args.no_jk_init,
+ )
continue
try:
- rj.ensure_runv_jail_for_user(pw.pw_name, home, no_jail=False, log=log)
+ rj.ensure_runv_jail_for_user(
+ pw.pw_name,
+ home,
+ no_jail=False,
+ log=log,
+ jk_profile=args.jk_profile,
+ no_jk_init=args.no_jk_init,
+ )
except Exception as e:
log.error("falha para %s: %s", pw.pw_name, e)
return 3
diff --git a/scripts/admin/runv_jail.py b/scripts/admin/runv_jail.py
@@ -4,6 +4,7 @@ import logging
import os
import shutil
import subprocess
+import tempfile
from pathlib import Path
RUNV_JAILED_GROUP = "runv-jailed"
@@ -77,10 +78,96 @@ def append_fstab_bind(real_home: Path, jail_mount_point: Path, log: logging.Logg
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")
+def remove_fstab_bind(real_home: Path, jail_mount_point: Path, log: logging.Logger) -> bool:
+ """Remove a linha de bind correspondente de ``/etc/fstab``. Devolve True se alterou o ficheiro."""
+ if not FSTAB_PATH.is_file():
+ return False
+ src = str(real_home.resolve())
+ dst = str(jail_mount_point.resolve())
+ text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace")
+ lines = text.splitlines(keepends=True)
+ out: list[str] = []
+ removed = False
+ for line in lines:
+ s = line.strip()
+ if not s or s.startswith("#"):
+ out.append(line)
+ continue
+ parts = s.split()
+ if len(parts) >= 2 and parts[0] == src and parts[1] == dst:
+ removed = True
+ log.info("jail: removida linha fstab bind %s -> %s", src, dst)
+ continue
+ out.append(line)
+ if not removed:
+ return False
+ new_body = "".join(out)
+ fd, tmp_name = tempfile.mkstemp(
+ prefix="fstab.",
+ suffix=".tmp",
+ dir=str(FSTAB_PATH.parent),
+ )
+ tmp_path = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(new_body)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_path, FSTAB_PATH)
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+ return True
+
+
+def jail_bind_mountpoint(username: str) -> Path:
+ """Caminho dentro do chroot onde a home real é montada (bind)."""
+ return JAIL_ROOT / username / "home" / username
+
+
+def remove_user_from_jailed_group(username: str, log: logging.Logger) -> None:
+ """Remove o utilizador do grupo ``runv-jailed`` (idempotente)."""
+ r = _run(["getent", "group", RUNV_JAILED_GROUP], log=log)
+ if r.returncode != 0 or not (r.stdout or "").strip():
+ log.debug("jail: grupo %s inexistente — nada a remover", RUNV_JAILED_GROUP)
+ return
+ 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 not in members:
+ log.debug("jail: %s já não está em %s", username, RUNV_JAILED_GROUP)
+ return
+ r2 = _run(["gpasswd", "-d", username, RUNV_JAILED_GROUP], log=log)
+ if r2.returncode != 0:
+ err = (r2.stderr or r2.stdout or "").strip()
+ raise RuntimeError(f"gpasswd -d {username} {RUNV_JAILED_GROUP}: {err}")
+ log.info("jail: %s removido do grupo %s", username, RUNV_JAILED_GROUP)
+
+
+def unbind_jail_home(jail_home: Path, log: logging.Logger) -> None:
+ """Desmonta o bind em ``jail_home`` se estiver montado."""
+ if not os.path.ismount(jail_home):
+ log.debug("jail: %s não está montado", jail_home)
+ return
+ r = _run(["umount", str(jail_home.resolve())], log=log)
+ if r.returncode != 0:
+ err = (r.stderr or r.stdout or "").strip()
+ raise RuntimeError(f"umount {jail_home}: {err}")
+ log.info("jail: desmontado bind em %s", jail_home)
+
+
+def ensure_jail_layout(
+ username: str,
+ home: Path,
+ log: logging.Logger,
+ *,
+ jk_profile: str = "extendedshell",
+ no_jk_init: bool = False,
+) -> Path:
+ """
+ Cria ``/srv/jail/user``, opcionalmente ``jk_init`` (perfil Jailkit), ``home/user``.
+ Devolve o caminho do mountpoint do bind.
+ """
jail_root = JAIL_ROOT / username
jail_root.mkdir(parents=True, exist_ok=True)
os.chmod(jail_root, 0o755)
@@ -90,11 +177,19 @@ def ensure_jail_layout(username: str, home: Path, log: logging.Logger) -> Path:
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 no_jk_init:
+ raise RuntimeError(
+ f"jail: {jail_root} sem layout Jailkit (falta bin/) e --no-jk-init foi pedido — "
+ "crie o jail manualmente ou execute sem --no-jk-init."
+ )
+ if shutil.which("jk_init") is None:
+ raise RuntimeError("jk_init não encontrado — instale jailkit e corra tools/tools.py")
+ prof = (jk_profile or "extendedshell").strip()
+ r = _run(["jk_init", "-j", str(jail_root), prof], 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)
+ raise RuntimeError(f"jk_init {prof!r} falhou: {err}")
+ log.info("jail: jk_init %s em %s", prof, jail_root)
else:
log.debug("jail: %s já tem layout jk (bin presente)", jail_root)
inner = jail_root / "home" / username
@@ -129,6 +224,8 @@ def ensure_runv_jail_for_user(
*,
no_jail: bool,
log: logging.Logger,
+ jk_profile: str = "extendedshell",
+ no_jk_init: bool = False,
) -> None:
if no_jail:
log.info("jail: omitido (--no-jail)")
@@ -138,6 +235,12 @@ def ensure_runv_jail_for_user(
return
home = home.resolve()
ensure_user_in_jailed_group(username, log)
- jail_home = ensure_jail_layout(username, home, log)
+ jail_home = ensure_jail_layout(
+ username,
+ home,
+ log,
+ jk_profile=jk_profile,
+ no_jk_init=no_jk_init,
+ )
ensure_bind_mount(home, jail_home, log)
append_fstab_bind(home, jail_home, log)
diff --git a/scripts/create_runv_user.md b/scripts/create_runv_user.md
@@ -16,7 +16,7 @@ Ferramenta de linha de comando para **administradores** criarem contas Unix no s
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. **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)**.
+7. **Jail SSH** — por omissão: grupo **`runv-jailed`**, **`/srv/jail/<user>`**, `jk_init extendedshell` (perfil Jailkit; idempotente se `bin/` já existir), **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)**; reversão: **`patches/undoperm.py`**.
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.
diff --git a/site/public/faq/index.html b/site/public/faq/index.html
@@ -44,7 +44,7 @@
<section class="faq-item" aria-labelledby="faq-3">
<h2 id="faq-3" class="faq-q">3. Como faço meu cadastro?</h2>
- <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>
+ <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 equipe revê a fila manualmente e cria a conta Unix no servidor. Como somos uma equipe pequena, o prazo de aprovação pode ir até <strong>21 dias corridos</strong>.</p>
</section>
<section class="faq-item" aria-labelledby="faq-4">
@@ -101,6 +101,11 @@
<h2 id="faq-14" class="faq-q">14. Qual é o canal oficial de contato?</h2>
<p>O canal oficial informado para dúvidas, suporte e contato geral é: <a href="mailto:admin@runv.club">admin@runv.club</a></p>
</section>
+
+ <section class="faq-item" aria-labelledby="faq-15">
+ <h2 id="faq-15" class="faq-q">15. Onde há suporte em IRC?</h2>
+ <p>No canal <strong>#runv</strong>, disponível em <strong>irc.portalidea.com.br</strong> e em <strong>irc.runv.club</strong> (é o mesmo canal nos dois servidores). Pode ligar com <strong>TLS/SSL</strong> ou sem cifra, conforme o seu cliente — ambos os modos são aceites. Para assuntos sensíveis à conta ou dados pessoais, prefira <a href="mailto:admin@runv.club">admin@runv.club</a>.</p>
+ </section>
</main>
<footer class="site-footer">
diff --git a/site/public/wiki/contas-e-acesso.html b/site/public/wiki/contas-e-acesso.html
@@ -43,7 +43,9 @@
<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 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 equipe revê a fila e a conta Unix é criada pela administração — não há registo instantâneo automático na web.</p>
+
+<p>A análise é manual. Como somos uma equipe pequena, o prazo para aprovar um pedido pode ir até 21 dias corridos — obrigado pela paciência enquanto revisamos cada caso.</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>
@@ -94,6 +96,8 @@ Se a conta concentra histórico, permissões e suporte, então ela precisa ser t
<p>Qualquer problema de login, recuperação, segurança ou suspeita de comprometimento deve ser comunicado para:<br>
admin@runv.club</p>
+
+<p>Comunidade e ajuda em tempo real: canal #runv em irc.portalidea.com.br ou irc.runv.club (o mesmo canal nos dois servidores). Pode ligar com TLS/SSL ou sem cifra, conforme o seu cliente IRC — ambos os modos são aceites. Use o IRC para dúvidas rápidas e conversa; para assuntos sensíveis à conta, prefira o e-mail acima.</p>
</main>
<footer class="site-footer">
diff --git a/site/public/wiki/faq.html b/site/public/wiki/faq.html
@@ -48,7 +48,7 @@ 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>
-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>
+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 equipe cria a conta após revisão manual. O prazo de aprovação pode ir até 21 dias corridos (somos uma equipe pequena).</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>
@@ -78,13 +78,16 @@ 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>
-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>
+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 equipe 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>
<p>15. Onde vejo as regras do sistema?<br>
Na wiki oficial do runv.club, especialmente nos arquivos de regras da comunidade, moderação, privacidade e acesso.</p>
+
+<p>16. Onde peço ajuda no IRC?<br>
+No canal #runv, disponível em irc.portalidea.com.br e em irc.runv.club (é o mesmo canal nos dois servidores). Pode usar ligação com TLS/SSL ou sem cifra, conforme o cliente — ambos são aceites. Para questões de conta ou dados sensíveis, use admin@runv.club.</p>
</main>
<footer class="site-footer">
diff --git a/site/wiki/03_contas-e-acesso.txt b/site/wiki/03_contas-e-acesso.txt
@@ -2,7 +2,9 @@ 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 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 equipe revê a fila e a conta Unix é criada pela administração — não há registo instantâneo automático na web.
+
+A análise é manual. Como somos uma equipe pequena, o prazo para aprovar um pedido pode ir até 21 dias corridos — obrigado pela paciência enquanto revisamos cada caso.
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.
@@ -58,3 +60,5 @@ SUPORTE DE ACESSO
Qualquer problema de login, recuperação, segurança ou suspeita de comprometimento deve ser comunicado para:
admin@runv.club
+
+Comunidade e ajuda em tempo real: canal #runv em irc.portalidea.com.br ou irc.runv.club (o mesmo canal nos dois servidores). Pode ligar com TLS/SSL ou sem cifra, conforme o seu cliente IRC — ambos os modos são aceites. Use o IRC para dúvidas rápidas e conversa; para assuntos sensíveis à conta, prefira o e-mail acima.
diff --git a/site/wiki/07_faq.txt b/site/wiki/07_faq.txt
@@ -7,7 +7,7 @@ O runv.club é um sistema de acesso, organização, suporte e uso em ambiente di
Não.
3. Como entro no sistema?
-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.
+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 equipe cria a conta após revisão manual. O prazo de aprovação pode ir até 21 dias corridos (somos uma equipe pequena).
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.
@@ -37,10 +37,13 @@ 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?
-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.
+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 equipe 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
15. Onde vejo as regras do sistema?
Na wiki oficial do runv.club, especialmente nos arquivos de regras da comunidade, moderação, privacidade e acesso.
+
+16. Onde peço ajuda no IRC?
+No canal #runv, disponível em irc.portalidea.com.br e em irc.runv.club (é o mesmo canal nos dois servidores). Pode usar ligação com TLS/SSL ou sem cifra, conforme o cliente — ambos são aceites. Para questões de conta ou dados sensíveis, use admin@runv.club.
diff --git a/terminal/config.example.toml b/terminal/config.example.toml
@@ -5,7 +5,8 @@ queue_dir = "/var/lib/runv/entre-queue"
log_file = "/var/log/runv/entre.log"
templates_dir = "/opt/runv/terminal/templates"
-# Email do administrador (opcional). Se vazio, só fila + log local.
+# Email do administrador (opcional). Se vazio aqui, o entre_app tenta admin_email
+# em /etc/runv-email.json (ex.: após configure_mailgun.py). Se ambos vazios: só fila + log.
admin_email = ""
# Remetente do aviso ao admin (From). Por omissão runv.club; se vazio no TOML, o código usa o mesmo.
mail_from = "entre@runv.club"
diff --git a/terminal/docs/ADMIN.md b/terminal/docs/ADMIN.md
@@ -68,8 +68,12 @@ Sugestão mínima: manter o ficheiro no sítio e só alterar `status` para audit
## Notificação ao administrador
1. **Obrigatória:** novo ficheiro na fila.
-2. **Log:** `/var/log/runv/entre.log` (ou o caminho em `config.toml`).
-3. **Email:** se `admin_email` estiver definido e `sendmail` funcionar, o `entre_app.py` envia um resumo.
+2. **Log:** `/var/log/runv/entre.log` (ou o caminho em `config.toml`); também um resumo curto (`admin_console_notice`) na mesma sessão.
+3. **Email:** o `entre_app.py` envia o corpo definido em `templates/admin_mail.txt` quando há destinatário válido:
+ - **Prioridade:** `admin_email` em `config.toml`.
+ - **Fallback:** se `admin_email` no TOML estiver vazio, usa `admin_email` de `/etc/runv-email.json` (o mesmo ficheiro do Mailgun / `configure_mailgun.py`).
+ - **Transporte:** [`entre_core.sendmail_notify`](../entre_core.py) tenta **primeiro** a API **Mailgun** via `lib.mailer.send_mail` quando o JSON global indica Mailgun; caso contrário usa `sendmail_path` (por omissão `/usr/sbin/sendmail`). Requisitos Mailgun: `email_package_root` ou variável `RUNV_EMAIL_ROOT` a apontar para a pasta `email/` do repositório.
+ - **Remetente:** se `mail_from` no TOML for o default `entre@runv.club` e o JSON tiver `default_from`, o *From* alinha-se a `default_from` (útil com domínio verificado no Mailgun).
### Reenviar notificação
@@ -80,9 +84,9 @@ Não há botão. Opções:
### Depuração de email
-- Ver log: `grep -i sendmail /var/log/runv/entre.log` ou mensagens `notificação por email`.
-- Testar MTA: `echo test | mail -s test root` (conforme configuração do sistema).
-- Confirmar caminho: `ls -l /usr/sbin/sendmail`.
+- Ver log: `grep -E 'notificação|Mailgun|sendmail' /var/log/runv/entre.log`.
+- **Mailgun:** confirmar `/etc/runv-email.json` + chave em `/etc/runv-email.secrets.json`; IP allowlist no painel Mailgun; `email_package_root` ou `RUNV_EMAIL_ROOT`.
+- **Legado (MTA):** testar `echo test | mail -s test root` (conforme o servidor); `ls -l /usr/sbin/sendmail`.
## Pedidos inválidos ou spam
diff --git a/terminal/docs/INSTALL.md b/terminal/docs/INSTALL.md
@@ -54,7 +54,9 @@ Opções úteis:
- **`--skip-pam-empty-password-rule`** — não mexer no PAM (só para quem configura à mão; em geral **não** use em `empty-password` em Debian).
- `--sshd-test-connection` — argumento `-C` para `sshd -T` (deve bater com o `Match`, ex.: `user=entre,host=runv.club,addr=127.0.0.1`).
- `--dry-run` — apenas mensagens, sem alterações.
-- `--force-config` — repõe `config.toml` a partir do example.
+- **Reexecução:** se já existir **`/opt/runv/terminal/entre_app.py`**, em terminal interactivo o script pergunta se deseja continuar (actualiza `entre_app.py`, `entre_core.py`, `templates/`, etc.). Responder **não** cancela tudo. Em seguida, se **`config.toml`** já existir, pergunta se deve **substituí-lo** pelo example (omissão: **não**, para não perder `admin_email`).
+- `-y` / `--yes` — não mostrar esses prompts (útil em scripts); **`config.toml`** continua preservado salvo **`--force-config`**.
+- `--force-config` — repõe `config.toml` a partir do example (sem segundo prompt).
- `--skip-copy` — só directórios/utilizador (sem copiar ficheiros).
- `--skip-sshd` — não toca no SSH; imprime o bloco `Match User entre` para cópia manual.
- `--no-reload` — grava o drop-in e corre `sshd -t` + validação `-T`, mas não recarrega o serviço (útil para rever antes).
@@ -63,9 +65,9 @@ Opções úteis:
Edite **`/opt/runv/terminal/config.toml`**:
-- **`admin_email`** — endereço para notificações (pode ficar vazio: só fila + log).
-- **`mail_from`** — remetente do email (cabeçalho `From`); por omissão **`entre@runv.club`**. Se a chave existir mas estiver vazia, o programa usa o mesmo endereço.
-- **`sendmail_path`** — normalmente `/usr/sbin/sendmail`.
+- **`admin_email`** — endereço para notificações. Pode ficar vazio no TOML se **`admin_email`** estiver definido em **`/etc/runv-email.json`** (fallback usado pelo `entre_app.py`). Se ambos estiverem vazios, só fila + log.
+- **`mail_from`** — remetente do email (cabeçalho `From`); por omissão **`entre@runv.club`**. Se a chave existir mas estiver vazia, o programa usa o mesmo endereço. Com Mailgun, se o remetente continuar no default e o JSON tiver `default_from`, o código alinha o *From* a `default_from`.
+- **`sendmail_path`** — normalmente `/usr/sbin/sendmail` (ramo legado; com Mailgun configurado, o envio pode ser pela API sem precisar de MTA).
## 5. Autenticação SSH para o utilizador `entre`
@@ -188,17 +190,16 @@ sudo jq . /var/lib/runv/entre-queue/<request_id>.json
## 9. Teste de notificação por email
-1. Preencha **`admin_email`** no `config.toml`.
-2. Garanta que **`sendmail`** aceita mail local ou relay (configuração do MTA fora do âmbito deste módulo).
-3. Opcional: inspeccionar o formato com:
+1. Defina o destinatário: **`admin_email`** no `config.toml` **ou** (se o TOML estiver vazio) **`admin_email`** em **`/etc/runv-email.json`**.
+2. **Mailgun:** estado e segredos correctos; `email_package_root` ou `RUNV_EMAIL_ROOT`; teste com `email/configure_mailgun.py --test` no servidor.
+3. **Legado:** **`sendmail`** e MTA a aceitar relay ou mail local.
+4. Opcional: inspeccionar o formato com:
```bash
sh scripts/test_mail.sh
```
-4. Para um teste real, pode redireccionar para sendmail conforme a política do servidor.
-
-Se o email falhar, o pedido **mantém-se** na fila e o log regista o aviso.
+Se o email falhar, o pedido **mantém-se** na fila e o log regista o aviso (`notificação Mailgun falhou`, `sendmail falhou`, etc.).
## 10. systemd.path (opcional)
@@ -226,5 +227,6 @@ A instalação automática faz **backup** do ficheiro anterior (`runv-entre.conf
| Log vazio / permissão | Dono de `/var/log/runv/entre.log`. |
| Chave rejeitada | `ssh-keygen` instalado; chave numa linha; tipo permitido. |
| Sessão SSH fecha logo | Autenticação de `entre` falhou antes do ForceCommand. |
+| Email do novo pedido não chega | `admin_email` no TOML ou no `/etc/runv-email.json`; Mailgun: allowlist de IP, chave HTTP, `email_package_root` / `RUNV_EMAIL_ROOT`; legado: `sendmail_path` e MTA. Ver log `entre`. |
Documentação de operação: **[ADMIN.md](ADMIN.md)**. Desenho: **[ARCHITECTURE.md](ARCHITECTURE.md)**.
diff --git a/terminal/docs/USO.md b/terminal/docs/USO.md
@@ -1,118 +0,0 @@
-# Instalação e uso — módulo `terminal` (entre)
-
-Este documento resume **como instalar**, **como usar** (visitante e administrador) e **onde olhar** quando algo falha. Detalhes técnicos extra: [INSTALL.md](INSTALL.md), [ADMIN.md](ADMIN.md), [ARCHITECTURE.md](ARCHITECTURE.md).
-
----
-
-## 1. O que é
-
-- Utilizador Unix **`entre`** no servidor; quem corre `ssh entre@runv.club` **não** recebe shell normal.
-- O OpenSSH executa **`entre_app.py`** (`ForceCommand`), que mostra textos em [templates/](../templates/) (introdução, avisos, formulário), valida dados e grava um **JSON** em `/var/lib/runv/entre-queue/`.
-- **Não cria conta Linux** automaticamente; a aprovação é manual e o provisionamento usa [`create_runv_user.py`](../../scripts/admin/create_runv_user.py).
-
----
-
-## 2. Instalação no servidor (admin)
-
-1. **Dependências** (Debian 13): `python3`, `openssh-server`, `openssh-client` (`ssh-keygen`), opcional `mailutils` para email.
-2. **Copiar e preparar o módulo** (como root), a partir da pasta `terminal/` do repositório:
-
- ```bash
- cd /caminho/runv-server/terminal
- sudo python3 setup_entre.py
- ```
-
- Ou: `sudo sh scripts/install.sh`
-
-3. **Configurar** `/opt/runv/terminal/config.toml` (a partir de `config.example.toml`):
- - `admin_email` — para receber notificação por `sendmail` (pode ficar vazio).
- - `queue_dir`, `log_file`, `templates_dir` — normalmente não precisa mudar.
-
-4. **OpenSSH:** por defeito o `setup_entre.py` instala **`/etc/ssh/sshd_config.d/runv-entre.conf`**, corre **`sshd -t`** e **`systemctl reload ssh`**. Com **`--skip-sshd`**, aplica o bloco à mão (ver [INSTALL.md](INSTALL.md) ou [examples/sshd_match_entre.conf.sample](../examples/sshd_match_entre.conf.sample)).
-
-5. **Autenticação:** omissão **`--auth-mode shared-password`**. **`empty-password`**: espírito **`join@tilde.town`** — grupo **`entre-open`**, **`passwd -d`**, **PAM** em `/etc/pam.d/sshd` por omissão; o drop-in SSH usa **`keyboard-interactive`** por omissão (Windows); **`--empty-password-tilde-password-auth`** = **`password`** + **`PermitEmptyPasswords`**. Não é “SSH sem credencial”. Shell **`/bin/sh`**. Ver [INSTALL.md](INSTALL.md).
-
----
-
-## 3. Uso pelo visitante (candidato)
-
-1. Ligar (o site indica a **palavra-passe partilhada** do utilizador `entre`, se existir):
-
- ```bash
- ssh entre@runv.club
- ```
-
-2. **Opcional:** em **`key-only`**, ou se o admin tiver posto a tua chave em `authorized_keys` (não aplica ao modo `shared-password` por defeito).
-
-3. No início aparece o **logo RUNV em ASCII** (verde, se o terminal suportar cores) e a frase *Aperte qualquer tecla para continuar...*; a cadeia **`runv.club`** é destacada a verde onde o terminal suporta. Segue-se uma **intro curta** e um **aviso sobre a chave** (Enter para seguir; `%%PAGE%%` nos `.txt` ainda pode partir em mais do que um ecrã se quiseres).
-4. No **aviso da chave**: relembra colar só a **pública**, nunca a privada.
-5. **Formulário em quatro passos**, cada um com cabeçalho claro e linha **»** onde escreves:
- - **utilizador** desejado (regras: minúsculas, letras/dígitos/`_`/`-`, não reservado nem já existente);
- - **email** de contacto — formato `nome@domínio` (com um único `@` e pelo menos um ponto no domínio, ex.: `maria@exemplo.org`);
- - **onde apareces online** — links ou perfis (várias linhas; termina com uma linha só com `.` e Enter);
- - **chave pública** SSH (uma linha).
-6. **Rever o resumo** (inclui fingerprint SHA256 e o texto “online”):
- - confirmar envio, **editar** de novo ou **cancelar**.
-7. Se confirmar: o pedido fica na fila; aparece a **despedida** com a referência `{request_id}`.
-8. **Aguardar email** da administração; não repetir o mesmo pedido muitas vezes.
-
-O **splash ASCII** (igual ao da landing em `site/public/index.html`) e o texto *Aperte qualquer tecla...* estão em [`entre_app.py`](../entre_app.py) (`RUNV_ASCII_ART`, `show_opening_splash`). Em `intro.txt` e `warning_public_key.txt`, `%%PAGE%%` **parte o texto em ecrãs** (`show_paged_template`). Os restantes textos: `confirm.txt`, `goodbye.txt`.
-
----
-
-## 4. Uso pelo administrador (após pedidos)
-
-1. **Listar pedidos:** `/var/lib/runv/entre-queue/*.json`
-2. **Ler e decidir** (duplicados, email inválido, etc.) — ver [ADMIN.md](ADMIN.md).
-3. **Criar conta** com o provisionador, usando os campos do JSON aprovado.
-4. **Opcional:** `systemd` `runv-entre-notify.path` para reagir a novos ficheiros na fila.
-
----
-
-## 5. Teste sem SSH (desenvolvimento)
-
-```bash
-cd terminal
-chmod +x scripts/test_local.sh
-./scripts/test_local.sh
-```
-
-Grava em `terminal/data/queue/` e usa `config.example.toml`. Exige `ssh-keygen` no PATH.
-
-Variáveis úteis: `RUNV_ENTRE_CONFIG`, `RUNV_ENTRE_QUEUE_DIR`, `RUNV_ENTRE_LOG_FILE` (ver [README.md](../README.md)).
-
----
-
-## 6. Onde está o quê
-
-| Item | Caminho típico |
-|------|----------------|
-| Aplicação instalada | `/opt/runv/terminal/` |
-| Configuração | `/opt/runv/terminal/config.toml` |
-| Fila de pedidos | `/var/lib/runv/entre-queue/` |
-| Log | `/var/log/runv/entre.log` |
-| Textos da sessão | `/opt/runv/terminal/templates/` |
-| Drop-in SSH `entre` | `/etc/ssh/sshd_config.d/runv-entre.conf` |
-
----
-
-## 7. Problemas comuns
-
-| Situação | O que verificar |
-|----------|-----------------|
-| SSH recusa antes de aparecer o texto | Palavra-passe de `entre` definida (`sudo passwd entre`); ou chave em `authorized_keys`; firewall; drop-in com `PasswordAuthentication yes` para `entre`. |
-| Erro ao gravar pedido | Dono e permissões de `/var/lib/runv/entre-queue` (dono `entre`, `0700`). |
-| Email não chega | `admin_email` preenchido; `sendmail` e MTA; mensagens no log. |
-| Chave inválida | Uma linha só; tipo permitido; `ssh-keygen` instalado no servidor. |
-
----
-
-## 8. Checklist rápido pós-instalação
-
-- [ ] `sudo python3 setup_entre.py` concluído sem erros
-- [ ] `config.toml` com `admin_email` se quiseres mail
-- [ ] Drop-in `runv-entre.conf` presente (ou `--skip-sshd` aplicado à mão); `sshd -t` OK após o setup
-- [ ] `ssh entre@host` mostra a narrativa e completa até JSON na fila
-- [ ] Log com linha `pedido gravado`
-
-Para texto legal e segurança em profundidade, ver [ARCHITECTURE.md](ARCHITECTURE.md).
diff --git a/terminal/entre_app.py b/terminal/entre_app.py
@@ -31,7 +31,6 @@ INTRO_PAGE_BREAK: str = "%%PAGE%%"
from entre_core import (
APP_VERSION,
- DEFAULT_MAIL_FROM,
MAX_ONLINE_PRESENCE_LEN,
ValidationError,
build_request_payload,
@@ -41,6 +40,7 @@ from entre_core import (
log_session,
new_request_id,
render_template,
+ resolve_entre_notify_recipients,
resolve_paths,
save_request_json,
sendmail_notify,
@@ -466,9 +466,7 @@ def main() -> int:
except OSError:
pass
- admin_email = str(cfg.get("admin_email", "")).strip()
- mail_raw = str(cfg.get("mail_from", DEFAULT_MAIL_FROM)).strip()
- mail_from = mail_raw or DEFAULT_MAIL_FROM
+ admin_email, mail_from = resolve_entre_notify_recipients(cfg, logger=logger)
sendmail_path = str(cfg.get("sendmail_path", "/usr/sbin/sendmail")).strip()
if admin_email:
try:
diff --git a/terminal/entre_core.py b/terminal/entre_core.py
@@ -310,6 +310,58 @@ def log_session(logger: logging.Logger, msg: str, *, level: int = logging.INFO)
logger.log(level, msg)
+RUNV_EMAIL_STATE_PATH: Final[Path] = Path("/etc/runv-email.json")
+
+
+def resolve_entre_notify_recipients(
+ cfg: dict[str, Any],
+ *,
+ logger: logging.Logger | None = None,
+) -> tuple[str, str]:
+ """
+ Destinatário e remetente para o email de novo pedido (fluxo entre).
+
+ Ordem: ``admin_email`` / ``mail_from`` no TOML; se ``admin_email`` vazio,
+ usa ``admin_email`` de :file:`/etc/runv-email.json`. Se o remetente efectivo
+ ainda for o default ``entre@runv.club`` e o JSON tiver ``default_from``,
+ alinha o *From* ao domínio Mailgun.
+ """
+ admin = str(cfg.get("admin_email", "")).strip()
+ mail_raw = str(cfg.get("mail_from", DEFAULT_MAIL_FROM)).strip()
+ mail_from = mail_raw or DEFAULT_MAIL_FROM
+
+ data: dict[str, Any] | None = None
+ if RUNV_EMAIL_STATE_PATH.is_file():
+ try:
+ raw = json.loads(RUNV_EMAIL_STATE_PATH.read_text(encoding="utf-8"))
+ if isinstance(raw, dict):
+ data = raw
+ except (OSError, json.JSONDecodeError):
+ data = None
+
+ if not admin and data is not None:
+ fe = str(data.get("admin_email", "")).strip()
+ if fe:
+ admin = fe
+ if logger is not None:
+ logger.info(
+ "notificação: admin_email obtido de %s (config.toml vazio)",
+ RUNV_EMAIL_STATE_PATH,
+ )
+
+ if data is not None:
+ df = str(data.get("default_from", "")).strip()
+ if df and mail_from == DEFAULT_MAIL_FROM:
+ mail_from = df
+ if logger is not None:
+ logger.info(
+ "notificação: mail_from alinhado a default_from em %s",
+ RUNV_EMAIL_STATE_PATH,
+ )
+
+ return admin, mail_from
+
+
def _try_runv_mailgun_notify(
*,
admin_email: str,
@@ -322,11 +374,10 @@ def _try_runv_mailgun_notify(
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():
+ if not RUNV_EMAIL_STATE_PATH.is_file():
return False
try:
- data = json.loads(state_path.read_text(encoding="utf-8"))
+ data = json.loads(RUNV_EMAIL_STATE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
be = str(data.get("backend", "")).lower()
@@ -343,7 +394,7 @@ def _try_runv_mailgun_notify(
if not root:
logger.warning(
"notificação Mailgun: defina email_package_root em %s ou a variável RUNV_EMAIL_ROOT.",
- state_path,
+ RUNV_EMAIL_STATE_PATH,
)
return False
email_root = str(Path(root).resolve())
diff --git a/terminal/setup_entre.py b/terminal/setup_entre.py
@@ -39,7 +39,10 @@ Use --skip-sshd / --no-reload / --dry-run conforme necessário.
Executar como root no servidor Debian.
-Versão 0.10 — runv.club
+Reexecução: com instalação existente, em TTY pede confirmação antes de actualizar o módulo
+ e (em separado) antes de substituir config.toml; use --yes / --force-config para automatizar.
+
+Versão 0.11 — runv.club
"""
from __future__ import annotations
@@ -56,7 +59,7 @@ import time
from pathlib import Path
from typing import Final
-VERSION: Final[str] = "0.10"
+VERSION: Final[str] = "0.11"
ENTRE_USER: Final[str] = "entre"
INSTALL_ROOT: Final[Path] = Path("/opt/runv/terminal")
QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue")
@@ -86,6 +89,20 @@ def eprint(msg: str) -> None:
print(msg, file=sys.stderr)
+def prompt_yes(question: str, *, default: bool) -> bool:
+ """Confirmação em TTY; fora de TTY devolve ``default``."""
+ if not sys.stdin.isatty():
+ return default
+ suffix = "[S/n]" if default else "[s/N]"
+ try:
+ raw = input(f"{question}{suffix} ").strip().lower()
+ except EOFError:
+ return default
+ if not raw:
+ return default
+ return raw in ("s", "sim", "y", "yes")
+
+
def require_root() -> None:
if os.geteuid() != 0:
eprint("Execute como root (sudo).")
@@ -711,6 +728,12 @@ def main() -> int:
description="Setup utilizador entre + /opt/runv/terminal + OpenSSH (automatizado).",
)
parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument(
+ "-y",
+ "--yes",
+ action="store_true",
+ help="não perguntar em reinstalação; combinar com --force-config para repor config.toml sem prompt",
+ )
parser.add_argument("--force-config", action="store_true", help="sobrescrever config.toml com example")
parser.add_argument("--home", type=Path, default=Path(f"/home/{ENTRE_USER}"))
parser.add_argument(
@@ -797,6 +820,27 @@ def main() -> int:
eprint("--empty-password-group não pode ser vazio.")
return 2
+ existing_module = (ir / "entre_app.py").is_file()
+ if (
+ existing_module
+ and not args.skip_copy
+ and not args.dry_run
+ and not args.yes
+ ):
+ if sys.stdin.isatty():
+ if not prompt_yes(
+ f"Já existe instalação em {ir} (ficheiros do módulo serão actualizados; "
+ f"config.toml só se pedir abaixo ou usar --force-config). Continuar? ",
+ default=True,
+ ):
+ print("Operação cancelada.")
+ return 0
+ else:
+ print(
+ f"Aviso: instalação existente em {ir}; a actualizar sem prompt "
+ f"(TTY ausente). Use --dry-run para simular ou --yes para suprimir avisos."
+ )
+
pam_done = False
apply_pam_empty = (
args.auth_mode == AUTH_EMPTY
@@ -805,7 +849,22 @@ def main() -> int:
if not args.skip_copy:
copy_module(ir, dry_run=args.dry_run)
- install_config(ir, dry_run=args.dry_run, force=args.force_config)
+ force_cfg = bool(args.force_config)
+ cfg_path = ir / "config.toml"
+ if (
+ cfg_path.is_file()
+ and not force_cfg
+ and not args.dry_run
+ and not args.yes
+ and sys.stdin.isatty()
+ ):
+ if prompt_yes(
+ f"Manter {cfg_path} com as suas definições (recomendado) ou substituir "
+ f"por config.example.toml (perde admin_email e outros valores)? Substituir? ",
+ default=False,
+ ):
+ force_cfg = True
+ install_config(ir, dry_run=args.dry_run, force=force_cfg)
if not args.dry_run:
chmod_tree_templates(ir)