runv-server

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

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:
Memail/docs/INTEGRATION.md | 4+++-
Aemail/docs/notes | 3+++
Memail/lib/templates.py | 5++++-
Memail/templates/admin_new_request.txt | 2+-
Memail/templates/system_test.txt | 2+-
Aemail/templates/user_account_community_deactivated.txt | 10++++++++++
Memail/templates/user_account_created.txt | 8++++----
Memail/templates/user_account_removed.txt | 4++--
Memail/templates/user_approved.txt | 4++--
Memail/templates/user_quota_warning.txt | 4++--
Memail/templates/user_rejected.txt | 4++--
Memail/templates/user_request_received.txt | 6+++---
Apatches/undoperm.py | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/admin/create_runv_user.py | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mscripts/admin/del-user.py | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mscripts/admin/perm1.md | 20++++++++++++++++++++
Mscripts/admin/perm1.py | 29+++++++++++++++++++++++++++--
Mscripts/admin/runv_jail.py | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mscripts/create_runv_user.md | 2+-
Msite/public/faq/index.html | 7++++++-
Msite/public/wiki/contas-e-acesso.html | 6+++++-
Msite/public/wiki/faq.html | 7+++++--
Msite/wiki/03_contas-e-acesso.txt | 6+++++-
Msite/wiki/07_faq.txt | 7+++++--
Mterminal/config.example.toml | 3++-
Mterminal/docs/ADMIN.md | 14+++++++++-----
Mterminal/docs/INSTALL.md | 22++++++++++++----------
Dterminal/docs/USO.md | 118-------------------------------------------------------------------------------
Mterminal/entre_app.py | 6++----
Mterminal/entre_core.py | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mterminal/setup_entre.py | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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)