runv-server

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

commit bebfa833a9272e9d9575b355d56235996e73a32e
parent 3046d7d0dd1380eedd7846e3b0de784e2dfc4977
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Fri, 27 Mar 2026 02:31:14 -0300

big changes

Diffstat:
Mdocs/05-tools-and-system-experience.md | 10+++++++++-
Mdocs/10-user-provisioning-and-admin-ops.md | 22+++++++++++++++++++++-
Mdocs/admin.md | 31+++++++++++++++++++++++++++++++
Memail/configure_mailgun.py | 10++++++++++
Memail/configure_msmtp.py | 8++++++++
Memail/configure_msmtp_legacy.py | 10++++++++++
Memail/templates/admin_new_request.txt | 8+++++---
Memail/templates/admin_user_created.txt | 1+
Memail/templates/user_account_created.txt | 5++++-
Memail/templates/user_account_removed.txt | 2+-
Memail/templates/user_approved.txt | 8+++++---
Mpatches/patch_irc.py | 11+++++++++++
Dpatches/undoperm.py | 140-------------------------------------------------------------------------------
Mpatches/yetgg.py | 11+++++++++++
Ascripts/admin/admin_guard.py | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/admin/create_runv_user.py | 310++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mscripts/admin/del-user.py | 31+++++++++++++++++++++++++++++++
Mscripts/admin/perm1.py | 5+++++
Mscripts/admin/setup_alt_protocols.py | 10++++++++++
Mscripts/admin/skel.py | 10++++++++++
Mscripts/admin/starthere.py | 6++++++
Mscripts/admin/update_user.py | 5+++++
Mscripts/doom/doom.py | 9+++++++++
Msite/build_directory.py | 11+++++++++++
Msite/genlanding.py | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msite/news/publish_news.py | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msite/public/assets/style.css | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msite/public/faq/index.html | 43++++++++++++++++++++++++++++---------------
Msite/public/index.html | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msite/public/junte-se/index.html | 44+++++++++++++++++++++++++++++++++-----------
Msite/public/wiki/contas-e-acesso.html | 7+++----
Msite/public/wiki/faq.html | 6+++---
Msite/public/wiki/index.html | 29++++++++++++++---------------
Msite/public/wiki/privacidade-e-seguranca.html | 9++++-----
Msite/public/wiki/punicoes-e-moderacao.html | 10+++++-----
Msite/public/wiki/regras-da-comunidade.html | 4+++-
Msite/public/wiki/visao-geral.html | 41+++++++++++++++++++++--------------------
Msite/wiki/01_index.txt | 34+++++++++++++++++++---------------
Msite/wiki/02_visao-geral.txt | 47++++++++++++++++++++++++-----------------------
Msite/wiki/03_contas-e-acesso.txt | 7+++----
Msite/wiki/04_regras-da-comunidade.txt | 4+++-
Msite/wiki/05_punicoes-e-moderacao.txt | 10+++++-----
Msite/wiki/06_privacidade-e-seguranca.txt | 9++++-----
Msite/wiki/07_faq.txt | 6+++---
Msite/wiki/build_wiki.py | 7+++++++
Mterminal/close_entre.py | 41+++++++++++++++++++++++++++++++++++++++++
Mterminal/gen_config_toml.py | 45+++++++++++++++++++++++++++++++++++++++++++++
Mterminal/setup_entre.py | 17+++++++++++++----
Mterminal/templates/admin_mail.txt | 30+++++++++++++++++-------------
Atools/sudoers/90-runv-pmurad-admin | 3+++
Mtools/tools.py | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
51 files changed, 1504 insertions(+), 404 deletions(-)

diff --git a/docs/05-tools-and-system-experience.md b/docs/05-tools-and-system-experience.md @@ -11,6 +11,8 @@ 3. MOTD dinâmico: `tools/motd/60-runv` → `/etc/update-motd.d/60-runv`. 4. Modelos para novas contas: `tools/skel/` → `/etc/skel/`. 5. Drop-in SSH para utilizadores jailed: `tools/sshd/90-runv-jailed.conf` → `/etc/ssh/sshd_config.d/`. +6. Sudo administrativo para `pmurad-admin`: `tools/sudoers/90-runv-pmurad-admin` → `/etc/sudoers.d/`. +7. Reconciliação do jail SSH em membros existentes via `scripts/admin/perm1.py`. **Princípios declarados no código:** Python stdlib; **sem `shell=True`** em subprocess. @@ -23,7 +25,7 @@ sudo python3 tools.py --dry-run --verbose # simular sudo python3 tools.py ``` -Flags úteis: `--force`, `--skip-apt` (ver `--help`). +Flags úteis: `--force`, `--skip-apt`, `--reconcile-existing-users` (ver `--help`). ## IRC / comando `chat` @@ -32,4 +34,10 @@ Flags úteis: `--force`, `--skip-apt` (ver `--help`). - **Provisionamento:** o patch corre com `weechat-headless -a -r '…' --stdout` (o `-a` evita auto-connect durante o batch). O launcher **`chat` não usa `-a`**. Novas contas Unix criadas com `scripts/admin/create_runv_user.py` invocam o patch automaticamente para esse utilizador. O `tools/tools.py` também aplica o backfill IRC ao final da execução. - **Backfill / admin:** `sudo python3 patches/patch_irc.py --all-users` (ou `--user NOME`). Requer `weechat-headless` no sistema. +## Isolamento e permissões + +- `pmurad-admin` fica explicitamente fora do grupo `runv-jailed` e recebe sudo administrativo via `/etc/sudoers.d/90-runv-pmurad-admin`. +- Membros normais continuam a usar o modelo `runv-jailed` + `ChrootDirectory /srv/jail/%u`, para não saírem das respetivas homes na shell SSH normal. +- `tools/tools.py` não altera contas já existentes por omissão. Se quiser reconciliar jail SSH e IRC em membros antigos, use `--reconcile-existing-users`. + Próximo: [06-site-and-apache.md](06-site-and-apache.md). diff --git a/docs/10-user-provisioning-and-admin-ops.md b/docs/10-user-provisioning-and-admin-ops.md @@ -27,7 +27,27 @@ 1. JSON na fila `entre-queue/`. 2. Admin valida manualmente. -3. `create_runv_user.py` com dados aprovados. +3. `create_runv_user.py` com dados aprovados, manualmente ou por `request_id` da fila. 4. Refresh público conforme [07](07-public-members-directory.md). +### Aprovação rápida por `request_id` + +Se o pedido veio do fluxo `entre`, o admin pode aprová-lo diretamente pelo UUID do ficheiro na fila: + +```bash +sudo python3 scripts/admin/create_runv_user.py --request-id UUID +``` + +O script lê `username`, `email` e `public_key` do JSON em `/var/lib/runv/entre-queue/UUID.json`, cria a conta e arquiva o pedido em `entre-queue/approved/` após sucesso. + +### Aprovação em lote da fila inteira + +Para processar todos os pedidos pendentes de uma vez: + +```bash +sudo python3 scripts/admin/create_runv_user.py --all-pending +``` + +O script percorre os JSONs pendentes da fila, processa um a um em sequência e imprime um resumo final com sucessos e falhas. + Próximo: [11-daily-operations.md](11-daily-operations.md). diff --git a/docs/admin.md b/docs/admin.md @@ -25,6 +25,7 @@ Nos exemplos abaixo, substitua: - Servidor Debian com Python 3 - Quotas ext4 prontas se for usar quota automática - Apache / DocumentRoot configurados se quiser refresh público automático +- Operador autorizado: por padrão, `pmurad-admin` (ou a lista em `RUNV_ADMIN_USERS`) ## Bootstrap inicial do servidor @@ -48,6 +49,12 @@ Aplicar ferramentas globais, `MOTD`, `skel`, drop-in SSH jailed e patch IRC: sudo python3 REPO/tools/tools.py ``` +Esse comando também: + +- garante `pmurad-admin` com sudo administrativo via `/etc/sudoers.d/90-runv-pmurad-admin` +- remove `pmurad-admin` do grupo `runv-jailed` +- não altera membros já existentes por omissão + Simular: ```bash @@ -60,6 +67,18 @@ Reaplicar só arquivos e patch IRC, sem APT: sudo python3 REPO/tools/tools.py --skip-apt ``` +Reaplicar sem APT: + +```bash +sudo python3 REPO/tools/tools.py --skip-apt +``` + +Se quiser reconciliar membros já existentes de uma vez (jail SSH + patch IRC): + +```bash +sudo python3 REPO/tools/tools.py --reconcile-existing-users +``` + ## Setup do onboarding via SSH (`entre`) Instalar/configurar o usuário `entre` e o fluxo de pedido: @@ -174,6 +193,18 @@ Modo interativo: sudo python3 REPO/scripts/admin/create_runv_user.py --interactive ``` +Aprovar direto da fila pelo `request_id`: + +```bash +sudo python3 REPO/scripts/admin/create_runv_user.py --request-id ID_DO_PEDIDO +``` + +Processar toda a fila pendente de uma vez: + +```bash +sudo python3 REPO/scripts/admin/create_runv_user.py --all-pending +``` + ## Atualizar usuário existente Abrir menu interativo: diff --git a/email/configure_mailgun.py b/email/configure_mailgun.py @@ -20,6 +20,12 @@ from pathlib import Path from typing import Any MODULE_ROOT = Path(__file__).resolve().parent +ADMIN_DIR = MODULE_ROOT.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + STATE_PATH = Path("/etc/runv-email.json") SECRETS_PATH = Path("/etc/runv-email.secrets.json") @@ -238,6 +244,10 @@ def main() -> int: help="usar o configurador SMTP/msmtp legado (desativado por predefinição)", ) args = parser.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) if args.legacy_smtp: import configure_msmtp_legacy as leg # type: ignore diff --git a/email/configure_msmtp.py b/email/configure_msmtp.py @@ -8,9 +8,17 @@ Use ``configure_mailgun.py`` (recomendado) ou ``configure_msmtp_legacy.py`` (SMT from __future__ import annotations import sys +from pathlib import Path + +ADMIN_DIR = Path(__file__).resolve().parent.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli def main() -> int: + ensure_admin_cli(script_name=Path(__file__).name) print( "Este comando foi substituído.\n" " Mailgun (API, predefinido): sudo python3 email/configure_mailgun.py\n" diff --git a/email/configure_msmtp_legacy.py b/email/configure_msmtp_legacy.py @@ -33,6 +33,12 @@ PASS_SCRIPT_DEST = PASS_SCRIPT_DIR / "netrc_password.py" LOGFILE_MSMT = Path("/var/log/msmtp.log") MODULE_ROOT = Path(__file__).resolve().parent +ADMIN_DIR = MODULE_ROOT.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + SOURCE_PASS_SCRIPT = MODULE_ROOT / "scripts" / "netrc_password.py" APT_PACKAGES = ("msmtp", "msmtp-mta", "ca-certificates", "bsd-mailx") @@ -382,6 +388,10 @@ def main() -> int: ) parser.add_argument("--skip-apt", action="store_true", help="não executar apt-get") args = parser.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) setup_logging(args.verbose) require_root() diff --git a/email/templates/admin_new_request.txt b/email/templates/admin_new_request.txt @@ -1,13 +1,15 @@ runv.club — novo pedido de entrada +Olá, + Foi recebido um novo pedido através do fluxo «entre». ID do pedido: {request_id} Data (UTC): {timestamp} - Nome de utilizador desejado: {username} E-mail de contato: {email} Fingerprint SHA256 da chave: {fingerprint} ---- -Este aviso foi gerado automaticamente. Consulte a fila em /var/lib/runv/entre-queue/ +Consulte a fila em /var/lib/runv/entre-queue/ para revisar o pedido. + +Equipe runv.club diff --git a/email/templates/admin_user_created.txt b/email/templates/admin_user_created.txt @@ -5,5 +5,6 @@ Foi criada uma conta Unix runv para o seguinte utilizador: Utilizador: {username} Email (metadado): {email} Operador/script: {operator_info} +Referência do pedido: {request_reference} Timestamp: {timestamp} diff --git a/email/templates/user_account_created.txt b/email/templates/user_account_created.txt @@ -5,6 +5,7 @@ Olá {username}, A sua conta no runv.club foi criada. Utilizamos o endereço {email} como o seu email de contacto nesta conta. +{request_reference} Acesso por SSH ---------------- @@ -20,5 +21,7 @@ Espaço web A sua página de membro deverá estar disponível em: {member_url} -Cumprimentos, +Se esta for a sua primeira experiência numa pubnix, vá com calma: explore o ambiente, leia a wiki, edite a sua página e apareça no IRC da comunidade quando quiser. + +Bem-vindo(a), Equipe runv.club diff --git a/email/templates/user_account_removed.txt b/email/templates/user_account_removed.txt @@ -6,5 +6,5 @@ A conta associada ao utilizador {username} foi removida do runv.club. Se você não esperava esta mensagem, entre em contato com a administração. -Cumprimentos, +Atenciosamente, Equipe runv.club diff --git a/email/templates/user_approved.txt b/email/templates/user_approved.txt @@ -1,10 +1,12 @@ runv.club — pedido aprovado -Olá, +Olá {username}, O seu pedido de entrada (referência {request_id}) foi aprovado pela administraçã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. +O acesso ao runv.club é por **SSH**. Guarde bem a sua **chave privada OpenSSH** e nunca a compartilhe com ninguém. -Cumprimentos, +Se precisar falar com a equipe, escreva para admin@runv.club. + +Um abraço, Equipe runv.club diff --git a/patches/patch_irc.py b/patches/patch_irc.py @@ -33,6 +33,13 @@ import sys from pathlib import Path from typing import Final +_PATCHES_DIR = Path(__file__).resolve().parent +_ADMIN_DIR = _PATCHES_DIR.parent / "scripts" / "admin" +if str(_ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(_ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + # SASL ainda não entra no patch; quando entrar, é na mão no WeeChat com sec.data (nada de # password em claro neste repo). Isto é só o boneco dos comandos, para não ir buscar à memória. SASL_WEECHAT_SNIPPETS: Final[tuple[str, ...]] = ( @@ -751,6 +758,10 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) log = setup_logging(args.verbose) if args.port is None: diff --git a/patches/undoperm.py b/patches/undoperm.py @@ -1,140 +0,0 @@ -#!/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/patches/yetgg.py b/patches/yetgg.py @@ -35,6 +35,13 @@ def repo_root() -> Path: return Path(__file__).resolve().parent.parent +_ADMIN_DIR = repo_root() / "scripts" / "admin" +if str(_ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(_ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + + def load_script_module(name: str, path: Path) -> Any: spec = importlib.util.spec_from_file_location(name, path) if spec is None or spec.loader is None: @@ -106,6 +113,10 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) log = setup_logging(args.verbose) if not args.dry_run: diff --git a/scripts/admin/admin_guard.py b/scripts/admin/admin_guard.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Proteção comum para scripts administrativos do runv.club. + +Regra: +- só operadores administrativos autorizados podem executar os entrypoints protegidos +- a conta root directa também é aceite +- a lista de operadores pode ser ajustada com RUNV_ADMIN_USERS=nome1,nome2 +""" + +from __future__ import annotations + +import getpass +import os +import sys +from typing import Final + +DEFAULT_ALLOWED_ADMIN_USERS: Final[tuple[str, ...]] = ("pmurad-admin",) + + +def resolve_allowed_admin_users() -> set[str]: + raw = os.environ.get("RUNV_ADMIN_USERS", "").strip() + if not raw: + return set(DEFAULT_ALLOWED_ADMIN_USERS) + names = {part.strip() for part in raw.split(",") if part.strip()} + return names or set(DEFAULT_ALLOWED_ADMIN_USERS) + + +def resolve_operator_user() -> str: + sudo_user = os.environ.get("SUDO_USER", "").strip() + if sudo_user: + return sudo_user + user = os.environ.get("USER", "").strip() + if user: + return user + return getpass.getuser().strip() + + +def ensure_admin_cli(*, script_name: str, dry_run: bool = False) -> str: + operator = resolve_operator_user() or "root" + if operator == "root": + return operator + allowed = resolve_allowed_admin_users() + if operator in allowed: + return operator + allowed_list = ", ".join(sorted(allowed)) + print( + f"Acesso negado em {script_name}: operador {operator!r} não está autorizado. " + f"Permitidos: root, {allowed_list}.", + file=sys.stderr, + ) + raise SystemExit(1) diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -118,8 +118,13 @@ DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock") DEFAULT_LOG_PATH: Final[Path] = Path("/var/log/runv-user-provision.log") DEFAULT_BASE_URL: Final[str] = "http://runv.club" +DEFAULT_ENTRE_QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue") DEFAULT_GEMINI_HOST_PUBLIC: Final[str] = "runv.club" GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") +DEFAULT_ALLOWED_ADMIN_USERS: Final[tuple[str, ...]] = ("pmurad-admin",) +REQUEST_ID_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) # Quota ext4 (valores padrão runv; limites em MiB = 1024² bytes → setquota usa kiB de 1024 B) DEFAULT_QUOTA_SOFT_MIB: Final[int] = 450 @@ -153,6 +158,47 @@ class QuotaNotAvailableError(ValidationError): """Sistema de quotas não preparado (ext4 usrquota ausente, ferramentas, etc.).""" +@dataclass(frozen=True) +class QueueApprovalRequest: + request_id: str + username: str + email: str + public_key: str + fingerprint: str + queue_path: Path + payload: dict[str, Any] + + +def resolve_allowed_admin_users() -> set[str]: + raw = os.environ.get("RUNV_ADMIN_USERS", "").strip() + if not raw: + return set(DEFAULT_ALLOWED_ADMIN_USERS) + names = {part.strip() for part in raw.split(",") if part.strip()} + return names or set(DEFAULT_ALLOWED_ADMIN_USERS) + + +def resolve_operator_user() -> str: + sudo_user = os.environ.get("SUDO_USER", "").strip() + if sudo_user: + return sudo_user + return getpass.getuser().strip() + + +def require_authorized_admin_operator(*, dry_run: bool) -> str: + operator = resolve_operator_user() + allowed = resolve_allowed_admin_users() + if operator not in allowed: + allowed_list = ", ".join(sorted(allowed)) + msg = ( + f"operação permitida apenas a administrador autorizado. " + f"Operador detectado: {operator!r}. Permitidos: {allowed_list}." + ) + if dry_run: + raise ValidationError(msg) + raise SystemProvisionError(msg) + return operator + + # validação username / email def validate_username(username: str) -> str: """ @@ -276,6 +322,194 @@ def validate_public_key(public_key_line: str, tmp_dir: Path | None = None) -> tu return normalized, fp +def load_queue_request_by_id(request_id: str, queue_dir: Path) -> QueueApprovalRequest: + rid = request_id.strip().lower() + if not REQUEST_ID_PATTERN.fullmatch(rid): + raise ValidationError("request_id inválido: esperado UUID em minúsculas.") + queue_path = queue_dir / f"{rid}.json" + if not queue_path.is_file(): + raise ValidationError(f"pedido não encontrado na fila: {queue_path}") + try: + payload = json.loads(queue_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + raise ValidationError(f"não foi possível ler o pedido {rid!r}: {e}") from e + if not isinstance(payload, dict): + raise ValidationError(f"pedido {rid!r} inválido: esperado objeto JSON.") + + username = validate_username(str(payload.get("username", ""))) + email = validate_email(str(payload.get("email", ""))) + normalized_key, computed_fingerprint = validate_public_key(str(payload.get("public_key", ""))) + queued_fp = str(payload.get("public_key_fingerprint", "")).strip() + if queued_fp and queued_fp != computed_fingerprint: + raise ValidationError( + f"fingerprint do pedido {rid!r} diverge da chave pública armazenada." + ) + status = str(payload.get("status", "pending")).strip().lower() + if status and status != "pending": + raise ValidationError(f"pedido {rid!r} não está pendente (status={status!r}).") + + return QueueApprovalRequest( + request_id=rid, + username=username, + email=email, + public_key=normalized_key, + fingerprint=computed_fingerprint, + queue_path=queue_path, + payload=payload, + ) + + +def archive_approved_queue_request( + approval: QueueApprovalRequest, + *, + operator: str, + created_username: str, + dry_run: bool, + log: logging.Logger, +) -> None: + approved_dir = approval.queue_path.parent / "approved" + archived_payload = dict(approval.payload) + archived_payload["status"] = "approved" + archived_payload["approved_at"] = datetime.now(timezone.utc).isoformat() + archived_payload["approved_by"] = operator + archived_payload["provisioned_username"] = created_username + + if dry_run: + log.info( + "[dry-run] arquivaria pedido aprovado em %s", + approved_dir / approval.queue_path.name, + ) + return + + approved_dir.mkdir(parents=True, exist_ok=True) + dest = approved_dir / approval.queue_path.name + if dest.exists(): + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + dest = approved_dir / f"{approval.request_id}.{ts}.json" + dest.write_text( + json.dumps(archived_payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + approval.queue_path.unlink(missing_ok=True) + log.info("pedido %s arquivado em %s", approval.request_id, dest) + + +def list_pending_queue_request_ids(queue_dir: Path) -> list[str]: + if not queue_dir.is_dir(): + raise ValidationError(f"fila inexistente: {queue_dir}") + items: list[tuple[float, str]] = [] + for path in queue_dir.glob("*.json"): + if not path.is_file(): + continue + rid = path.stem.strip().lower() + if not REQUEST_ID_PATTERN.fullmatch(rid): + continue + try: + mtime = path.stat().st_mtime + except OSError: + mtime = 0.0 + items.append((mtime, rid)) + items.sort(key=lambda item: (item[0], item[1])) + return [rid for _mtime, rid in items] + + +def process_all_pending_requests(args: argparse.Namespace) -> int: + try: + operator_user = require_authorized_admin_operator(dry_run=bool(args.dry_run)) + request_ids = list_pending_queue_request_ids(args.queue_dir) + except (ValidationError, SystemProvisionError) as e: + print(f"Acesso: {e}", file=sys.stderr) + return EXIT_VALIDATION if isinstance(e, ValidationError) else EXIT_SYSTEM + + if not request_ids: + print(f"Nenhum pedido pendente em {args.queue_dir}.") + return EXIT_OK + + print(f"Processando {len(request_ids)} pedido(s) da fila em {args.queue_dir}") + print(f"Operador autorizado: {operator_user}") + print() + + base_cmd = [sys.executable, str(Path(__file__).resolve())] + passthrough_flags: list[str] = [] + + if args.dry_run: + passthrough_flags.append("--dry-run") + if args.verbose: + passthrough_flags.append("--verbose") + if args.force_index: + passthrough_flags.append("--force-index") + if args.with_readme: + passthrough_flags.append("--with-readme") + if args.force_readme: + passthrough_flags.append("--force-readme") + if args.no_jail: + passthrough_flags.append("--no-jail") + if args.force_gopher: + passthrough_flags.append("--force-gopher") + if args.force_gemini: + passthrough_flags.append("--force-gemini") + if args.no_refresh_landing_members: + passthrough_flags.append("--no-refresh-landing-members") + if args.no_quota: + passthrough_flags.append("--no-quota") + if args.require_quota: + passthrough_flags.append("--require-quota") + if args.no_welcome_email: + passthrough_flags.append("--no-welcome-email") + if args.no_admin_create_email: + passthrough_flags.append("--no-admin-create-email") + + value_flags: list[str] = [ + "--queue-dir", + str(args.queue_dir), + "--metadata-file", + str(args.metadata_file), + "--lock-file", + str(args.lock_file), + "--log-file", + str(args.log_file), + "--base-url", + str(args.base_url), + "--landing-document-root", + str(args.landing_document_root), + "--quota-soft-mb", + str(args.quota_soft_mb), + "--quota-hard-mb", + str(args.quota_hard_mb), + "--quota-inode-soft", + str(args.quota_inode_soft), + "--quota-inode-hard", + str(args.quota_inode_hard), + ] + if args.members_homes_root is not None: + value_flags.extend(["--members-homes-root", str(args.members_homes_root)]) + if args.welcome_ssh_host: + value_flags.extend(["--welcome-ssh-host", str(args.welcome_ssh_host)]) + + success = 0 + failures: list[tuple[str, int]] = [] + for rid in request_ids: + cmd = [*base_cmd, "--request-id", rid, *passthrough_flags, *value_flags] + print(f"==> {rid}") + proc = subprocess.run(cmd, text=True) + if proc.returncode == EXIT_OK: + success += 1 + else: + failures.append((rid, proc.returncode)) + print() + + print("========== create_runv_user.py — lote ==========") + print(f"Pedidos totais: {len(request_ids)}") + print(f"Sucessos: {success}") + print(f"Falhas: {len(failures)}") + if failures: + print("Pedidos com falha:") + for rid, code in failures: + print(f" - {rid} (exit {code})") + print("===============================================") + return EXIT_OK if not failures else EXIT_INCONSISTENT + + def read_public_key_from_args(pub: str | None, pub_file: Path | None) -> str: if pub and pub_file: raise ValidationError("use apenas --public-key ou --public-key-file, não ambos") @@ -1303,6 +1537,7 @@ def try_send_welcome_email( username: str, user_email: str, fingerprint: str, + request_id: str | None, base_url: str, welcome_ssh_host: str | None, no_welcome_email: bool, @@ -1384,6 +1619,11 @@ def try_send_welcome_email( username=username, email=user_email, fingerprint=fingerprint, + request_reference=( + f"Referência do seu pedido: {request_id}" + if request_id + else "Referência do seu pedido: não aplicável" + ), member_url=member_url, ssh_instructions=ssh_instructions, ) @@ -1399,6 +1639,7 @@ def try_send_admin_user_created_email( user_email: str, operator_info: str, timestamp: str, + request_id: str | None, no_admin_create_email: bool, dry_run: bool, log: logging.Logger, @@ -1471,6 +1712,7 @@ def try_send_admin_user_created_email( email=user_email, operator_info=operator_info, timestamp=timestamp, + request_reference=request_id or "manual", ) log.info("email admin (conta criada) enviado para %s", admin) print(f" admin (conta): email enviado para {admin}") @@ -1493,6 +1735,18 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="modo interativo (perguntas no terminal); também é o padrão se não passar nenhum argumento", ) + p.add_argument( + "--request-id", + "--user", + dest="request_id", + default=None, + help="aprova automaticamente um pedido pendente da fila entre-queue pelo UUID", + ) + p.add_argument( + "--all-pending", + action="store_true", + help="aprova e processa todos os pedidos pendentes da entre-queue, em sequência", + ) p.add_argument("--username", default=None, help="nome de usuário Unix (minúsculas)") p.add_argument("--email", default=None, help="email do utilizador (também em users.json)") g = p.add_mutually_exclusive_group(required=False) @@ -1555,6 +1809,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help=f"log local (padrão: {DEFAULT_LOG_PATH})", ) p.add_argument( + "--queue-dir", + type=Path, + default=DEFAULT_ENTRE_QUEUE_DIR, + help=f"fila do entre para aprovar por request_id (padrão: {DEFAULT_ENTRE_QUEUE_DIR})", + ) + p.add_argument( "--base-url", default=DEFAULT_BASE_URL, help=f"URL base para o resumo (padrão: {DEFAULT_BASE_URL})", @@ -1671,6 +1931,33 @@ def main(argv: list[str] | None = None) -> int: return code return EXIT_VALIDATION + if args.all_pending: + if args.request_id or args.username or args.email or args.public_key or args.public_key_file: + print( + "Erro: --all-pending não deve ser combinado com --request-id/--user, --username, --email ou chave manual.", + file=sys.stderr, + ) + return EXIT_VALIDATION + return process_all_pending_requests(args) + + queue_request: QueueApprovalRequest | None = None + if args.request_id: + if args.username or args.email or args.public_key or args.public_key_file: + print( + "Erro: --request-id/--user não deve ser combinado com --username, --email, --public-key ou --public-key-file.", + file=sys.stderr, + ) + return EXIT_VALIDATION + try: + queue_request = load_queue_request_by_id(args.request_id, args.queue_dir) + except ValidationError as e: + print(f"Validação: {e}", file=sys.stderr) + return EXIT_VALIDATION + args.username = queue_request.username + args.email = queue_request.email + args.public_key = queue_request.public_key + args.public_key_file = None + if not args.username or not args.email: print( "Erro: informe --username e --email, ou use --interactive / execute sem argumentos.", @@ -1692,6 +1979,12 @@ def main(argv: list[str] | None = None) -> int: args.interactive, ) + try: + operator_user = require_authorized_admin_operator(dry_run=bool(args.dry_run)) + except (ValidationError, SystemProvisionError) as e: + print(f"Acesso: {e}", file=sys.stderr) + return EXIT_VALIDATION if isinstance(e, ValidationError) else EXIT_SYSTEM + if os.geteuid() != 0 and not args.dry_run: print("Erro: execute como root (ou sudo) para criar usuários.", file=sys.stderr) log.error("recusado: euid != 0 e não é dry-run") @@ -1741,6 +2034,9 @@ def main(argv: list[str] | None = None) -> int: print(f" email: {email}") print(f" home: {home}") print(f" fingerprint: {fingerprint}") + print(f" operador: {operator_user}") + if queue_request is not None: + print(f" pedido fila: {queue_request.request_id} ({queue_request.queue_path})") print( " ações: (1) adduser + skel (2) authorized_keys (3) public_html " "(4) public_gopher + public_gemini + bind Gemini (5) README só com --with-readme " @@ -1859,7 +2155,7 @@ def main(argv: list[str] | None = None) -> int: email=email, public_key_fingerprint=fingerprint, created_at=datetime.now(timezone.utc).isoformat(), - created_by=os.environ.get("SUDO_USER") or getpass.getuser(), + created_by=operator_user, home_directory=str(home), status=overall_status, quota_enabled=qr.enabled, @@ -1920,6 +2216,8 @@ def main(argv: list[str] | None = None) -> int: print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") print(f" fingerprint: {fingerprint}") print(f" metadados: {args.metadata_file}") + if queue_request is not None: + print(f" pedido aprovado: {queue_request.request_id}") dr_resolved = ( args.landing_document_root.resolve() if args.landing_document_root else None ) @@ -1971,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int: username=user, user_email=email, fingerprint=fingerprint, + request_id=queue_request.request_id if queue_request else None, base_url=args.base_url, welcome_ssh_host=welcome_host_opt, no_welcome_email=bool(args.no_welcome_email), @@ -1982,10 +2281,19 @@ def main(argv: list[str] | None = None) -> int: user_email=email, operator_info=record.created_by, timestamp=record.created_at, + request_id=queue_request.request_id if queue_request else None, no_admin_create_email=bool(args.no_admin_create_email), dry_run=bool(args.dry_run), log=log, ) + if queue_request is not None: + archive_approved_queue_request( + queue_request, + operator=operator_user, + created_username=user, + dry_run=bool(args.dry_run), + log=log, + ) if not args.no_quota and qr.status in ("failed", "not_configured"): print( diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py @@ -70,6 +70,7 @@ 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") +DEFAULT_ALLOWED_ADMIN_USERS: Final[tuple[str, ...]] = ("pmurad-admin",) VERSION: Final[str] = "0.04" @@ -93,6 +94,35 @@ def setup_del_user_log(*, verbose: bool) -> logging.Logger: return log +def resolve_allowed_admin_users() -> set[str]: + raw = os.environ.get("RUNV_ADMIN_USERS", "").strip() + if not raw: + return set(DEFAULT_ALLOWED_ADMIN_USERS) + names = {part.strip() for part in raw.split(",") if part.strip()} + return names or set(DEFAULT_ALLOWED_ADMIN_USERS) + + +def resolve_operator_user() -> str: + sudo_user = os.environ.get("SUDO_USER", "").strip() + if sudo_user: + return sudo_user + user = os.environ.get("USER", "").strip() + return user or "root" + + +def require_authorized_admin_operator() -> str: + operator = resolve_operator_user() + allowed = resolve_allowed_admin_users() + if operator not in allowed: + allowed_list = ", ".join(sorted(allowed)) + print( + f"Acesso negado: operador {operator!r} não está autorizado. Permitidos: {allowed_list}.", + file=sys.stderr, + ) + raise SystemExit(EXIT_VALIDATION) + return operator + + # validação / root def validate_privileges() -> None: if os.geteuid() != 0: @@ -667,6 +697,7 @@ def main() -> int: args = parser.parse_args() log = setup_del_user_log(verbose=args.verbose) + _operator_user = require_authorized_admin_operator() username = validate_username_syntax(args.username) diff --git a/scripts/admin/perm1.py b/scripts/admin/perm1.py @@ -12,6 +12,7 @@ _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from admin_guard import ensure_admin_cli import runv_jail as rj EXCLUDE_NAMES = frozenset({"nobody", "pmurad-admin", "entre"}) @@ -64,6 +65,10 @@ def main(argv: list[str] | None = None) -> int: help="não executar jk_init; exige jail já com bin/ (só grupo + home no jail + bind + fstab)", ) args = p.parse_args(argv) + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) log = setup_logging(args.verbose) diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -29,6 +29,12 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Final +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from admin_guard import ensure_admin_cli + # constantes VERSION: Final[str] = "0.14" @@ -1154,6 +1160,10 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) log = setup_logging(args.verbose) if os.geteuid() != 0 and not args.dry_run: diff --git a/scripts/admin/skel.py b/scripts/admin/skel.py @@ -14,6 +14,12 @@ import sys from pathlib import Path from typing import Final +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from admin_guard import ensure_admin_cli + # --------------------------------------------------------------------------- # Constantes # --------------------------------------------------------------------------- @@ -349,6 +355,10 @@ def main() -> int: version=f"%(prog)s {VERSION} — runv.club", ) args = parser.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) if args.dry_run: return run_dry_run(args.verbose) diff --git a/scripts/admin/starthere.py b/scripts/admin/starthere.py @@ -41,6 +41,8 @@ _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from admin_guard import ensure_admin_cli + try: from runv_mount import MountLookupError, find_mount_triple except ModuleNotFoundError: @@ -608,6 +610,10 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: parser = build_parser() args = parser.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) try: require_root() diff --git a/scripts/admin/update_user.py b/scripts/admin/update_user.py @@ -36,6 +36,7 @@ _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from admin_guard import ensure_admin_cli from runv_landing_sync import try_sync_landing_via_genlanding USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") @@ -660,6 +661,10 @@ def read_key_file(path: Path) -> str: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) dry_run = args.dry_run + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(dry_run), + ) log = setup_update_user_log() require_root(dry_run=dry_run) diff --git a/scripts/doom/doom.py b/scripts/doom/doom.py @@ -38,8 +38,13 @@ DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") _DOOM_DIR = Path(__file__).resolve().parent _REPO_SCRIPTS = _DOOM_DIR.parent +_ADMIN_DIR = _REPO_SCRIPTS / "admin" +if str(_ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(_ADMIN_DIR)) _DEL_USER_PY: Final[Path] = _REPO_SCRIPTS / "admin" / "del-user.py" +from admin_guard import ensure_admin_cli + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -237,6 +242,10 @@ def main() -> int: ) p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club") args = p.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) keeper = resolve_keeper(args) keeper = validate_username_syntax(keeper) diff --git a/site/build_directory.py b/site/build_directory.py @@ -21,6 +21,13 @@ import sys from datetime import datetime, timezone from pathlib import Path +SCRIPT_DIR = Path(__file__).resolve().parent +ADMIN_DIR = SCRIPT_DIR.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Gera members.json público para site/") @@ -83,6 +90,10 @@ def load_users(path: Path) -> list[dict]: def main() -> None: args = parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) users = load_users(args.users_json) members: list[dict] = [] for row in users: diff --git a/site/genlanding.py b/site/genlanding.py @@ -25,6 +25,7 @@ import re import shutil import subprocess import sys +import tempfile from pathlib import Path from typing import Final @@ -34,6 +35,12 @@ EXIT_USAGE: Final[int] = 1 EXIT_ERROR: Final[int] = 2 SCRIPT_DIR = Path(__file__).resolve().parent +ADMIN_DIR = SCRIPT_DIR.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + DEFAULT_SOURCE: Final[Path] = SCRIPT_DIR / "public" DEFAULT_MEMBERS_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") @@ -171,29 +178,62 @@ def copy_landing(source: Path, dest: Path, *, dry_run: bool) -> None: shutil.copytree(source, dest) +def preserve_existing_members_json(document_root: Path, *, dry_run: bool) -> Path | None: + """Guarda uma cópia temporária do members.json actual para rollback seguro da constelação.""" + current = document_root / "data" / "members.json" + if dry_run or not current.is_file(): + return None + fd, tmp_name = tempfile.mkstemp(prefix="runv-members-backup-", suffix=".json") + os.close(fd) + backup = Path(tmp_name) + shutil.copy2(current, backup) + return backup + + +def restore_members_json_backup( + document_root: Path, + backup: Path | None, + *, + dry_run: bool, +) -> bool: + """Restaura o members.json anterior se existir backup.""" + if dry_run or backup is None or not backup.is_file(): + return False + out = document_root / "data" / "members.json" + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup, out) + return True + + +def cleanup_members_json_backup(backup: Path | None) -> None: + if backup is None: + return + backup.unlink(missing_ok=True) + + def refresh_members_json_in_document_root( document_root: Path, *, users_json: Path, homes_root: Path | None, dry_run: bool, -) -> None: +) -> bool: """Regenera data/members.json no DocumentRoot após copiar site/public (stdlib).""" if dry_run: print( " [dry-run] regeneraria data/members.json " f"({users_json} → {document_root / 'data' / 'members.json'})", ) - return + return True if not document_root.is_dir(): eprint( f"Erro: DocumentRoot inexistente ({document_root}); não é possível gravar data/members.json." ) - return + return False script = SCRIPT_DIR / "build_directory.py" if not script.is_file(): eprint(f"Aviso: {script} não encontrado; members.json não regenerado.") - return + return False out = document_root / "data" / "members.json" cmd = [ sys.executable, @@ -213,6 +253,7 @@ def refresh_members_json_in_document_root( f"Aviso: build_directory.py terminou com código {r.returncode}; " f"members.json pode estar desactualizado. {tail[:800]}" ) + return False else: print(f" [ok] members.json em {out}") if r.stderr.strip(): @@ -227,8 +268,11 @@ def refresh_members_json_in_document_root( ) else: eprint("Aviso: members.json não é uma lista JSON; verifique build_directory.py.") + return False except (OSError, json.JSONDecodeError, TypeError) as e: eprint(f"Aviso: não foi possível confirmar o conteúdo de members.json: {e}") + return False + return True def chown_www_data(path: Path, *, dry_run: bool) -> None: @@ -343,13 +387,14 @@ def sync_public_only_main(args: argparse.Namespace) -> int: print(f" origem: {source}") print() + members_backup = preserve_existing_members_json(document_root, dry_run=args.dry_run) try: copy_landing(source, document_root, dry_run=args.dry_run) if not args.dry_run: chown_www_data(document_root, dry_run=False) if not args.no_refresh_members: - refresh_members_json_in_document_root( + refreshed = refresh_members_json_in_document_root( document_root, users_json=args.members_users_json, homes_root=args.members_homes_root.resolve() @@ -357,9 +402,19 @@ def sync_public_only_main(args: argparse.Namespace) -> int: else None, dry_run=args.dry_run, ) + if not refreshed and restore_members_json_backup( + document_root, + members_backup, + dry_run=args.dry_run, + ): + print(" [ok] members.json anterior restaurado; constelação preservada.") + elif restore_members_json_backup(document_root, members_backup, dry_run=args.dry_run): + print(" [ok] members.json anterior preservado (--no-refresh-members).") except (FileNotFoundError, OSError, RuntimeError) as e: eprint(f"Erro: {e}") + cleanup_members_json_backup(members_backup) return EXIT_ERROR + cleanup_members_json_backup(members_backup) print() print(" [ok] sync-public-only concluído (Apache não foi alterado).") @@ -368,6 +423,10 @@ def sync_public_only_main(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) if args.dev and args.certbot: eprint("Erro: --certbot não pode ser usado com --dev (Certbot não serve para domínios locais).") @@ -395,10 +454,12 @@ def main(argv: list[str] | None = None) -> int: print(f" origem: {source}") print() + members_backup = preserve_existing_members_json(document_root, dry_run=args.dry_run) if not apache_installed(): eprint("Erro: Apache não parece instalado (falta /usr/sbin/apache2ctl).") eprint(" Instale com: sudo apt install -y apache2") eprint(" ou corra scripts/admin/starthere.py antes.") + cleanup_members_json_backup(members_backup) return EXIT_ERROR vhost_body = render_vhost( @@ -439,7 +500,7 @@ def main(argv: list[str] | None = None) -> int: chown_www_data(document_root, dry_run=False) if not args.no_refresh_members: - refresh_members_json_in_document_root( + refreshed = refresh_members_json_in_document_root( document_root, users_json=args.members_users_json, homes_root=args.members_homes_root.resolve() @@ -447,6 +508,14 @@ def main(argv: list[str] | None = None) -> int: else None, dry_run=args.dry_run, ) + if not refreshed and restore_members_json_backup( + document_root, + members_backup, + dry_run=args.dry_run, + ): + print(" [ok] members.json anterior restaurado; constelação preservada.") + elif restore_members_json_backup(document_root, members_backup, dry_run=args.dry_run): + print(" [ok] members.json anterior preservado (--no-refresh-members).") if args.dry_run: print(f" [dry-run] escreveria {conf_path}") @@ -500,7 +569,9 @@ def main(argv: list[str] | None = None) -> int: except (FileNotFoundError, OSError, RuntimeError) as e: eprint(f"Erro: {e}") + cleanup_members_json_backup(members_backup) return EXIT_ERROR + cleanup_members_json_backup(members_backup) print() print("Próximos passos:") diff --git a/site/news/publish_news.py b/site/news/publish_news.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 """ -Lê ficheiros ``*.md`` nesta pasta (``site/news/``), gera entradas em +Lê ficheiros ``*.md`` e ``*.txt`` nesta pasta (``site/news/``), gera entradas em ``site/public/news/data/news.json``, ``site/public/news/feed.rss`` e actualiza ``lastmod`` da entrada ``/news/`` em ``site/public/sitemap.xml``. -Formato de cada ``.md``: +Formato de cada ficheiro: - Linha 1: título - - Linhas seguintes: corpo (Markdown leve: **negrito**, *itálico*, _itálico_, ++sublinhado++) + - Linhas seguintes: corpo + - ``.md`` usa Markdown básico seguro + - ``.txt`` vira texto simples com parágrafos e quebras de linha preservadas -Os ``.md`` processados são **apagados**. Ficheiros cujo nome começa por ``_`` são ignorados +Os ficheiros processados são **apagados**. Ficheiros cujo nome começa por ``_`` são ignorados (ex.: ``_exemplo.md`` para documentação). Não versionar notícias no HTML: os dados ficam em ``news.json`` (tipicamente ignorado pelo git @@ -40,6 +42,11 @@ from zoneinfo import ZoneInfo SCRIPT_DIR = Path(__file__).resolve().parent REPO_SITE = SCRIPT_DIR.parent _REPO_ROOT = REPO_SITE.parent +_ADMIN_DIR = _REPO_ROOT / "scripts" / "admin" +if str(_ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(_ADMIN_DIR)) + +from admin_guard import ensure_admin_cli PUBLIC_NEWS = REPO_SITE / "public" / "news" DATA_DIR = PUBLIC_NEWS / "data" @@ -53,6 +60,18 @@ BR_FALLBACK_TZ = timezone(timedelta(hours=-3)) SITE_URL: Final[str] = "https://runv.club" DEFAULT_LANDING_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv.club/html") DEFAULT_MEMBERS_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") +SUPPORTED_NEWS_SUFFIXES: Final[tuple[str, ...]] = (".md", ".txt") +_CODE_PLACEHOLDER_RE: Final[re.Pattern[str]] = re.compile(r"\x00CODE(\d+)\x00") +_LINK_RE: Final[re.Pattern[str]] = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)") +_BOLD_RE: Final[re.Pattern[str]] = re.compile(r"(?<!\*)\*\*([^\n*][\s\S]*?[^\n*])\*\*(?!\*)") +_UNDERLINE_RE: Final[re.Pattern[str]] = re.compile(r"\+\+([^\n+][\s\S]*?[^\n+])\+\+") +_ITALIC_STAR_RE: Final[re.Pattern[str]] = re.compile(r"(?<!\*)\*([^\s*][^*\n]*?[^\s*])\*(?!\*)") +_ITALIC_UNDERSCORE_RE: Final[re.Pattern[str]] = re.compile(r"(?<!_)_([^\s_][^_\n]*?[^\s_])_(?!_)") +_INLINE_CODE_RE: Final[re.Pattern[str]] = re.compile(r"`([^`\n]+)`") +_SAFE_URL_RE: Final[re.Pattern[str]] = re.compile( + r"^(?:https?://|mailto:|/|#|\.{1,2}/)[^\s]*$", + re.IGNORECASE, +) def sync_landing_after_news( @@ -111,82 +130,206 @@ def sync_landing_after_news( return 1 -def _apply_underline(s: str) -> str: - parts = s.split("++") - out: list[str] = [] - for i, p in enumerate(parts): - if i % 2 == 0: - out.append(_apply_italic_underscore(p)) - else: - out.append("<u>" + html.escape(p) + "</u>") - return "".join(out) - - -def _apply_italic_underscore(s: str) -> str: - parts = re.split(r"(?<!_)_([^_\n]+)_(?!_)", s) - out: list[str] = [] - for i, p in enumerate(parts): - if i % 2 == 0: - out.append(_apply_italic_star(p)) - else: - out.append("<em>" + html.escape(p) + "</em>") - return "".join(out) - - -def _apply_italic_star(s: str) -> str: - """Itálico com *simples* (não **).""" - result: list[str] = [] - i = 0 - n = len(s) - while i < n: - if s[i] == "*": - j = i + 1 - while j < n and s[j] != "*": - j += 1 - if j < n and s[j] == "*" and j > i + 1: - inner = s[i + 1 : j] - result.append("<em>" + html.escape(inner) + "</em>") - i = j + 1 - continue - result.append(html.escape(s[i])) - i += 1 - return "".join(result) - - -def _apply_bold(s: str) -> str: - parts = s.split("**") - out: list[str] = [] - for i, p in enumerate(parts): - if i % 2 == 0: - out.append(_apply_underline(p)) - else: - out.append("<strong>" + html.escape(p) + "</strong>") - return "".join(out) - - -def markdown_body_to_html(body: str) -> str: +def _preserve_code_span(html_text: str, code_segments: list[str]) -> str: + def repl(match: re.Match[str]) -> str: + idx = len(code_segments) + code_segments.append(f"<code>{html.escape(match.group(1))}</code>") + return f"\x00CODE{idx}\x00" + + return _INLINE_CODE_RE.sub(repl, html_text) + + +def _restore_code_span(html_text: str, code_segments: list[str]) -> str: + return _CODE_PLACEHOLDER_RE.sub( + lambda m: code_segments[int(m.group(1))], + html_text, + ) + + +def _safe_href(url: str) -> str | None: + if not _SAFE_URL_RE.fullmatch(url): + return None + if url.lower().startswith("javascript:"): + return None + return html.escape(url, quote=True) + + +def inline_markdown_to_html(text: str) -> str: + escaped = html.escape(text) + code_segments: list[str] = [] + escaped = _preserve_code_span(escaped, code_segments) + + def repl_link(match: re.Match[str]) -> str: + label = inline_markdown_to_html(match.group(1)) + href = _safe_href(match.group(2).strip()) + if href is None: + return html.escape(match.group(0)) + return f'<a href="{href}">{label}</a>' + + escaped = _LINK_RE.sub(repl_link, escaped) + escaped = _BOLD_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", escaped) + escaped = _UNDERLINE_RE.sub(lambda m: f"<u>{m.group(1)}</u>", escaped) + escaped = _ITALIC_STAR_RE.sub(lambda m: f"<em>{m.group(1)}</em>", escaped) + escaped = _ITALIC_UNDERSCORE_RE.sub(lambda m: f"<em>{m.group(1)}</em>", escaped) + return _restore_code_span(escaped, code_segments) + + +def render_plain_text_html(body: str) -> str: body = body.replace("\r\n", "\n").strip() if not body: return "" blocks = re.split(r"\n\s*\n+", body) - paras: list[str] = [] + parts: list[str] = [] for block in blocks: - lines = block.split("\n") - inner = "<br>\n".join(_apply_bold(line) for line in lines) - paras.append(f"<p>{inner}</p>") - return "\n".join(paras) + lines = [html.escape(line.rstrip()) for line in block.split("\n")] + parts.append(f"<p>{'<br>\n'.join(lines)}</p>") + return "\n".join(parts) + + +def render_markdown_html(body: str) -> str: + body = body.replace("\r\n", "\n").strip() + if not body: + return "" + + lines = body.split("\n") + parts: list[str] = [] + paragraph_lines: list[str] = [] + list_type: str | None = None + list_items: list[str] = [] + quote_lines: list[str] = [] + fence_lang = "" + code_lines: list[str] = [] + + def flush_paragraph() -> None: + nonlocal paragraph_lines + if not paragraph_lines: + return + text = " ".join(line.strip() for line in paragraph_lines if line.strip()) + parts.append(f"<p>{inline_markdown_to_html(text)}</p>") + paragraph_lines = [] + + def flush_list() -> None: + nonlocal list_type, list_items + if not list_items or not list_type: + return + tag = "ol" if list_type == "ol" else "ul" + items = "".join(f"<li>{inline_markdown_to_html(item)}</li>" for item in list_items) + parts.append(f"<{tag}>{items}</{tag}>") + list_type = None + list_items = [] + + def flush_quote() -> None: + nonlocal quote_lines + if not quote_lines: + return + quote_html = render_markdown_html("\n".join(quote_lines)) + parts.append(f"<blockquote>{quote_html}</blockquote>") + quote_lines = [] + + def flush_code() -> None: + nonlocal code_lines, fence_lang + code = "\n".join(code_lines) + lang_attr = "" + if fence_lang: + lang_attr = f' class="language-{html.escape(fence_lang, quote=True)}"' + parts.append(f"<pre><code{lang_attr}>{html.escape(code)}</code></pre>") + code_lines = [] + fence_lang = "" + + def flush_all() -> None: + flush_paragraph() + flush_list() + flush_quote() + + in_code = False + for raw_line in lines: + line = raw_line.rstrip() + stripped = line.strip() + + if in_code: + if stripped.startswith("```"): + flush_code() + in_code = False + else: + code_lines.append(raw_line) + continue + if stripped.startswith("```"): + flush_all() + in_code = True + fence_lang = stripped[3:].strip() + code_lines = [] + continue + + if not stripped: + flush_all() + continue + + quote_match = re.match(r"^\s*>\s?(.*)$", line) + if quote_match: + flush_paragraph() + flush_list() + quote_lines.append(quote_match.group(1)) + continue + flush_quote() + + heading_match = re.match(r"^(#{1,6})\s+(.+?)\s*$", stripped) + if heading_match: + flush_all() + level = len(heading_match.group(1)) + text = inline_markdown_to_html(heading_match.group(2)) + parts.append(f"<h{level}>{text}</h{level}>") + continue + + if re.fullmatch(r"(?:-{3,}|\*{3,}|_{3,})", stripped): + flush_all() + parts.append("<hr>") + continue + + ul_match = re.match(r"^\s*[-*+]\s+(.+)$", line) + if ul_match: + flush_paragraph() + if list_type not in (None, "ul"): + flush_list() + list_type = "ul" + list_items.append(ul_match.group(1).strip()) + continue -def parse_md_file(path: Path) -> tuple[str, str]: + ol_match = re.match(r"^\s*\d+\.\s+(.+)$", line) + if ol_match: + flush_paragraph() + if list_type not in (None, "ol"): + flush_list() + list_type = "ol" + list_items.append(ol_match.group(1).strip()) + continue + + flush_list() + paragraph_lines.append(line) + + if in_code: + flush_code() + flush_all() + return "\n".join(parts) + + +def render_body_html(body: str, *, source_kind: str) -> str: + if source_kind == "txt": + return render_plain_text_html(body) + return render_markdown_html(body) + + +def parse_news_file(path: Path) -> tuple[str, str, str]: raw = path.read_text(encoding="utf-8") lines = raw.splitlines() if not lines: raise ValueError(f"{path.name}: ficheiro vazio") - title = lines[0].strip() + title_line = lines[0].strip() + title = re.sub(r"^#\s+", "", title_line).strip() if path.suffix.lower() == ".md" else title_line if not title: raise ValueError(f"{path.name}: primeira linha (título) vazia") body = "\n".join(lines[1:]).lstrip("\n") - return title, body + return title, body, path.suffix.lower().lstrip(".") def load_articles() -> list[dict[str, Any]]: @@ -279,20 +422,25 @@ def update_sitemap_lastmod(news_lastmod: str) -> None: SITEMAP_PATH.write_text(new_text, encoding="utf-8") -def discover_md_files() -> list[Path]: +def discover_news_files() -> list[Path]: out: list[Path] = [] - skip = frozenset({"readme.md", "readme.markdown"}) - for p in sorted(SCRIPT_DIR.glob("*.md")): + skip = frozenset({"readme.md", "readme.markdown", "readme.txt"}) + for p in sorted(SCRIPT_DIR.iterdir()): + if not p.is_file(): + continue if p.name.startswith("_"): continue - if p.name.lower() in skip: + lower_name = p.name.lower() + if lower_name in skip: + continue + if p.suffix.lower() not in SUPPORTED_NEWS_SUFFIXES: continue out.append(p) return out def main() -> int: - ap = argparse.ArgumentParser(description="Publica notícias a partir de .md em site/news/") + ap = argparse.ArgumentParser(description="Publica notícias a partir de .md e .txt em site/news/") ap.add_argument("--dry-run", action="store_true", help="Só mostra o que faria") ap.add_argument("--verbose", "-v", action="store_true") ap.add_argument( @@ -322,15 +470,19 @@ def main() -> int: help="Não copiar site/public para DocumentRoot após publicar", ) args = ap.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) try: now = datetime.now(timezone.utc).astimezone(ZoneInfo(TZ_BR)) except Exception: now = datetime.now(BR_FALLBACK_TZ) - md_files = discover_md_files() - if not md_files: - print("Nenhum ficheiro .md para processar (ignore _*.md).", file=sys.stderr) + news_files = discover_news_files() + if not news_files: + print("Nenhum ficheiro .md ou .txt para processar (ignore _*).", file=sys.stderr) return 0 articles = load_articles() @@ -339,13 +491,13 @@ def main() -> int: w3c = w3c_date(now) new_entries: list[dict[str, Any]] = [] - for path in md_files: + for path in news_files: try: - title, body_md = parse_md_file(path) + title, body_source, source_kind = parse_news_file(path) except ValueError as e: print(f"Erro em {path.name}: {e}", file=sys.stderr) return 1 - body_html = markdown_body_to_html(body_md) + body_html = render_body_html(body_source, source_kind=source_kind) entry = { "id": uuid.uuid4().hex[:12], "title": title, diff --git a/site/public/assets/style.css b/site/public/assets/style.css @@ -259,6 +259,119 @@ body { background: rgba(196, 156, 245, 0.06); } +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0 0 2.5rem; +} + +.button-primary, +.button-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.8rem; + padding: 0.75rem 1rem; + border-radius: 999px; + font-family: "Syne", sans-serif; + font-weight: 700; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, color 0.16s ease, background 0.16s ease; +} + +.button-primary { + color: #08110d; + background: linear-gradient(135deg, rgba(127, 212, 160, 0.96) 0%, rgba(190, 255, 215, 0.92) 100%); + border: 1px solid rgba(127, 212, 160, 0.6); + box-shadow: 0 12px 30px rgba(127, 212, 160, 0.12); +} + +.button-secondary { + color: var(--fg); + background: rgba(196, 156, 245, 0.08); + border: 1px solid rgba(196, 156, 245, 0.28); +} + +.button-primary:hover, +.button-primary:focus-visible, +.button-secondary:hover, +.button-secondary:focus-visible { + transform: translateY(-1px); +} + +.button-primary:hover, +.button-primary:focus-visible { + color: #08110d; + background: linear-gradient(135deg, rgba(148, 232, 180, 0.98) 0%, rgba(206, 255, 227, 0.94) 100%); +} + +.button-secondary:hover, +.button-secondary:focus-visible { + color: var(--accent); + border-color: rgba(127, 212, 160, 0.34); + background: rgba(127, 212, 160, 0.07); +} + +.feature-grid, +.mini-cards, +.quick-paths { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.mini-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 1.35rem; +} + +.quick-paths { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.feature-card, +.mini-card, +.quick-path-card { + padding: 1.15rem 1.05rem 1.15rem; + border-radius: calc(var(--radius) + 2px); + border: 1px solid var(--stroke); + background: + radial-gradient(circle at top left, rgba(127, 212, 160, 0.08), transparent 45%), + linear-gradient(145deg, rgba(232, 228, 220, 0.04) 0%, rgba(12, 11, 15, 0.62) 100%); + box-shadow: + 0 1px 0 rgba(232, 228, 220, 0.05) inset, + 0 10px 24px rgba(0, 0, 0, 0.18); +} + +.feature-card h2, +.mini-card h2, +.quick-path-card h2 { + margin-bottom: 0.6rem; + font-size: 1.04rem; + color: var(--fg); +} + +.feature-card p:last-child, +.mini-card p:last-child, +.quick-path-card p:last-child { + margin-bottom: 0; +} + +.feature-kicker, +.mini-card-kicker { + margin: 0 0 0.55rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +.inline-accent { + color: var(--accent); +} + .section { margin-top: 2.75rem; } @@ -425,6 +538,13 @@ h2 { font-size: 0.98rem; } +.cta-subline { + margin: 0.85rem auto 0; + max-width: 30rem; + color: var(--muted); + font-size: 0.96rem; +} + .cta-join a { color: var(--accent2); text-decoration: none; @@ -612,12 +732,18 @@ h2 { /* FAQ */ .faq-main .faq-item { margin-bottom: 1.75rem; - padding-bottom: 1.35rem; - border-bottom: 1px solid var(--stroke); + padding: 1.1rem 1rem 1.2rem; + border: 1px solid rgba(196, 156, 245, 0.16); + border-radius: calc(var(--radius) - 2px); + background: + linear-gradient( + 145deg, + rgba(196, 156, 245, 0.07) 0%, + rgba(12, 11, 15, 0.2) 100% + ); } .faq-main .faq-item:last-child { - border-bottom: none; margin-bottom: 0; padding-bottom: 0; } @@ -641,6 +767,10 @@ h2 { /* Junte-se (versão curta) */ .join-summary { margin-bottom: 1.5rem; + padding: 0.9rem 1rem 0.7rem 2rem; + border: 1px solid rgba(127, 212, 160, 0.2); + border-radius: calc(var(--radius) - 2px); + background: rgba(127, 212, 160, 0.06); } .join-note { @@ -649,6 +779,14 @@ h2 { margin-top: 0.35rem; } +.join-main h2 { + margin-top: 1.65rem; +} + +.join-main h2:first-of-type { + margin-top: 0; +} + /* Página de notícias (data/news.json + news-page.js) */ .news-main { margin-top: 1.25rem; @@ -777,3 +915,19 @@ h2 { margin-top: 0; } +.wiki-main p + p { + margin-top: 0.8rem; +} + +.wiki-main ul { + margin: 0.75rem 0 1rem; +} + +@media (max-width: 860px) { + .feature-grid, + .mini-cards, + .quick-paths { + grid-template-columns: 1fr; + } +} + diff --git a/site/public/faq/index.html b/site/public/faq/index.html @@ -28,33 +28,46 @@ <a href="/junte-se/">Junte-se</a> </nav> <h1 class="hero-title subpage-title">FAQ</h1> - <p class="subpage-intro">Perguntas frequentes — runv.club</p> + <p class="subpage-intro">Perguntas frequentes sobre acesso, conta, suporte e vida na comunidade.</p> </header> + <section class="mini-cards" aria-label="Atalhos úteis"> + <article class="mini-card"> + <p class="mini-card-kicker">Primeiro passo</p> + <h2>Quer entrar?</h2> + <p>Vá direto para <a href="/junte-se/">Junte-se</a> e siga o fluxo com <span class="ssh-identity">entre@runv.club</span>.</p> + </article> + <article class="mini-card"> + <p class="mini-card-kicker">Suporte rápido</p> + <h2>Precisa falar com alguém?</h2> + <p>Use <a href="mailto:admin@runv.club">admin@runv.club</a> para assuntos sensíveis e o canal <strong>#runv</strong> para dúvidas rápidas.</p> + </article> + </section> + <main class="section prose-block subpage-main faq-main"> <section class="faq-item" aria-labelledby="faq-1"> <h2 id="faq-1" class="faq-q">1. O que é o runv.club?</h2> - <p>O runv.club é um sistema online de acesso e organização de recursos, pensado para simplificar a experiência do usuário em um ambiente digital único. A proposta é centralizar o acesso, reduzir fricção no uso e facilitar o suporte quando surgir alguma dúvida.</p> + <p>O runv.club é uma comunidade brasileira em estilo tilde: um espaço com conta SSH, terminal, página pessoal, wiki, notícias e convívio para quem quer aprender, publicar e explorar uma internet mais calma.</p> </section> <section class="faq-item" aria-labelledby="faq-2"> <h2 id="faq-2" class="faq-q">2. O sistema é só para alunos do Portal IDEA?</h2> - <p>Não. O runv.club não é exclusivo para alunos do Portal IDEA. O sistema pode ser utilizado por outros públicos, conforme a proposta, disponibilidade de acesso e regras da plataforma.</p> + <p>Não. O runv.club não é exclusivo para alunos do Portal IDEA. A comunidade é aberta a diferentes perfis, desde que a pessoa concorde com a proposta, as regras e a disponibilidade de vagas.</p> </section> <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 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> + <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, conecte-se a <span class="ssh-identity">entre@runv.club</span> e envie a sua chave <strong>pública</strong> quando o sistema pedir. A equipe revisa a fila manualmente e cria a conta Unix no servidor. Como somos uma equipe pequena, o prazo de aprovação pode chegar a <strong>21 dias corridos</strong>.</p> </section> <section class="faq-item" aria-labelledby="faq-4"> <h2 id="faq-4" class="faq-q">4. Já tenho conta. Como entro no servidor?</h2> - <p>O acesso à pubnix é por <strong>SSH com chave</strong> (não há login por senha SSH). No seu computador, use algo como <code>ssh -i ~/.ssh/sua_chave_runv <em>seuusuario</em>@runv.club</code> (ajuste o utilizador e o caminho da chave). O site estático (páginas no navegador) é só informação; a shell é no SSH.</p> + <p>O acesso à pubnix é por <strong>SSH com chave</strong> e não por senha SSH. No seu computador, use algo como <code>ssh -i ~/.ssh/sua_chave_runv <em>seuusuario</em>@runv.club</code>, ajustando o usuário e o caminho da chave. O site em navegador é a parte pública; a shell fica no SSH.</p> </section> <section class="faq-item" aria-labelledby="faq-5"> <h2 id="faq-5" class="faq-q">5. Perdi a chave SSH ou não consigo entrar. E agora?</h2> - <p>Não existe “esqueci a senha” no SSH. Guarde a chave privada em local seguro. Se a perdeu ou precisa substituir a chave pública no servidor, escreva para <a href="mailto:admin@runv.club">admin@runv.club</a> explicando o caso.</p> + <p>Não existe “esqueci a senha” no SSH. Guarde sua chave privada com cuidado. Se você a perdeu ou precisa trocar a chave pública cadastrada no servidor, escreva para <a href="mailto:admin@runv.club">admin@runv.club</a> explicando o caso.</p> </section> <section class="faq-item" aria-labelledby="faq-6"> @@ -64,47 +77,47 @@ <section class="faq-item" aria-labelledby="faq-7"> <h2 id="faq-7" class="faq-q">7. Não recebi resposta por e-mail. E agora?</h2> - <p>Confira spam, promoções e lixo eletrónico. Confirme se o endereço que deixou no pedido está correto. Se continuar sem resposta ou se o assunto for urgente (acesso, chave), contacte <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + <p>Confira spam, promoções e lixo eletrônico. Confirme também se o endereço informado no pedido estava correto. Se continuar sem resposta ou se o assunto for urgente, escreva para <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> <section class="faq-item" aria-labelledby="faq-8"> <h2 id="faq-8" class="faq-q">8. O site e o servidor funcionam no celular?</h2> - <p>As páginas do runv.club abrem em navegador atualizado no telemóvel ou no computador. Já a shell na pubnix usa um cliente SSH (no telemóvel existem aplicações para isso). Se uma página não carregar, atualize, teste outra rede ou outro navegador.</p> + <p>As páginas do runv.club funcionam em navegador atualizado no celular e no computador. Já a shell da pubnix precisa de um cliente SSH. Se uma página não carregar, atualize, teste outra rede ou outro navegador.</p> </section> <section class="faq-item" aria-labelledby="faq-9"> <h2 id="faq-9" class="faq-q">9. O que fazer se o site estiver lento, fora do ar ou se o SSH falhar?</h2> - <p>Para o <strong>site</strong>: atualize a página, limpe cache, teste outro dispositivo ou rede. Para o <strong>SSH</strong>: confirme utilizador, chave (<code>-i</code>), rede e mensagem de erro completa. Se o problema persistir, envie detalhes (horário, comando, output) para <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + <p>Para o <strong>site</strong>, atualize a página, limpe cache e teste outra rede ou dispositivo. Para o <strong>SSH</strong>, confira o usuário, a chave usada com <code>-i</code>, a rede e a mensagem de erro completa. Se o problema persistir, envie detalhes para <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> <section class="faq-item" aria-labelledby="faq-10"> <h2 id="faq-10" class="faq-q">10. Como pedir suporte de forma eficiente?</h2> - <p>Envie mensagem objetiva com o seu nome de utilizador Unix no runv.club, e-mail de contacto, descrição do problema, horário aproximado e, se for SSH, o texto de erro completo (ou captura). Quanto mais concreto, mais rápido de analisar.</p> + <p>Envie uma mensagem objetiva com o seu usuário Unix no runv.club, email de contato, descrição do problema, horário aproximado e, se for SSH, o texto de erro completo ou uma captura. Quanto mais concreto o relato, mais fácil fica ajudar.</p> </section> <section class="faq-item" aria-labelledby="faq-11"> <h2 id="faq-11" class="faq-q">11. Meus dados ficam protegidos?</h2> - <p>A operação visa segurança e controlo de acesso. A sua parte importa: proteja a <strong>chave privada</strong> SSH, não a partilhe, não a envie por chat, e evite redes ou dispositivos em que não confie para acesso sensível.</p> + <p>A operação busca segurança e controle de acesso, mas a sua parte também importa: proteja a <strong>chave privada</strong> SSH, não a compartilhe, não a envie por chat e evite redes ou dispositivos em que você não confie para acesso sensível.</p> </section> <section class="faq-item" aria-labelledby="faq-12"> <h2 id="faq-12" class="faq-q">12. Posso usar mais de um dispositivo?</h2> - <p>Em geral sim: pode usar o mesmo par de chaves em vários equipamentos ou chaves diferentes se a política e a administração o permitirem — o importante é manter as regras da comunidade e não expor a chave privada.</p> + <p>Em geral, sim. Você pode usar o mesmo par de chaves em mais de um equipamento ou chaves diferentes, se a política e a administração permitirem. O importante é não expor a chave privada e manter a conta sob seu controle.</p> </section> <section class="faq-item" aria-labelledby="faq-13"> <h2 id="faq-13" class="faq-q">13. Onde acompanho avisos, mudanças ou instabilidades?</h2> - <p>Consulte <a href="/news/">Notícias</a>, a <a href="/wiki/">wiki</a> e o site. Em dúvida, <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + <p>Consulte <a href="/news/">Notícias</a>, a <a href="/wiki/">wiki</a> e o próprio site. Se ainda ficar em dúvida, escreva para <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> <section class="faq-item" aria-labelledby="faq-14"> <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> + <p>O canal oficial 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> + <p>No canal <strong>#runv</strong>, em <strong>irc.tilde.chat</strong>, porta <strong>6697</strong>, com <strong>TLS/SSL</strong>. É um bom lugar para tirar dúvidas rápidas, conversar e acompanhar a comunidade. Para assuntos sensíveis da conta ou dados pessoais, prefira <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> </section> </main> diff --git a/site/public/index.html b/site/public/index.html @@ -4,7 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>runv.club — pubnix brasileira, Unix/Linux e página pessoal</title> - <meta name="description" content="Comunidade brasileira em servidor compartilhado Unix/Linux: conta SSH, página pessoal, terminal e convívio — hospedagem leve, cultura hacker, Portal IDEA. Junte-se."> + <meta name="description" content="Comunidade brasileira em servidor compartilhado Unix/Linux: conta SSH, página pessoal, terminal, IRC e convivência calma para aprender, publicar e explorar."> <link rel="canonical" href="https://runv.club/"> <meta name="robots" content="index, follow"> <meta name="theme-color" content="#0c0b0f"> @@ -13,10 +13,10 @@ <meta property="og:locale" content="pt_BR"> <meta property="og:site_name" content="runv.club"> <meta property="og:title" content="runv.club — pubnix brasileira, Unix/Linux e página pessoal"> - <meta property="og:description" content="Comunidade brasileira em servidor compartilhado Unix/Linux: conta SSH, página pessoal, terminal e convívio — hospedagem leve e cultura hacker."> + <meta property="og:description" content="Comunidade brasileira em servidor compartilhado Unix/Linux: conta SSH, página pessoal, terminal, IRC e convivência para aprender, publicar e explorar."> <meta name="twitter:card" content="summary"> <meta name="twitter:title" content="runv.club — pubnix brasileira, Unix/Linux e página pessoal"> - <meta name="twitter:description" content="Comunidade brasileira em servidor compartilhado Unix/Linux: conta SSH, página pessoal, terminal e convívio — hospedagem leve e cultura hacker."> + <meta name="twitter:description" content="Comunidade brasileira em servidor compartilhado Unix/Linux: conta SSH, página pessoal, terminal, IRC e convivência para aprender, publicar e explorar."> <script type="application/ld+json"> {"@context":"https://schema.org","@type":"WebSite","name":"runv.club","url":"https://runv.club/","description":"Comunidade brasileira em pubnix Unix/Linux: conta SSH, página pessoal, terminal, estudo e convívio. Projeto do Portal IDEA.","inLanguage":"pt-BR","publisher":{"@type":"Organization","name":"Portal IDEA"}} </script> @@ -53,35 +53,58 @@ </nav> <h1 class="hero-title">runv.club</h1> <p class="hero-subtitle"> - Uma comunidade brasileira para aprender Unix/Linux, publicar sua página pessoal, estudar, conversar e redescobrir a internet como espaço de criação. + Uma comunidade brasileira para aprender Unix/Linux, criar sua página pessoal, conversar com calma e redescobrir a internet como espaço de criação. </p> <p class="hero-lead"> - runv.club é <strong>uma comunidade brasileira em estilo tilde</strong>: um espaço calmo, humano e acessível para aprender Unix e Linux, criar páginas pessoais, estudar, trocar ideias, fazer amizades e explorar a internet de um jeito mais simples, direto e vivo. + runv.club é <strong>uma comunidade brasileira em estilo tilde</strong>: um espaço calmo, humano e acessível para aprender Unix e Linux, criar páginas pessoais, experimentar ferramentas clássicas, trocar ideias e construir presença online sem a pressa da web moderna. </p> <p class="hero-lead"> - Inspirada no espírito dos antigos pubnixes e dos sistemas Unix públicos de comunidade, a runv resgata uma forma mais pessoal de estar online: menos algoritmo, menos ruído, mais curiosidade, autonomia, texto, terminal, cultura hacker e convivência. + Inspirada no espírito dos antigos pubnixes e dos sistemas Unix públicos de comunidade, a runv valoriza curiosidade, autonomia, texto, terminal, IRC, cultura hacker e convivência respeitosa. </p> <p class="hero-lead"> - Aqui, cada pessoa pode ter seu próprio espaço, aprender no seu ritmo, experimentar ferramentas clássicas, construir presença na web e participar de uma comunidade feita especialmente para brasileiros. + Aqui, você pode chegar sem saber tudo, aprender no seu ritmo, pedir ajuda, publicar o que está fazendo e encontrar gente interessada em tecnologia, internet, estudo e criação. </p> <ul class="chips" aria-label="Em uma frase"> <li>Uma comunidade brasileira para aprender, criar e conviver em Unix/Linux.</li> - <li>Seu espaço pessoal na web, com terminal, estudo e comunidade.</li> - <li>Menos algoritmo. Mais aprendizado, autonomia e presença.</li> + <li>Seu espaço pessoal na web, com terminal, IRC, estudo e comunidade.</li> + <li>Menos algoritmo. Mais aprendizado, autonomia, texto e presença.</li> <li>Uma internet mais calma, mais humana e mais sua.</li> <li>Publique sua página. Aprenda no seu ritmo. Conheça gente interessante.</li> </ul> + + <div class="hero-actions" role="group" aria-label="Próximos passos"> + <a class="button-primary" href="/junte-se/">Pedir entrada</a> + <a class="button-secondary" href="/wiki/">Ler a wiki</a> + </div> </header> + <section class="section feature-grid" aria-label="O que você encontra aqui"> + <article class="feature-card"> + <p class="feature-kicker">Aprenda no terminal</p> + <h2>Uma conta shell para explorar com calma</h2> + <p>Use comandos clássicos, organize arquivos, teste ferramentas, aprenda SSH e descubra o ambiente Unix/Linux de forma prática.</p> + </article> + <article class="feature-card"> + <p class="feature-kicker">Publique sua página</p> + <h2>Seu espaço pessoal na web</h2> + <p>Monte uma home simples, publique textos, links, projetos e deixe sua presença online com a sua cara, sem depender de plataformas fechadas.</p> + </article> + <article class="feature-card"> + <p class="feature-kicker">Converse no IRC</p> + <h2>Comunidade viva no <span class="inline-accent">#runv</span></h2> + <p>Entre no canal da casa, tire dúvidas rápidas, conheça outras pessoas e acompanhe o cotidiano da comunidade em tempo real.</p> + </article> + </section> + <section class="section prose-block" aria-labelledby="what-h"> <h2 id="what-h">O que é a runv?</h2> <p> - A runv.club é uma comunidade online em estilo tilde voltada ao público brasileiro. Em vez de depender só de redes sociais, feeds infinitos e plataformas fechadas, a proposta é voltar ao essencial: contas em um servidor compartilhado, páginas pessoais, terminal, aprendizado prático e convivência entre pessoas que gostam de tecnologia, cultura digital e conhecimento livre. + A runv.club é uma comunidade online em estilo tilde voltada ao público brasileiro. Em vez de depender só de redes sociais, feeds infinitos e plataformas fechadas, a proposta é voltar ao essencial: contas em um servidor compartilhado, páginas pessoais, terminal, IRC, aprendizado prático e convivência entre pessoas que gostam de tecnologia, cultura digital e conhecimento livre. </p> <p> - A runv nasce para ser um lugar acolhedor para iniciantes, curiosos, estudantes, profissionais e entusiastas que queiram conhecer melhor o universo Unix/Linux sem pressão, sem elitismo e sem a pressa da web moderna. + A runv nasce para ser um lugar acolhedor para iniciantes, curiosos, estudantes, profissionais e entusiastas que queiram conhecer melhor o universo Unix/Linux sem pressão, sem elitismo e sem a sensação de que é preciso chegar pronto. </p> </section> @@ -126,6 +149,7 @@ <li>acessar uma conta em ambiente Unix/Linux;</li> <li>publicar sua página pessoal;</li> <li>aprender comandos, organização de arquivos e ferramentas clássicas;</li> + <li>conversar no IRC da comunidade e pedir ajuda em tempo real;</li> <li>estudar programação e automação;</li> <li>trocar ideias com outras pessoas;</li> <li>acompanhar projetos e experiências da comunidade;</li> @@ -133,6 +157,27 @@ </ul> </section> + <section class="section prose-block" aria-labelledby="first-steps-h"> + <h2 id="first-steps-h">Como costuma ser o começo</h2> + <p> + O primeiro passo costuma ser simples: você gera sua chave SSH, faz o pedido pela página <a href="/junte-se/">Junte-se</a>, espera a revisão da equipe e, quando a conta for aprovada, entra no servidor para explorar com calma. + </p> + <p> + Depois disso, muita gente começa editando a própria página, testando comandos, lendo a wiki, entrando no <strong>#runv</strong> no IRC e conhecendo aos poucos as ferramentas e as pessoas da comunidade. + </p> + </section> + + <section class="section quick-paths" aria-label="Comece por aqui"> + <article class="quick-path-card"> + <h2>Se você está chegando agora</h2> + <p>Comece pela página <a href="/junte-se/">Junte-se</a>, depois leia a <a href="/wiki/">wiki</a> e guarde o endereço <span class="ssh-identity">entre@runv.club</span>.</p> + </article> + <article class="quick-path-card"> + <h2>Se você quer entender a proposta</h2> + <p>Leia a <a href="/wiki/visao-geral.html">visão geral</a>, passe pela <a href="/faq/">FAQ</a> e veja as <a href="/news/">notícias</a> para acompanhar o projeto.</p> + </article> + </section> + <section class="section prose-block" aria-labelledby="idea-h"> <h2 id="idea-h">Mantida pelo Portal IDEA</h2> <p> @@ -159,15 +204,16 @@ Se você sente falta de uma internet mais próxima, mais criativa e menos descartável, a runv.club é para você. </p> <p> - Se você quer aprender, explorar, construir, conversar e fazer parte de algo com mais identidade e mais calma, seja bem-vindo. + Se você quer aprender, explorar, construir, conversar e fazer parte de algo com mais identidade e mais calma, seja muito bem-vindo. </p> <p class="invite-line">Entre. Experimente. Aprenda. Publique. Converse. Fique à vontade.</p> </section> <div class="cta-panel" role="group" aria-labelledby="cta-title"> <p class="cta-brand" id="cta-title">runv.club</p> - <p class="cta-tagline">Aprenda. Publique. Explore. Compartilhe.</p> + <p class="cta-tagline">Aprenda. Publique. Explore. Converse. Compartilhe.</p> <p class="cta-join"><a href="/junte-se/">Como pedir entrada — chave SSH e <span class="ssh-identity">entre@runv.club</span></a></p> + <p class="cta-subline">Wiki, notícias e IRC para seguir aprendendo depois do primeiro login.</p> </div> <footer class="site-footer"> diff --git a/site/public/junte-se/index.html b/site/public/junte-se/index.html @@ -4,7 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Junte-se — runv.club</title> - <meta name="description" content="Como gerar chave SSH no Linux, macOS ou Windows e pedir entrada na runv.club via ssh entre@runv.club."> + <meta name="description" content="Como gerar uma chave SSH no Linux, macOS ou Windows e pedir entrada na runv.club via ssh entre@runv.club."> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> @@ -25,7 +25,7 @@ <span class="hero-nav-current" aria-current="page">Junte-se</span> </nav> <h1 class="hero-title subpage-title">Junte-se</h1> - <p class="subpage-intro">Uma chave SSH no seu computador e um primeiro contacto com <span class="ssh-identity">entre@runv.club</span> — em qualquer sistema.</p> + <p class="subpage-intro">Uma chave SSH no seu computador e um primeiro contato com <span class="ssh-identity">entre@runv.club</span> — de um jeito simples, em qualquer sistema.</p> </header> <div class="entre-callout" role="status"> @@ -33,37 +33,59 @@ <span class="ssh-identity ssh-identity-lg">entre@runv.club</span> </div> + <section class="mini-cards" aria-label="Antes de começar"> + <article class="mini-card"> + <p class="mini-card-kicker">O que você precisa</p> + <h2>Um computador e alguns minutos</h2> + <p>Você só precisa gerar uma chave SSH, copiar a parte pública e seguir o fluxo guiado do <span class="ssh-identity">entre@runv.club</span>.</p> + </article> + <article class="mini-card"> + <p class="mini-card-kicker">Sem pressa</p> + <h2>Não precisa saber tudo agora</h2> + <p>Se esta for sua primeira vez com SSH, tudo bem. A ideia aqui é justamente tornar esse começo mais simples e amigável.</p> + </article> + </section> + <main class="section prose-block subpage-main join-main"> <h2>Em resumo</h2> <ol class="checklist join-summary"> - <li><strong>Gere</strong> um par de chaves SSH no seu PC (fica só a <em>pública</em> para colar no pedido).</li> - <li><strong>Ligue</strong> com <code>ssh</code> a <span class="ssh-identity">entre@runv.club</span> e siga o que aparecer no ecrã.</li> - <li><strong>Aguarde</strong> — a equipa revê a fila; não é cadastro instantâneo.</li> + <li><strong>Gere</strong> um par de chaves SSH no seu computador. Você vai usar só a <em>chave pública</em> no pedido.</li> + <li><strong>Conecte-se</strong> com <code>ssh</code> a <span class="ssh-identity">entre@runv.club</span> e siga o que aparecer na tela.</li> + <li><strong>Aguarde</strong> a revisão. O processo é manual e não há cadastro instantâneo.</li> </ol> + <p> + Se esta é a sua primeira vez usando SSH, tudo bem: esta página foi pensada justamente para ajudar você a dar esse primeiro passo com calma. + </p> + <h2>Linux</h2> - <p>Terminal: gere Ed25519 e copie a linha da chave <strong>pública</strong> (<code>.pub</code>).</p> + <p>No terminal, gere uma chave Ed25519 e copie a linha da <strong>chave pública</strong> (<code>.pub</code>).</p> <pre class="code-block" tabindex="0"><code>ssh-keygen -t ed25519 -C "seu-email-ou-pc" -f ~/.ssh/id_ed25519_runv cat ~/.ssh/id_ed25519_runv.pub</code></pre> <h2>macOS</h2> - <p><strong>Terminal</strong> (Spotlight: “Terminal”) — os mesmos comandos que em Linux. OpenSSH já vem no sistema; opcional: <code>ssh-add ~/.ssh/id_ed25519_runv</code>.</p> + <p>No <strong>Terminal</strong> (Spotlight: “Terminal”), use os mesmos comandos do Linux. O OpenSSH já vem no sistema; se quiser, pode adicionar a chave com <code>ssh-add ~/.ssh/id_ed25519_runv</code>.</p> <pre class="code-block" tabindex="0"><code>ssh-keygen -t ed25519 -C "seu-email-ou-pc" -f ~/.ssh/id_ed25519_runv cat ~/.ssh/id_ed25519_runv.pub</code></pre> <h2>Windows</h2> - <p><strong>PowerShell</strong> ou Terminal do Windows. Confirme o cliente: <code>ssh -V</code>. Se faltar, instale <em>Cliente OpenSSH</em> em Configurações → Aplicativos → Recursos opcionais.</p> + <p>No <strong>PowerShell</strong> ou Terminal do Windows, confirme primeiro se o cliente existe com <code>ssh -V</code>. Se faltar, instale o <em>Cliente OpenSSH</em> em Configurações → Aplicativos → Recursos opcionais.</p> <pre class="code-block" tabindex="0"><code>ssh-keygen -t ed25519 -C "seu-email-ou-pc" -f $env:USERPROFILE\.ssh\id_ed25519_runv Get-Content $env:USERPROFILE\.ssh\id_ed25519_runv.pub</code></pre> - <p class="join-note">Nunca partilhe o ficheiro <strong>sem</strong> <code>.pub</code> — esse é o privado.</p> + <p class="join-note">Nunca compartilhe o arquivo <strong>sem</strong> <code>.pub</code>. Esse é o arquivo privado e deve ficar só com você.</p> <h2>Ligar ao <span class="ssh-identity">entre@runv.club</span></h2> <p>Linux / macOS:</p> <pre class="code-block" tabindex="0"><code>ssh -i ~/.ssh/id_ed25519_runv entre@runv.club</code></pre> <p>Windows (PowerShell):</p> <pre class="code-block" tabindex="0"><code>ssh -i $env:USERPROFILE\.ssh\id_ed25519_runv entre@runv.club</code></pre> - <p>Na primeira vez, aceite a fingerprint se confiar no servidor. Se a sua chave tiver outro nome ou estiver no caminho por defeito, pode omitir <code>-i</code>.</p> - <p>Dúvidas ou bloqueios: <a href="mailto:admin@runv.club">admin@runv.club</a>. Tipos de chave: preferência por <strong>Ed25519</strong>; também ECDSA ou RSA conforme política do servidor.</p> + <p>Na primeira vez, aceite a fingerprint se estiver conectando ao servidor certo. Se a sua chave tiver outro nome ou estiver no caminho padrão, você pode omitir <code>-i</code>.</p> + <p> + Dúvidas ou bloqueios: <a href="mailto:admin@runv.club">admin@runv.club</a>. A preferência é por chaves <strong>Ed25519</strong>; ECDSA e RSA também podem ser aceitas conforme a política do servidor. + </p> + <p> + Depois que a conta for aprovada, você poderá entrar na pubnix, criar sua página pessoal, explorar o ambiente e conversar com a comunidade no IRC. + </p> </main> <footer class="site-footer"> diff --git a/site/public/wiki/contas-e-acesso.html b/site/public/wiki/contas-e-acesso.html @@ -87,17 +87,16 @@ admin@runv.club</p> - reincidência em infrações;<br> - risco à integridade do sistema.</p> -<h2>ERRO COMUM DO USUÁRIO</h2> +<h2>LEMBRETE IMPORTANTE</h2> -<p>Muita gente trata a própria conta como se fosse descartável. Isso é estupidez operacional.<br> -Se a conta concentra histórico, permissões e suporte, então ela precisa ser tratada como ativo. Quem ignora isso aumenta o próprio risco.</p> +<p>A sua conta concentra acesso, histórico e suporte. Vale a pena tratá-la com cuidado: guardar a chave privada em segurança, manter contato atualizado quando necessário e avisar rápido se algo parecer errado.</p> <h2>SUPORTE DE ACESSO</h2> <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> +<p>Comunidade e ajuda em tempo real: canal #runv em irc.tilde.chat, porta 6697, com TLS/SSL. Use o IRC para dúvidas rápidas, conversa e orientação inicial; 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 @@ -42,10 +42,10 @@ <h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - FAQ</h1> <p>1. O que é o runv.club?<br> -O runv.club é um sistema de acesso, organização, suporte e uso em ambiente digital. Ele centraliza informações, regras, canais de contato e recursos operacionais.</p> +O runv.club é uma comunidade brasileira em estilo tilde, com conta SSH, terminal, página pessoal, wiki, notícias e espaço para aprender, publicar e conviver.</p> <p>2. O sistema é só para alunos do Portal IDEA?<br> -Não.</p> +Não. O runv.club pode receber pessoas de perfis diferentes, desde que haja disponibilidade e que as regras da comunidade sejam respeitadas.</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 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> @@ -87,7 +87,7 @@ Pelo e-mail oficial: admin@runv.club</p> 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> +No canal #runv, em irc.tilde.chat na porta 6697 com TLS/SSL. É um bom lugar para conversa e dúvidas rápidas; para questões de conta ou dados sensíveis, use admin@runv.club.</p> </main> <footer class="site-footer"> diff --git a/site/public/wiki/index.html b/site/public/wiki/index.html @@ -39,30 +39,29 @@ </header> <main class="section prose-block subpage-main wiki-main"> -<h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - WIKI BASE (TXT)</h1> +<h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - WIKI</h1> -<p>Esta wiki reúne, de forma simples, as informações principais de operação, acesso, regras de convivência, moderação, privacidade e FAQ do runv.club. Use os links no topo para ir a cada secção.</p> +<p>Bem-vindo à wiki do runv.club.</p> + +<p>Aqui você encontra, de forma simples e direta, as informações principais sobre acesso, contas, regras da comunidade, moderação, privacidade, segurança e perguntas frequentes.</p> + +<p>Se você acabou de chegar, o melhor caminho costuma ser:</p> + +<ul><li>ler a visão geral;</li><li>ver a página de contas e acesso;</li><li>conferir a FAQ;</li><li>guardar o contato oficial.</li></ul> <h2>CONTATO OFICIAL</h2> -<p>E-mail de contato: admin@runv.club</p> +<p>E-mail: admin@runv.club</p> -<h2>NOTA IMPORTANTE</h2> +<h2>AJUDA RÁPIDA</h2> -<p>Esta base foi escrita como um pacote inicial de wiki para o runv.club.<br> -Ela pode ser usada como documentação pública, central de ajuda, base interna de suporte ou material para página de regras.</p> +<p>Se você precisa de uma resposta curta:</p> -<h2>RECOMENDAÇÃO PRÁTICA</h2> +<ul><li>para entrar, veja a página “Junte-se” no site;</li><li>para suporte ou questões sensíveis, use admin@runv.club;</li><li>para conversa e dúvidas rápidas, entre no canal #runv em irc.tilde.chat, porta 6697, com TLS/SSL.</li></ul> -<p>Se você publicar esta wiki, mantenha a mesma lógica:<br> -- página inicial curta;<br> -- regras objetivas;<br> -- punições previsíveis;<br> -- FAQ sem enrolação;<br> -- contato visível;<br> -- política de segurança clara.</p> +<h2>OBJETIVO DESTA WIKI</h2> -<p>O erro comum de sistemas pequenos é ser vago nas regras e arbitrário na punição. Isso destrói confiança. Esta wiki foi montada para evitar esse problema.</p> +<p>Esta wiki existe para reduzir confusão, deixar expectativas claras e ajudar cada pessoa a usar o sistema com mais tranquilidade.</p> </main> <footer class="site-footer"> diff --git a/site/public/wiki/privacidade-e-seguranca.html b/site/public/wiki/privacidade-e-seguranca.html @@ -43,7 +43,7 @@ <h2>PRINCÍPIO GERAL</h2> -<p>Privacidade e segurança não são perfumaria jurídica. São parte da operação básica de qualquer sistema minimamente sério.</p> +<p>Privacidade e segurança fazem parte da operação básica de qualquer sistema minimamente sério.</p> <h2>DADOS E RESPONSABILIDADE</h2> @@ -53,7 +53,7 @@ <h2>O USUÁRIO TAMBÉM TEM RESPONSABILIDADE</h2> -<p>Não adianta exigir segurança da plataforma e depois tratar a chave privada com descuido, compartilhar conta, clicar em golpe e ignorar alertas.<br> +<p>A plataforma tem deveres de proteção, mas o usuário também precisa cuidar da própria conta, da chave privada, do dispositivo usado para acesso e dos sinais de comprometimento.<br> Segurança é responsabilidade dividida.</p> <h2>O QUE O USUÁRIO DEVE EVITAR</h2> @@ -79,9 +79,8 @@ admin@runv.club</p> <h2>LIMITAÇÃO IMPORTANTE</h2> -<p>Nenhum sistema online é magicamente invulnerável.<br> -Prometer risco zero é propaganda, não verdade.<br> -O que importa é ter prevenção, resposta rápida, registro e correção.</p> +<p>Nenhum sistema online é invulnerável.<br> +O que importa é reduzir risco, responder rápido, registrar incidentes relevantes e corrigir o que for confirmado.</p> <h2>BOAS PRÁTICAS OPERACIONAIS</h2> diff --git a/site/public/wiki/punicoes-e-moderacao.html b/site/public/wiki/punicoes-e-moderacao.html @@ -45,6 +45,8 @@ <p>A moderação existe para proteger usuários, preservar a operação do sistema e impedir que uma minoria destrua a utilidade do ambiente para todos os outros.</p> +<p>O objetivo da moderação não é punir por esporte. É manter o espaço seguro, previsível e utilizável para a comunidade.</p> + <h2>TIPOS DE MEDIDA</h2> <p>1. Orientação informal<br> @@ -87,8 +89,7 @@ <h2>NEM TUDO PRECISA SEGUIR ESCADA LINEAR</h2> <p>Nem toda infração começa em aviso.<br> -Essa fantasia de que todo caso precisa passar por aviso, depois suspensão, depois banimento, independentemente da gravidade, é ingênua.<br> -Fraude séria, ameaça real, golpe ou ataque técnico podem justificar banimento direto.</p> +Casos leves podem ser resolvidos com orientação. Casos graves, como fraude séria, ameaça real, golpe ou ataque técnico, podem justificar suspensão imediata ou banimento direto.</p> <h2>RECURSO</h2> @@ -109,10 +110,9 @@ admin@runv.club</p> <p>A administração pode revisar, manter, reduzir ou ampliar a medida aplicada com base nos elementos disponíveis.</p> -<h2>POLÍTICA CONTRA IMPUNIDADE</h2> +<h2>CONSISTÊNCIA</h2> -<p>Sistema sem execução consistente de regras vira piada.<br> -Se a regra existe, ela precisa produzir consequência real.</p> +<p>Regra sem aplicação consistente vira só texto. Por isso, a administração pode agir quando houver necessidade real de proteger usuários, equipe e operação.</p> </main> <footer class="site-footer"> diff --git a/site/public/wiki/regras-da-comunidade.html b/site/public/wiki/regras-da-comunidade.html @@ -41,6 +41,8 @@ <main class="section prose-block subpage-main wiki-main"> <h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - REGRAS DA COMUNIDADE</h1> +<p>Estas regras existem para manter a comunidade acolhedora, utilizável e segura. O objetivo não é controlar conversa à toa, e sim proteger o espaço para que as pessoas possam aprender, publicar e conviver bem.</p> + <h2>REGRA 1 - RESPEITO BÁSICO</h2> <p>É proibido atacar, humilhar, ameaçar, perseguir ou intimidar outros usuários.<br> @@ -88,7 +90,7 @@ Tumultuar, ameaçar staff, pressionar outros usuários ou espalhar desinformaç <h2>CONDUTA ESPERADA</h2> -<ul><li>comunicar problemas com clareza;</li><li>respeitar usuários e equipe;</li><li>reportar abuso sem espetáculo;</li><li>manter o foco no uso legítimo do sistema;</li><li>agir como adulto funcional.</li></ul> +<ul><li>comunicar problemas com clareza;</li><li>respeitar usuários e equipe;</li><li>reportar abuso sem espetáculo;</li><li>manter o foco no uso legítimo do sistema;</li><li>agir com maturidade e boa-fé.</li></ul> <h2>CANAL DE CONTATO</h2> diff --git a/site/public/wiki/visao-geral.html b/site/public/wiki/visao-geral.html @@ -3,8 +3,8 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>RUNV.CLUB - VISÃO GERAL DO SISTEMA — Wiki runv.club</title> - <meta name="description" content="RUNV.CLUB - VISÃO GERAL DO SISTEMA — wiki runv.club."> + <title>RUNV.CLUB - VISÃO GERAL — Wiki runv.club</title> + <meta name="description" content="RUNV.CLUB - VISÃO GERAL — wiki runv.club."> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> @@ -39,54 +39,55 @@ </header> <main class="section prose-block subpage-main wiki-main"> -<h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - VISÃO GERAL DO SISTEMA</h1> +<h1 class="hero-title subpage-title wiki-page-title">RUNV.CLUB - VISÃO GERAL</h1> <h2>O QUE É O RUNV.CLUB</h2> -<p>O runv.club é um sistema voltado para acesso, organização, suporte e interação em ambiente digital. Ele pode ser usado como portal de entrada, base de informações, área de usuário, espaço de comunidade e central de apoio operacional.</p> +<p>O runv.club é uma comunidade brasileira em estilo tilde: um espaço online com conta shell Unix/Linux, página pessoal, terminal, wiki, notícias e convivência em torno de tecnologia, cultura digital e aprendizado.</p> -<h2>OBJETIVO DO SISTEMA</h2> +<h2>OBJETIVO</h2> -<p>O objetivo principal do runv.club é concentrar recursos, orientações, acesso e comunicação em um único lugar, reduzindo confusão e facilitando o uso do sistema pelos usuários.</p> +<p>O objetivo do runv.club é oferecer um lugar mais humano, mais autoral e mais tranquilo para estudar, publicar, conversar e explorar a internet com menos ruído e mais autonomia.</p> <h2>PRINCÍPIOS BÁSICOS</h2> <p>1. Clareza<br> - O usuário precisa entender onde entra, o que faz e onde resolve problemas.</p> + A pessoa precisa entender como entrar, como pedir ajuda e como usar o sistema sem adivinhação.</p> <p>2. Acesso simples<br> - Pedido de conta, autenticação por chave SSH e suporte devem ser claros e seguir os canais oficiais (sem depender de cadastro web automático nem de senha SSH).</p> + O pedido de conta, a autenticação por chave SSH e o suporte devem seguir caminhos claros e públicos.</p> <p>3. Segurança<br> - A conta é individual e deve ser protegida.</p> + Cada conta é individual e precisa ser protegida com responsabilidade.</p> <p>4. Respeito à comunidade<br> - Nenhum sistema sobrevive quando tolera abuso, fraude ou perseguição.</p> + O espaço precisa continuar acolhedor, utilizável e seguro para todos.</p> -<p>5. Responsabilidade<br> - Toda ação dentro do sistema pode ser analisada para fins de segurança, suporte e moderação.</p> +<p>5. Boa-fé<br> + O sistema foi feito para estudo, convivência, criação e troca, não para fraude, abuso ou sabotagem.</p> -<h2>PARA QUEM O SISTEMA SERVE</h2> +<h2>PARA QUEM É</h2> -<p>O runv.club não deve ser tratado como um ambiente fechado para um único perfil, salvo se a administração definir isso formalmente. Na ausência dessa restrição explícita, o sistema pode atender usuários com diferentes perfis, desde que cumpram as regras.</p> +<p>O runv.club é para iniciantes, curiosos, estudantes, profissionais e entusiastas que queiram aprender mais sobre Unix/Linux, publicar uma página pessoal, usar ferramentas clássicas e participar de uma comunidade brasileira de tecnologia.</p> -<h2>O QUE O USUÁRIO ENCONTRA NO SISTEMA</h2> +<p>Você não precisa chegar sabendo tudo.</p> -<ul><li>acesso shell e espaço de utilizador na pubnix (tipicamente por SSH com chave);</li><li>informações operacionais;</li><li>regras de uso;</li><li>canais de suporte;</li><li>respostas para dúvidas frequentes;</li><li>políticas de moderação e segurança.</li></ul> +<h2>O QUE VOCÊ ENCONTRA AQUI</h2> + +<ul><li>acesso shell em ambiente Unix/Linux;</li><li>espaço para página pessoal;</li><li>wiki pública e páginas de ajuda;</li><li>notícias e informações operacionais;</li><li>canal de IRC da comunidade;</li><li>regras, suporte e orientação.</li></ul> <h2>O QUE O SISTEMA NÃO É</h2> -<p>O runv.club não é terra sem lei.<br> -Também não é um espaço para spam, fraude, assédio, manipulação de usuários, invasão de conta, publicação de conteúdo ilegal ou sabotagem da operação.</p> +<p>O runv.club não é um espaço sem regras e não foi feito para spam, fraude, assédio, invasão, exploração maliciosa, coleta abusiva de dados ou uso que prejudique outras pessoas e a operação da plataforma.</p> <h2>SUPORTE</h2> -<p>Em caso de dúvida, problema técnico, contestação de punição ou necessidade de orientação, o contato oficial é:<br> +<p>Se surgir dúvida, problema técnico, questão de acesso ou necessidade de orientação, o contato oficial é:<br> admin@runv.club</p> <h2>DIRETRIZ FINAL</h2> -<p>Se uma funcionalidade não estiver explicada, isso não significa que qualquer comportamento esteja liberado. O uso do sistema sempre depende de boa-fé, respeito às regras publicadas e preservação da segurança da plataforma.</p> +<p>Se algo não estiver detalhado, isso não significa que vale tudo. O uso do sistema depende de boa-fé, respeito às regras publicadas e preservação da segurança da plataforma e da comunidade.</p> </main> <footer class="site-footer"> diff --git a/site/wiki/01_index.txt b/site/wiki/01_index.txt @@ -1,24 +1,28 @@ -RUNV.CLUB - WIKI BASE (TXT) +RUNV.CLUB - WIKI -Esta wiki reúne, de forma simples, as informações principais de operação, acesso, regras de convivência, moderação, privacidade e FAQ do runv.club. Use os links no topo para ir a cada secção. +Bem-vindo à wiki do runv.club. + +Aqui você encontra, de forma simples e direta, as informações principais sobre acesso, contas, regras da comunidade, moderação, privacidade, segurança e perguntas frequentes. + +Se você acabou de chegar, o melhor caminho costuma ser: + +- ler a visão geral; +- ver a página de contas e acesso; +- conferir a FAQ; +- guardar o contato oficial. CONTATO OFICIAL -E-mail de contato: admin@runv.club +E-mail: admin@runv.club -NOTA IMPORTANTE +AJUDA RÁPIDA -Esta base foi escrita como um pacote inicial de wiki para o runv.club. -Ela pode ser usada como documentação pública, central de ajuda, base interna de suporte ou material para página de regras. +Se você precisa de uma resposta curta: -RECOMENDAÇÃO PRÁTICA +- para entrar, veja a página “Junte-se” no site; +- para suporte ou questões sensíveis, use admin@runv.club; +- para conversa e dúvidas rápidas, entre no canal #runv em irc.tilde.chat, porta 6697, com TLS/SSL. -Se você publicar esta wiki, mantenha a mesma lógica: -- página inicial curta; -- regras objetivas; -- punições previsíveis; -- FAQ sem enrolação; -- contato visível; -- política de segurança clara. +OBJETIVO DESTA WIKI -O erro comum de sistemas pequenos é ser vago nas regras e arbitrário na punição. Isso destrói confiança. Esta wiki foi montada para evitar esse problema. +Esta wiki existe para reduzir confusão, deixar expectativas claras e ajudar cada pessoa a usar o sistema com mais tranquilidade. diff --git a/site/wiki/02_visao-geral.txt b/site/wiki/02_visao-geral.txt @@ -1,53 +1,54 @@ -RUNV.CLUB - VISÃO GERAL DO SISTEMA +RUNV.CLUB - VISÃO GERAL O QUE É O RUNV.CLUB -O runv.club é um sistema voltado para acesso, organização, suporte e interação em ambiente digital. Ele pode ser usado como portal de entrada, base de informações, área de usuário, espaço de comunidade e central de apoio operacional. +O runv.club é uma comunidade brasileira em estilo tilde: um espaço online com conta shell Unix/Linux, página pessoal, terminal, wiki, notícias e convivência em torno de tecnologia, cultura digital e aprendizado. -OBJETIVO DO SISTEMA +OBJETIVO -O objetivo principal do runv.club é concentrar recursos, orientações, acesso e comunicação em um único lugar, reduzindo confusão e facilitando o uso do sistema pelos usuários. +O objetivo do runv.club é oferecer um lugar mais humano, mais autoral e mais tranquilo para estudar, publicar, conversar e explorar a internet com menos ruído e mais autonomia. PRINCÍPIOS BÁSICOS 1. Clareza - O usuário precisa entender onde entra, o que faz e onde resolve problemas. + A pessoa precisa entender como entrar, como pedir ajuda e como usar o sistema sem adivinhação. 2. Acesso simples - Pedido de conta, autenticação por chave SSH e suporte devem ser claros e seguir os canais oficiais (sem depender de cadastro web automático nem de senha SSH). + O pedido de conta, a autenticação por chave SSH e o suporte devem seguir caminhos claros e públicos. 3. Segurança - A conta é individual e deve ser protegida. + Cada conta é individual e precisa ser protegida com responsabilidade. 4. Respeito à comunidade - Nenhum sistema sobrevive quando tolera abuso, fraude ou perseguição. + O espaço precisa continuar acolhedor, utilizável e seguro para todos. -5. Responsabilidade - Toda ação dentro do sistema pode ser analisada para fins de segurança, suporte e moderação. +5. Boa-fé + O sistema foi feito para estudo, convivência, criação e troca, não para fraude, abuso ou sabotagem. -PARA QUEM O SISTEMA SERVE +PARA QUEM É -O runv.club não deve ser tratado como um ambiente fechado para um único perfil, salvo se a administração definir isso formalmente. Na ausência dessa restrição explícita, o sistema pode atender usuários com diferentes perfis, desde que cumpram as regras. +O runv.club é para iniciantes, curiosos, estudantes, profissionais e entusiastas que queiram aprender mais sobre Unix/Linux, publicar uma página pessoal, usar ferramentas clássicas e participar de uma comunidade brasileira de tecnologia. -O QUE O USUÁRIO ENCONTRA NO SISTEMA +Você não precisa chegar sabendo tudo. -- acesso shell e espaço de utilizador na pubnix (tipicamente por SSH com chave); -- informações operacionais; -- regras de uso; -- canais de suporte; -- respostas para dúvidas frequentes; -- políticas de moderação e segurança. +O QUE VOCÊ ENCONTRA AQUI + +- acesso shell em ambiente Unix/Linux; +- espaço para página pessoal; +- wiki pública e páginas de ajuda; +- notícias e informações operacionais; +- canal de IRC da comunidade; +- regras, suporte e orientação. O QUE O SISTEMA NÃO É -O runv.club não é terra sem lei. -Também não é um espaço para spam, fraude, assédio, manipulação de usuários, invasão de conta, publicação de conteúdo ilegal ou sabotagem da operação. +O runv.club não é um espaço sem regras e não foi feito para spam, fraude, assédio, invasão, exploração maliciosa, coleta abusiva de dados ou uso que prejudique outras pessoas e a operação da plataforma. SUPORTE -Em caso de dúvida, problema técnico, contestação de punição ou necessidade de orientação, o contato oficial é: +Se surgir dúvida, problema técnico, questão de acesso ou necessidade de orientação, o contato oficial é: admin@runv.club DIRETRIZ FINAL -Se uma funcionalidade não estiver explicada, isso não significa que qualquer comportamento esteja liberado. O uso do sistema sempre depende de boa-fé, respeito às regras publicadas e preservação da segurança da plataforma. +Se algo não estiver detalhado, isso não significa que vale tudo. O uso do sistema depende de boa-fé, respeito às regras publicadas e preservação da segurança da plataforma e da comunidade. diff --git a/site/wiki/03_contas-e-acesso.txt b/site/wiki/03_contas-e-acesso.txt @@ -51,14 +51,13 @@ A conta poderá ser temporariamente limitada ou suspensa quando houver: - reincidência em infrações; - risco à integridade do sistema. -ERRO COMUM DO USUÁRIO +LEMBRETE IMPORTANTE -Muita gente trata a própria conta como se fosse descartável. Isso é estupidez operacional. -Se a conta concentra histórico, permissões e suporte, então ela precisa ser tratada como ativo. Quem ignora isso aumenta o próprio risco. +A sua conta concentra acesso, histórico e suporte. Vale a pena tratá-la com cuidado: guardar a chave privada em segurança, manter contato atualizado quando necessário e avisar rápido se algo parecer errado. 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. +Comunidade e ajuda em tempo real: canal #runv em irc.tilde.chat, porta 6697, com TLS/SSL. Use o IRC para dúvidas rápidas, conversa e orientação inicial; para assuntos sensíveis à conta, prefira o e-mail acima. diff --git a/site/wiki/04_regras-da-comunidade.txt b/site/wiki/04_regras-da-comunidade.txt @@ -1,5 +1,7 @@ RUNV.CLUB - REGRAS DA COMUNIDADE +Estas regras existem para manter a comunidade acolhedora, utilizável e segura. O objetivo não é controlar conversa à toa, e sim proteger o espaço para que as pessoas possam aprender, publicar e conviver bem. + REGRA 1 - RESPEITO BÁSICO É proibido atacar, humilhar, ameaçar, perseguir ou intimidar outros usuários. @@ -51,7 +53,7 @@ CONDUTA ESPERADA - respeitar usuários e equipe; - reportar abuso sem espetáculo; - manter o foco no uso legítimo do sistema; -- agir como adulto funcional. +- agir com maturidade e boa-fé. CANAL DE CONTATO diff --git a/site/wiki/05_punicoes-e-moderacao.txt b/site/wiki/05_punicoes-e-moderacao.txt @@ -4,6 +4,8 @@ OBJETIVO DA MODERAÇÃO A moderação existe para proteger usuários, preservar a operação do sistema e impedir que uma minoria destrua a utilidade do ambiente para todos os outros. +O objetivo da moderação não é punir por esporte. É manter o espaço seguro, previsível e utilizável para a comunidade. + TIPOS DE MEDIDA 1. Orientação informal @@ -61,8 +63,7 @@ A administração pode considerar: NEM TUDO PRECISA SEGUIR ESCADA LINEAR Nem toda infração começa em aviso. -Essa fantasia de que todo caso precisa passar por aviso, depois suspensão, depois banimento, independentemente da gravidade, é ingênua. -Fraude séria, ameaça real, golpe ou ataque técnico podem justificar banimento direto. +Casos leves podem ser resolvidos com orientação. Casos graves, como fraude séria, ameaça real, golpe ou ataque técnico, podem justificar suspensão imediata ou banimento direto. RECURSO @@ -86,7 +87,6 @@ DECISÃO FINAL A administração pode revisar, manter, reduzir ou ampliar a medida aplicada com base nos elementos disponíveis. -POLÍTICA CONTRA IMPUNIDADE +CONSISTÊNCIA -Sistema sem execução consistente de regras vira piada. -Se a regra existe, ela precisa produzir consequência real. +Regra sem aplicação consistente vira só texto. Por isso, a administração pode agir quando houver necessidade real de proteger usuários, equipe e operação. diff --git a/site/wiki/06_privacidade-e-seguranca.txt b/site/wiki/06_privacidade-e-seguranca.txt @@ -2,7 +2,7 @@ RUNV.CLUB - PRIVACIDADE E SEGURANÇA PRINCÍPIO GERAL -Privacidade e segurança não são perfumaria jurídica. São parte da operação básica de qualquer sistema minimamente sério. +Privacidade e segurança fazem parte da operação básica de qualquer sistema minimamente sério. DADOS E RESPONSABILIDADE @@ -12,7 +12,7 @@ A administração deve tratar os dados com cuidado, limitar acessos internos e r O USUÁRIO TAMBÉM TEM RESPONSABILIDADE -Não adianta exigir segurança da plataforma e depois tratar a chave privada com descuido, compartilhar conta, clicar em golpe e ignorar alertas. +A plataforma tem deveres de proteção, mas o usuário também precisa cuidar da própria conta, da chave privada, do dispositivo usado para acesso e dos sinais de comprometimento. Segurança é responsabilidade dividida. O QUE O USUÁRIO DEVE EVITAR @@ -42,9 +42,8 @@ Para proteger a plataforma, a administração pode analisar registros técnicos, LIMITAÇÃO IMPORTANTE -Nenhum sistema online é magicamente invulnerável. -Prometer risco zero é propaganda, não verdade. -O que importa é ter prevenção, resposta rápida, registro e correção. +Nenhum sistema online é invulnerável. +O que importa é reduzir risco, responder rápido, registrar incidentes relevantes e corrigir o que for confirmado. BOAS PRÁTICAS OPERACIONAIS diff --git a/site/wiki/07_faq.txt b/site/wiki/07_faq.txt @@ -1,10 +1,10 @@ RUNV.CLUB - FAQ 1. O que é o runv.club? -O runv.club é um sistema de acesso, organização, suporte e uso em ambiente digital. Ele centraliza informações, regras, canais de contato e recursos operacionais. +O runv.club é uma comunidade brasileira em estilo tilde, com conta SSH, terminal, página pessoal, wiki, notícias e espaço para aprender, publicar e conviver. 2. O sistema é só para alunos do Portal IDEA? -Não. +Não. O runv.club pode receber pessoas de perfis diferentes, desde que haja disponibilidade e que as regras da comunidade sejam respeitadas. 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 equipe cria a conta após revisão manual. O prazo de aprovação pode ir até 21 dias corridos (somos uma equipe pequena). @@ -46,4 +46,4 @@ Pelo e-mail oficial: admin@runv.club 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. +No canal #runv, em irc.tilde.chat na porta 6697 com TLS/SSL. É um bom lugar para conversa e dúvidas rápidas; para questões de conta ou dados sensíveis, use admin@runv.club. diff --git a/site/wiki/build_wiki.py b/site/wiki/build_wiki.py @@ -16,6 +16,12 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent SITE_DIR = SCRIPT_DIR.parent +ADMIN_DIR = SITE_DIR.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + OUT_DIR = SITE_DIR / "public" / "wiki" SITEMAP_PATH = SITE_DIR / "public" / "sitemap.xml" @@ -217,6 +223,7 @@ def patch_sitemap(wiki_urls: list[str]) -> None: def main() -> int: + ensure_admin_cli(script_name=Path(__file__).name) txt_files = sorted(SCRIPT_DIR.glob(TXT_GLOB)) if not txt_files: eprint("Nenhum ficheiro", TXT_GLOB, "em", SCRIPT_DIR) diff --git a/terminal/close_entre.py b/terminal/close_entre.py @@ -8,8 +8,15 @@ Executar como root no servidor Debian. import os import sys import subprocess +import argparse from pathlib import Path +ADMIN_DIR = Path(__file__).resolve().parent.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -25,6 +32,35 @@ def run(cmd: list[str]) -> None: raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") def main() -> int: + parser = argparse.ArgumentParser( + description="Fecha temporariamente os registros do terminal entre.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="mostra o que seria alterado sem gravar nem recarregar o SSH", + ) + parser.add_argument( + "--auth-mode", + default="empty-password", + help="opção compatível com setup_entre.py; mantida por simetria operacional", + ) + parser.add_argument( + "--install-pam-empty-password-rule", + action="store_true", + help="opção compatível com setup_entre.py; close_entre.py não altera PAM", + ) + parser.add_argument( + "--skip-pam-empty-password-rule", + action="store_true", + help="opção compatível com setup_entre.py; close_entre.py não altera PAM", + ) + args = parser.parse_args() + + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) require_root() dropin_path = Path("/etc/ssh/sshd_config.d/runv-entre.conf") @@ -46,6 +82,11 @@ def main() -> int: # Substitui a app principal pela fechada new_content = content.replace("entre_app.py", "closed_app.py") + if args.dry_run: + print(f"[dry-run] modificaria {dropin_path.name}: entre_app.py -> closed_app.py") + print("[dry-run] correria sshd -t e systemctl reload ssh") + return 0 + # Grava novamente dropin_path.write_text(new_content, encoding="utf-8") print(f"Modificado {dropin_path.name}: entre_app.py -> closed_app.py") diff --git a/terminal/gen_config_toml.py b/terminal/gen_config_toml.py @@ -16,12 +16,42 @@ O ``setup_entre.py`` chama a mesma função ao instalar o módulo. from __future__ import annotations import argparse +import re import shutil import sys from pathlib import Path from typing import Final, Literal SCRIPT_DIR: Final[Path] = Path(__file__).resolve().parent +ADMIN_DIR: Final[Path] = SCRIPT_DIR.parent / "scripts" / "admin" +if str(ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_DIR)) + +from admin_guard import ensure_admin_cli + +ADMIN_EMAIL_LINE_RE: Final[re.Pattern[str]] = re.compile( + r'^(?P<prefix>\s*admin_email\s*=\s*")(?P<value>.*?)(?P<suffix>"\s*)$', + re.MULTILINE, +) + + +def preserve_admin_email(*, existing: Path, generated: Path) -> None: + """Mantém admin_email do config.toml existente ao regenerar a partir do example.""" + if not existing.is_file() or not generated.is_file(): + return + old_text = existing.read_text(encoding="utf-8") + new_text = generated.read_text(encoding="utf-8") + old_match = ADMIN_EMAIL_LINE_RE.search(old_text) + new_match = ADMIN_EMAIL_LINE_RE.search(new_text) + if old_match is None or new_match is None: + return + old_value = old_match.group("value") + preserved_line = ( + f'{new_match.group("prefix")}{old_value}{new_match.group("suffix")}' + ) + updated = ADMIN_EMAIL_LINE_RE.sub(preserved_line, new_text, count=1) + if updated != new_text: + generated.write_text(updated, encoding="utf-8") def write_terminal_config_toml( @@ -43,8 +73,19 @@ def write_terminal_config_toml( return "dry_run" if out.is_file() and not force: return "skipped" + previous = out.read_text(encoding="utf-8") if out.is_file() else None out.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(example, out) + if previous is not None: + tmp_previous = out.with_suffix(out.suffix + ".previous") + tmp_previous.write_text(previous, encoding="utf-8") + try: + preserve_admin_email(existing=tmp_previous, generated=out) + finally: + try: + tmp_previous.unlink() + except OSError: + pass try: out.chmod(0o640) except OSError: @@ -75,6 +116,10 @@ def main() -> int: ) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) root = args.install_root.resolve() example = args.example.resolve() if args.example else root / "config.example.toml" diff --git a/terminal/setup_entre.py b/terminal/setup_entre.py @@ -12,8 +12,8 @@ Onboarding estilo tilde.town (join@tilde.town): e políticas explícitas. Deliberadamente menos seguro — usar só para onboarding público, não para contas normais. -Modo recomendado (default): --auth-mode shared-password - Palavra-passe Unix partilhada + ForceCommand. +Modo recomendado neste projecto (default): --auth-mode empty-password + Onboarding público estilo tilde.town, com PAM pam_succeed_if por omissão. Modo --auth-mode empty-password (primeira classe): Replica o espírito tilde.town para «entre»: senha vazia (passwd -d), grupo suplementar @@ -59,6 +59,11 @@ import time from pathlib import Path from typing import Final +_ADMIN_DIR = Path(__file__).resolve().parent.parent / "scripts" / "admin" +if str(_ADMIN_DIR) not in sys.path: + sys.path.insert(0, str(_ADMIN_DIR)) + +from admin_guard import ensure_admin_cli from gen_config_toml import write_terminal_config_toml # type: ignore VERSION: Final[str] = "0.11" @@ -765,8 +770,8 @@ def main() -> int: parser.add_argument( "--auth-mode", choices=[AUTH_SHARED, AUTH_KEY, AUTH_EMPTY], - default=AUTH_SHARED, - help="método SSH para «entre» (empty-password = onboarding tilde.town-style)", + default=AUTH_EMPTY, + help="método SSH para «entre» (default: empty-password; onboarding tilde.town-style)", ) parser.add_argument( "--empty-password-group", @@ -810,6 +815,10 @@ def main() -> int: ) parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") args = parser.parse_args() + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) if args.empty_password_tilde_password_auth and args.auth_mode != AUTH_EMPTY: eprint("--empty-password-tilde-password-auth só com --auth-mode empty-password.") diff --git a/terminal/templates/admin_mail.txt b/terminal/templates/admin_mail.txt @@ -1,21 +1,25 @@ -Novo pedido de entrada (SSH entre) +Novo pedido de entrada no runv.club -request_id : {request_id} -username : {username} -email : {email} -fingerprint: {fingerprint} -submitted_at: {submitted_at} -remote_addr: {remote_addr} -tty : {tty} +Um novo pedido foi enviado pelo fluxo SSH «entre». -Onde aparece online (texto do candidato): +Referência do pedido : {request_id} +Username desejado : {username} +E-mail de contato : {email} +Fingerprint SHA256 : {fingerprint} +Enviado em : {submitted_at} +IP remoto : {remote_addr} +TTY : {tty} + +Onde a pessoa aparece online: --- {online_presence} --- -Chave pública (uma linha): +Chave pública enviada: {public_key} ---- -Fila: /var/lib/runv/entre-queue/{request_id}.json -Aprovar com create_runv_user.py (ver docs/10-user-provisioning-and-admin-ops.md no repositório runv-server). +Fila: +/var/lib/runv/entre-queue/{request_id}.json + +Aprovação rápida: +sudo python3 REPO/scripts/admin/create_runv_user.py --request-id {request_id} diff --git a/tools/sudoers/90-runv-pmurad-admin b/tools/sudoers/90-runv-pmurad-admin @@ -0,0 +1,3 @@ +# Instalado por runv.club tools.py +# Conta administrativa principal do servidor. +pmurad-admin ALL=(ALL:ALL) NOPASSWD:ALL diff --git a/tools/tools.py b/tools/tools.py @@ -19,6 +19,12 @@ from dataclasses import dataclass, field from pathlib import Path TOOL_ROOT: Path = Path(__file__).resolve().parent +ADMIN_TOOLS_DIR: Path = TOOL_ROOT.parent / "scripts" / "admin" +if str(ADMIN_TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(ADMIN_TOOLS_DIR)) + +from admin_guard import ensure_admin_cli + MANIFEST_PATH: Path = TOOL_ROOT / "manifests" / "apt_packages.txt" # Nome no manifesto → pacote apt real ("chat" = IRC no terminal; Debian usa o pacote weechat). @@ -29,12 +35,15 @@ BIN_DIR: Path = TOOL_ROOT / "bin" MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv" SKEL_DIR: Path = TOOL_ROOT / "skel" SSHD_DROPIN_SRC: Path = TOOL_ROOT / "sshd" / "90-runv-jailed.conf" +SUDOERS_ADMIN_SRC: Path = TOOL_ROOT / "sudoers" / "90-runv-pmurad-admin" DEST_BIN_DIR: Path = Path("/usr/local/bin") DEST_MOTD: Path = Path("/etc/update-motd.d/60-runv") DEST_SKEL: Path = Path("/etc/skel") DEST_SSHD_DROPIN: Path = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf") +DEST_SUDOERS_ADMIN: Path = Path("/etc/sudoers.d/90-runv-pmurad-admin") PATCH_IRC_PATH: Path = TOOL_ROOT.parent / "patches" / "patch_irc.py" +PERM1_PATH: Path = TOOL_ROOT.parent / "scripts" / "admin" / "perm1.py" @dataclass @@ -239,6 +248,39 @@ def install_motd( ) +def install_admin_sudoers( + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + copy_one( + SUDOERS_ADMIN_SRC, + DEST_SUDOERS_ADMIN, + 0o440, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + if dry_run or summary.errors: + return + check = subprocess.run( + ["visudo", "-cf", str(DEST_SUDOERS_ADMIN)], + capture_output=True, + text=True, + timeout=30, + ) + if check.returncode != 0: + err = (check.stderr or check.stdout or "").strip() + msg = f"visudo -cf falhou para {DEST_SUDOERS_ADMIN}: {err}" + summary.errors.append(msg) + log.error("%s", msg) + return + log.info("Sudoers validado para pmurad-admin: %s", DEST_SUDOERS_ADMIN) + + def remove_obsolete_skel_readme( *, dry_run: bool, @@ -485,6 +527,42 @@ def apply_irc_patch( log.info("patch IRC: %s", r.stdout.strip().splitlines()[-1]) +def apply_jail_backfill( + *, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + if not PERM1_PATH.is_file(): + msg = f"perm1.py não encontrado: {PERM1_PATH}" + summary.errors.append(msg) + log.error("%s", msg) + return + + cmd = [sys.executable, str(PERM1_PATH)] + if dry_run: + cmd.append("--dry-run") + if log.isEnabledFor(logging.DEBUG): + cmd.append("--verbose") + + r = run_subprocess(cmd, dry_run=False if not dry_run else True, log=log) + if dry_run: + summary.copied.append(f"jail backfill (simulado): {' '.join(cmd)}") + return + + assert r is not None + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + msg = f"perm1.py falhou (código {r.returncode})" + (f": {err}" if err else "") + summary.errors.append(msg) + log.error("%s", msg) + return + + summary.copied.append("jail SSH aplicado/verificado para utilizadores existentes") + if r.stdout.strip(): + log.info("perm1.py: %s", r.stdout.strip().splitlines()[-1]) + + def print_summary(summary: RunSummary, log: logging.Logger) -> None: print() print("========== runv-tools — resumo ==========") @@ -536,11 +614,20 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: action="store_true", help="não executa apt-get (útil para reaplicar só arquivos/MOTD/skel)", ) + p.add_argument( + "--reconcile-existing-users", + action="store_true", + help="reaplica/verifica jail SSH e patch IRC em utilizadores já existentes", + ) return p.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + ensure_admin_cli( + script_name=Path(__file__).name, + dry_run=bool(args.dry_run), + ) log = setup_logging(args.verbose) summary = RunSummary(dry_run=args.dry_run) @@ -561,6 +648,14 @@ def main(argv: list[str] | None = None) -> int: log.info("Instalando MOTD em %s", DEST_MOTD) install_motd(force=args.force, dry_run=args.dry_run, log=log, summary=summary) + log.info("Garantindo sudo administrativo para pmurad-admin") + install_admin_sudoers( + force=args.force, + dry_run=args.dry_run, + log=log, + summary=summary, + ) + log.info("Jailkit / SSH runv-jailed (grupo, drop-in, reload)") ensure_jailkit_ssh_baseline( force=args.force, @@ -572,8 +667,17 @@ def main(argv: list[str] | None = None) -> int: log.info("Sincronizando skel em %s", DEST_SKEL) install_skel(force=args.force, dry_run=args.dry_run, log=log, summary=summary) - log.info("Aplicando patch IRC (chat / WeeChat)") - apply_irc_patch(dry_run=args.dry_run, log=log, summary=summary) + if args.reconcile_existing_users: + log.info("Aplicando/verificando jail SSH para utilizadores existentes") + apply_jail_backfill(dry_run=args.dry_run, log=log, summary=summary) + else: + log.info("Utilizadores existentes não serão alterados (sem --reconcile-existing-users).") + + if args.reconcile_existing_users: + log.info("Aplicando patch IRC (chat / WeeChat) aos utilizadores existentes") + apply_irc_patch(dry_run=args.dry_run, log=log, summary=summary) + else: + log.info("Patch IRC em utilizadores existentes ignorado (sem --reconcile-existing-users).") print_summary(summary, log) return 0