runv-server

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

commit e9b032eb093e3ac62215ebee9cbb22dd166361de
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 11:31:34 -0300

Initial commit

Made-with: Cursor

Diffstat:
A.gitignore | 10++++++++++
AINSTALL.md | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 4++++
Aemail/README.md | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/config/aliases.example | 7+++++++
Aemail/config/msmtprc.example | 20++++++++++++++++++++
Aemail/config/netrc.example | 6++++++
Aemail/configure_msmtp.py | 468+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/docs/ADMIN.md | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/docs/INSTALL.md | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/docs/INTEGRATION.md | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/docs/TROUBLESHOOTING.md | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/lib/__init__.py | 1+
Aemail/lib/mailer.py | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aemail/lib/templates.py | 41+++++++++++++++++++++++++++++++++++++++++
Aemail/scripts/diagnose_msmtp.sh | 46++++++++++++++++++++++++++++++++++++++++++++++
Aemail/scripts/netrc_password.py | 41+++++++++++++++++++++++++++++++++++++++++
Aemail/scripts/send_test_mail.sh | 21+++++++++++++++++++++
Aemail/templates/admin_error.txt | 9+++++++++
Aemail/templates/admin_new_request.txt | 13+++++++++++++
Aemail/templates/admin_user_created.txt | 9+++++++++
Aemail/templates/admin_user_deleted.txt | 7+++++++
Aemail/templates/system_test.txt | 10++++++++++
Aemail/templates/user_account_created.txt | 12++++++++++++
Aemail/templates/user_account_removed.txt | 10++++++++++
Aemail/templates/user_approved.txt | 10++++++++++
Aemail/templates/user_quota_warning.txt | 12++++++++++++
Aemail/templates/user_rejected.txt | 13+++++++++++++
Aemail/templates/user_request_received.txt | 13+++++++++++++
Ascripts/admin/create_runv_user.py | 1422+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/del-user.py | 489+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/runv_mount.py | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/skel.py | 401+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/starthere.py | 719+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/update_user.py | 752+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/create_runv_user.md | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/del-user.md | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/docs/1 - begining.md | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/docs/2 - server setup.md | 625+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/doom/doom.md | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/doom/doom.py | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/skel.md | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/starthere.md | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/README.md | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/build_directory.md | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/build_directory.py | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/example-users.json | 38++++++++++++++++++++++++++++++++++++++
Asite/genlanding.md | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/genlanding.py | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/public/assets/app.js | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/public/assets/style.css | 570+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/public/data/members.json | 1+
Asite/public/index.html | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/public/junte-se/index.html | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asite/public/news/index.html | 41+++++++++++++++++++++++++++++++++++++++++
Asite/public/wiki/index.html | 41+++++++++++++++++++++++++++++++++++++++++
Aterminal/README.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/config.example.toml | 12++++++++++++
Aterminal/data/.gitkeep | 0
Aterminal/docs/ADMIN.md | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/docs/ARCHITECTURE.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/docs/INSTALL.md | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/docs/USO.md | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/entre_app.py | 429+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/entre_core.py | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/examples/sshd_match_entre.conf.sample | 19+++++++++++++++++++
Aterminal/examples/sshd_match_entre_empty.conf.sample | 32++++++++++++++++++++++++++++++++
Aterminal/scripts/install.sh | 6++++++
Aterminal/scripts/test_local.sh | 11+++++++++++
Aterminal/scripts/test_mail.sh | 14++++++++++++++
Aterminal/setup_entre.py | 897+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/systemd/runv-entre-notify.path | 16++++++++++++++++
Aterminal/systemd/runv-entre-notify.service | 15+++++++++++++++
Aterminal/templates/admin_console_notice.txt | 1+
Aterminal/templates/admin_mail.txt | 16++++++++++++++++
Aterminal/templates/confirm.txt | 13+++++++++++++
Aterminal/templates/goodbye.txt | 20++++++++++++++++++++
Aterminal/templates/intro.txt | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aterminal/templates/warning_public_key.txt | 25+++++++++++++++++++++++++
Atools/README.md | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-help | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-links | 26++++++++++++++++++++++++++
Atools/bin/runv-status | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/docs/ADMIN.md | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/docs/INSTALL.md | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/docs/USER_EXPERIENCE.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/manifests/apt_packages.txt | 15+++++++++++++++
Atools/motd/60-runv | 29+++++++++++++++++++++++++++++
Atools/setuptools/tools.txt | 10++++++++++
Atools/skel/.bash_aliases | 7+++++++
Atools/skel/README.md | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/skel/public_html/index.html | 48++++++++++++++++++++++++++++++++++++++++++++++++
Atools/tools.py | 373+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
93 files changed, 12709 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*$py.class +.Python +.venv/ +venv/ +.env +.env.local +guide.md +\ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md @@ -0,0 +1,182 @@ +# Instalação do servidor runv.club + +Este documento descreve a **ordem recomendada** para preparar um servidor Debian (testado em Debian 13 “trixie”) com os scripts deste repositório, e onde aprofundar a configuração de cada módulo. + +**Pré-requisitos** + +- Acesso **root** (ou `sudo`) no servidor. +- Repositório clonado num caminho fixo (ex.: `/root/runv-server` ou `/opt/runv/src`). Os exemplos abaixo assumem que o diretório raiz do clone é `REPO` — substitua pelo caminho real. + +**Convenções de caminhos no servidor** + +| Caminho | Função | +|--------|--------| +| `/var/lib/runv/users.json` | Metadados dos utilizadores (criado na primeira operação que o use) | +| `/var/lib/runv/users.lock` | Lock para escrita segura em `users.json` | +| `/var/lib/runv/entre-queue` | Fila de pedidos do SSH «entre» | +| `/var/log/runv/` | Logs do terminal `entre` | +| `/opt/runv/terminal/` | Cópia instalada do módulo `terminal/` | + +--- + +## Ordem geral (resumo) + +1. **Bootstrap do sistema** — `scripts/admin/starthere.py` (Apache, SSH, firewall, quotas, pacotes base). +2. **Ferramentas e ficheiros globais** — `tools/tools.py` (MOTD, skel, binários, pacotes do manifest). +3. **Site Apache / landing** — `site/genlanding.py`. +4. **Dados públicos da landing** — `site/build_directory.py` (idealmente em **cron**). +5. **Email de saída (msmtp)** — `email/configure_msmtp.py` (+ documentação em `email/docs/`). +6. **SSH restrito «entre»** — `terminal/setup_entre.py`. +7. **Operação** — `create_runv_user.py`, `update_user.py`, `del-user.py` (e só em cenários controlados: `scripts/doom/doom.py`). + +Os passos 2–6 podem ser ajustados conforme já tiveres Apache ou email configurados; a ordem acima minimiza dependências (Apache antes de publicar; `users.json` antes do cron que o lê). + +--- + +## 1. Bootstrap: `starthere.py` + +**Objetivo:** instalar e preparar Apache, OpenSSH, UFW, quotas, pacotes úteis e hardening básico. + +```bash +cd REPO/scripts/admin +sudo python3 starthere.py --help # rever opções +sudo python3 starthere.py # ou com flags que precisares +``` + +**Nota:** este script **não** configura msmtp nem o utilizador `entre`. Consulta também os Markdown em `scripts/docs/` se existirem no teu clone. + +--- + +## 2. Ferramentas globais: `tools/tools.py` + +**Objetivo:** aplicar manifest de pacotes APT, scripts em `/usr/local/bin`, MOTD, skel de utilizador, etc. + +```bash +cd REPO/tools +sudo python3 tools.py --help +sudo python3 tools.py # execução real (requer root conforme o script) +``` + +Detalhes: `tools/docs/INSTALL.md` (se presente). + +--- + +## 3. Landing Apache: `genlanding.py` + +**Objetivo:** virtual host, site estático, opcionalmente Certbot. + +```bash +cd REPO/site +sudo python3 genlanding.py --help +sudo python3 genlanding.py --domain runv.club # exemplo; ajustar domínio e flags +``` + +Garante que o DNS aponta para o servidor antes de TLS com Certbot. + +--- + +## 4. Dados públicos: `build_directory.py` + +**Objetivo:** gerar ficheiros consumidos pela landing a partir de `/var/lib/runv/users.json`. + +```bash +cd REPO/site +python3 build_directory.py --help +``` + +**Cron (exemplo)** — executar como utilizador com permissão de leitura a `users.json` e escrita no destino web: + +```cron +*/15 * * * * cd /caminho/para/REPO/site && /usr/bin/python3 build_directory.py +``` + +Ajusta intervalo e caminhos conforme a política do servidor. + +--- + +## 5. Email (msmtp + mail): `email/configure_msmtp.py` + +**Objetivo:** pacotes (`msmtp-mta`, `bsd-mailx` ou equivalente), `msmtprc`, `~/.netrc` ou segredo adequado, aliases, testes. + +```bash +cd REPO/email +sudo python3 configure_msmtp.py --help +sudo python3 configure_msmtp.py --dry-run # simular +sudo python3 configure_msmtp.py # aplicar (root) +``` + +Documentação completa: + +- `email/docs/INSTALL.md` +- `email/docs/ADMIN.md`, `TROUBLESHOOTING.md`, `INTEGRATION.md` +- `email/README.md` + +Scripts auxiliares: `email/scripts/send_test_mail.sh`, `email/scripts/diagnose_msmtp.sh`. + +--- + +## 6. Terminal SSH «entre»: `setup_entre.py` + +**Objetivo:** utilizador `entre`, cópia do módulo para `/opt/runv/terminal`, drop-in `sshd_config`, filas e logs. + +```bash +cd REPO/terminal +sudo python3 setup_entre.py --help +sudo python3 setup_entre.py +``` + +- Coloca chaves em `~entre/.ssh/authorized_keys` antes de confiar em acessos. +- Integração com email (avisos): `email/docs/INTEGRATION.md` e `terminal/docs/INSTALL.md`. + +Arranque do serviço Python (systemd ou manual) está descrito na documentação do terminal. + +--- + +## 7. Operação: contas runv + +Todos em `REPO/scripts/admin/` (executar como **root** salvo indicação em contrário). + +| Script | Uso | +|--------|-----| +| `create_runv_user.py` | Criar utilizador Unix + home + quota + entrada em `users.json` | +| `update_user.py` | Atualizar metadados / quota / estado | +| `del-user.py` | Remover utilizador e limpar metadados (com locks e confirmações) | + +**Ordem típica na vida real:** após infraestrutura (passos 1–6), usas `create_runv_user.py` para cada membro; `build_directory.py` (cron) mantém o site alinhado com `users.json`. + +### `scripts/doom/doom.py` (perigoso) + +Remove **todas** as contas listadas em `users.json` exceto a indicada por `--keep`. Só em ambientes de teste ou com backups e confirmação explícita. + +```bash +cd REPO/scripts/doom +sudo python3 doom.py --help +``` + +--- + +## Verificação rápida (checklist) + +- [ ] `sshd -t` sem erros após drop-in do `entre`. +- [ ] `apache2ctl configtest` / `apachectl configtest` OK após `genlanding.py`. +- [ ] `systemctl status apache2` e `ssh` ativos. +- [ ] Email: `send_test_mail.sh` ou equivalente a partir da documentação do email. +- [ ] Ficheiro `users.json` coerente e `build_directory.py` gera saída esperada. +- [ ] Login SSH como `entre` executa apenas o menu esperado (ForceCommand). + +--- + +## Documentação por pasta + +| Pasta | Documentos | +|-------|------------| +| `email/` | `README.md`, `docs/INSTALL.md`, `ADMIN.md`, … | +| `terminal/` | `docs/INSTALL.md` | +| `tools/` | `docs/INSTALL.md` | +| `scripts/` | `docs/*.md` (conforme o repositório) | + +--- + +## Nota sobre o código Python + +Os scripts usam `subprocess` com **listas de argumentos** (sem `shell=True` nas invocações analisadas), o que reduz risco de injeção de comando. Antes de atualizar em produção, convém correr `python3 -m compileall` na raiz do repositório e testar com `--dry-run` onde o script o suportar. diff --git a/README.md b/README.md @@ -0,0 +1,3 @@ +runv.club server stuff + +~pmurad +\ No newline at end of file diff --git a/email/README.md b/email/README.md @@ -0,0 +1,57 @@ +# Email runv.club — envio via msmtp + sendmail + +Subsistema **só de envio** para Debian 13: instala `msmtp`, `msmtp-mta`, `ca-certificates` e `bsd-mailx`, configura `/etc/msmtprc` e credenciais em `/root/.netrc`, e oferece biblioteca Python reutilizável com templates em texto puro. + +## O que faz + +- Coloca `/usr/sbin/sendmail` compatível (via **msmtp-mta**) a apontar para um **SMTP externo** configurável. +- **Não** instala Postfix, Exim, Dovecot nem qualquer MTA completo. +- **Não** recebe email (sem IMAP, sem caixa local). + +## O que instala (APT) + +| Pacote | Papel | +|--------|--------| +| `msmtp` | Cliente SMTP. | +| `msmtp-mta` | Fornece `/usr/sbin/sendmail`. | +| `ca-certificates` | Confiança TLS. | +| `bsd-mailx` | Comando `mail` para testes em CLI (evita `mailutils`, que pode recomendar MTA local). | + +## Execução rápida + +```bash +cd /caminho/runv-server/email +sudo python3 configure_msmtp.py +``` + +Flags: `--dry-run`, `--verbose`, `--force`, `--test`, `--skip-apt`. Detalhes: [docs/INSTALL.md](docs/INSTALL.md). + +## Documentação + +| Ficheiro | Conteúdo | +|----------|-----------| +| [docs/INSTALL.md](docs/INSTALL.md) | Instalação, flags, verificação, testes. | +| [docs/ADMIN.md](docs/ADMIN.md) | Alterar SMTP, remetente, admin, aliases. | +| [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Falhas comuns. | +| [docs/INTEGRATION.md](docs/INTEGRATION.md) | `lib/mailer.py`, eventos, scripts existentes. | + +## Biblioteca + +Defina `RUNV_EMAIL_ROOT` para a pasta `email/` do repositório (onde estão `lib/` e `templates/`) e importe `lib.mailer`. + +## Checklist manual de verificação + +- [ ] `dpkg -l msmtp msmtp-mta` — pacotes instalados. +- [ ] `readlink -f /usr/sbin/sendmail` — aponta para msmtp. +- [ ] `ls -l /etc/msmtprc /root/.netrc` — permissões 600, root. +- [ ] `sudo python3 configure_msmtp.py --test` — email de teste recebido. +- [ ] `echo corpo | mail -s assunto root` — chega ao alias do admin (se aliases configurados). +- [ ] Fluxo `entre` com `admin_email` no `config.toml` — notificação ao admin (regressão). + +## Scripts auxiliares + +- `scripts/diagnose_msmtp.sh` — diagnóstico sem segredos. +- `scripts/send_test_mail.sh` — teste via `mail`. +- `scripts/netrc_password.py` — usado por `passwordeval` no msmtp (instalado em `/usr/local/lib/runv-email/`). + +Versão do módulo alinhada ao repositório runv-server. diff --git a/email/config/aliases.example b/email/config/aliases.example @@ -0,0 +1,7 @@ +# Aliases msmtp — formato: nome_local: endereco@destino +# Isto NÃO é Sendmail: o comando newaliases não aplica aqui. +# Edite /etc/msmtp_aliases no servidor e mantenha coerente com msmtprc (directiva aliases). + +root: admin@example.com +cron: admin@example.com +default: admin@example.com diff --git a/email/config/msmtprc.example b/email/config/msmtprc.example @@ -0,0 +1,20 @@ +# Exemplo de /etc/msmtprc (runv.club — msmtp, sem MTA local). +# Gerado normalmente por email/configure_msmtp.py — não copie credenciais para exemplos. + +defaults +auth on +tls on +tls_starttls on +tls_trust_file /etc/ssl/certs/ca-certificates.crt +logfile /var/log/msmtp.log + +account runv +host SMTP_HOST +port 587 +from DEFAULT_FROM_ADDRESS +user SMTP_USER +# Palavra-passe vem de /root/.netrc (máquina = mesmo valor que host) +passwordeval /usr/local/lib/runv-email/netrc_password.py SMTP_HOST +aliases /etc/msmtp_aliases + +account default : runv diff --git a/email/config/netrc.example b/email/config/netrc.example @@ -0,0 +1,6 @@ +# Exemplo de /root/.netrc — permissões obrigatórias: chmod 600, dono root. +# A linha "machine" deve coincidir com o hostname SMTP usado no msmtprc (campo host). + +machine smtp.example.com +login usuario_smtp@example.com +password COLOQUE_A_SENHA_AQUI diff --git a/email/configure_msmtp.py b/email/configure_msmtp.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +""" +Instalador/configurador runv.club — envio de email via msmtp + sendmail (Debian 13). + +Executar como root. Ver email/docs/INSTALL.md. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import time +from getpass import getpass +from pathlib import Path +from typing import Any + +# Caminhos no sistema +MSMPTRC_PATH = Path("/etc/msmtprc") +ALIASES_PATH = Path("/etc/msmtp_aliases") +NETRC_PATH = Path("/root/.netrc") +STATE_PATH = Path("/etc/runv-email.json") +PASS_SCRIPT_DIR = Path("/usr/local/lib/runv-email") +PASS_SCRIPT_DEST = PASS_SCRIPT_DIR / "netrc_password.py" +LOGFILE_MSMT = Path("/var/log/msmtp.log") + +MODULE_ROOT = Path(__file__).resolve().parent +SOURCE_PASS_SCRIPT = MODULE_ROOT / "scripts" / "netrc_password.py" + +APT_PACKAGES = ("msmtp", "msmtp-mta", "ca-certificates", "bsd-mailx") + +ACCOUNT_NAME = "runv" + + +def setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + +def log() -> logging.Logger: + return logging.getLogger("runv-email") + + +def require_root() -> None: + if os.geteuid() != 0: + print("Execute como root (sudo).", file=sys.stderr) + raise SystemExit(1) + + +def run_cmd( + cmd: list[str], + *, + dry_run: bool, + timeout: int = 600, +) -> subprocess.CompletedProcess[str] | None: + log().debug("exec: %s", " ".join(cmd)) + if dry_run: + log().info("[dry-run] %s", " ".join(cmd)) + return None + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def apt_install(dry_run: bool) -> None: + r = run_cmd(["apt-get", "update", "-qq"], dry_run=dry_run) + if r is not None and r.returncode != 0: + log().warning("apt-get update: código %s — %s", r.returncode, r.stderr.strip()) + r2 = run_cmd( + ["apt-get", "install", "-y", *APT_PACKAGES], + dry_run=dry_run, + ) + if r2 is not None and r2.returncode != 0: + raise RuntimeError(f"apt-get install falhou: {r2.stderr or r2.stdout}") + + +def backup_if_exists(path: Path, *, dry_run: bool, force: bool) -> Path | None: + if not path.is_file(): + return None + bak = path.with_name(f"{path.name}.bak.{int(time.time())}") + if dry_run: + log().info("[dry-run] backup seria: %s -> %s", path, bak) + return bak + shutil.copy2(path, bak) + log().info("Backup: %s", bak) + return bak + + +def confirm_overwrite(path: Path, *, force: bool) -> bool: + if force: + return True + if not path.is_file(): + return True + r = input(f"O ficheiro {path} já existe. Sobrescrever? [s/N]: ").strip().lower() + return r in ("s", "sim", "y", "yes") + + +def _remove_netrc_machine_block(text: str, host: str) -> str: + """Remove o bloco que começa em 'machine <host>' até à linha antes do próximo 'machine '.""" + host_line = re.compile(rf"^machine\s+{re.escape(host)}\s*$", re.MULTILINE) + next_machine = re.compile(r"^machine\s+", re.MULTILINE) + lines = text.splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + if host_line.match(lines[i]): + i += 1 + while i < len(lines) and not next_machine.match(lines[i]): + i += 1 + continue + out.append(lines[i]) + i += 1 + return "\n".join(out) + + +def upsert_netrc_machine(host: str, login: str, password: str, *, dry_run: bool) -> None: + """Atualiza ou acrescenta bloco machine HOST em /root/.netrc.""" + block = f"machine {host}\nlogin {login}\npassword {password}\n" + if dry_run: + log().info("[dry-run] atualizaria .netrc para machine %s", host) + return + + existing = "" + if NETRC_PATH.is_file(): + existing = NETRC_PATH.read_text(encoding="utf-8", errors="replace") + + stripped = _remove_netrc_machine_block(existing, host).rstrip() + new_text = (stripped + "\n\n" + block if stripped else block).rstrip() + "\n" + + NETRC_PATH.parent.mkdir(parents=True, exist_ok=True) + NETRC_PATH.write_text(new_text, encoding="utf-8") + os.chmod(NETRC_PATH, 0o600) + try: + os.chown(NETRC_PATH, 0, 0) + except OSError: + pass + log().info("Escrito %s (0600)", NETRC_PATH) + + +def install_passwordeval_script(*, dry_run: bool) -> None: + if not SOURCE_PASS_SCRIPT.is_file(): + raise FileNotFoundError(f"script em falta no módulo: {SOURCE_PASS_SCRIPT}") + if dry_run: + log().info("[dry-run] copiaria netrc_password.py para %s", PASS_SCRIPT_DEST) + return + PASS_SCRIPT_DIR.mkdir(parents=True, mode=0o755, exist_ok=True) + shutil.copy2(SOURCE_PASS_SCRIPT, PASS_SCRIPT_DEST) + PASS_SCRIPT_DEST.chmod(0o755) + try: + os.chown(PASS_SCRIPT_DEST, 0, 0) + except OSError: + pass + log().info("Instalado %s", PASS_SCRIPT_DEST) + + +def build_msmtprc( + *, + host: str, + port: int, + tls_on: bool, + starttls_on: bool, + auth_on: bool, + user: str, + default_from: str, + use_aliases: bool, +) -> str: + lines = [ + "# Gerido por runv.club configure_msmtp.py — não editar à mão sem cópia de segurança", + "", + "defaults", + f"tls_trust_file /etc/ssl/certs/ca-certificates.crt", + f"logfile {LOGFILE_MSMT}", + "", + f"account {ACCOUNT_NAME}", + f"host {host}", + f"port {port}", + f"from {default_from}", + "tls " + ("on" if tls_on else "off"), + "tls_starttls " + ("on" if starttls_on else "off"), + ] + if auth_on and user: + lines.append("auth on") + lines.append(f"user {user}") + lines.append(f"passwordeval {PASS_SCRIPT_DEST} {host}") + else: + lines.append("auth off") + + if use_aliases: + lines.append(f"aliases {ALIASES_PATH}") + + lines.extend( + [ + "", + f"account default : {ACCOUNT_NAME}", + "", + ] + ) + return "\n".join(lines) + + +def write_msmtprc(content: str, *, dry_run: bool) -> None: + if dry_run: + log().info("[dry-run] escreveria %s", MSMPTRC_PATH) + log().debug("%s", content) + return + MSMPTRC_PATH.write_text(content, encoding="utf-8") + os.chmod(MSMPTRC_PATH, 0o600) + try: + os.chown(MSMPTRC_PATH, 0, 0) + except OSError: + pass + log().info("Escrito %s (0600)", MSMPTRC_PATH) + + +def write_aliases(admin_email: str, *, dry_run: bool) -> None: + body = ( + f"# Gerido por runv.club configure_msmtp.py — formato msmtp (não Sendmail)\n" + f"root: {admin_email}\n" + f"cron: {admin_email}\n" + f"default: {admin_email}\n" + ) + if dry_run: + log().info("[dry-run] escreveria %s", ALIASES_PATH) + return + backup_if_exists(ALIASES_PATH, dry_run=False, force=True) + ALIASES_PATH.write_text(body, encoding="utf-8") + os.chmod(ALIASES_PATH, 0o644) + try: + os.chown(ALIASES_PATH, 0, 0) + except OSError: + pass + log().info("Escrito %s (0644)", ALIASES_PATH) + + +def write_state(data: dict[str, Any], *, dry_run: bool) -> None: + if dry_run: + log().info("[dry-run] escreveria %s", STATE_PATH) + return + STATE_PATH.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + os.chmod(STATE_PATH, 0o600) + try: + os.chown(STATE_PATH, 0, 0) + except OSError: + pass + log().info("Metadados em %s (sem segredos)", STATE_PATH) + + +def touch_logfile(*, dry_run: bool) -> None: + if dry_run: + return + LOGFILE_MSMT.parent.mkdir(parents=True, exist_ok=True) + if not LOGFILE_MSMT.exists(): + LOGFILE_MSMT.touch(mode=0o640) + try: + os.chown(LOGFILE_MSMT, 0, 0) + except OSError: + pass + + +def load_state() -> dict[str, Any]: + if not STATE_PATH.is_file(): + raise FileNotFoundError( + f"Estado não encontrado: {STATE_PATH}. Execute configure_msmtp.py sem --test primeiro.", + ) + return json.loads(STATE_PATH.read_text(encoding="utf-8")) + + +def run_test_send(*, dry_run: bool) -> None: + state = load_state() + admin = str(state.get("admin_email", "")).strip() + from_addr = str(state.get("default_from", "")).strip() + if not admin or not from_addr: + raise ValueError("admin_email ou default_from em falta no estado") + + sys.path.insert(0, str(MODULE_ROOT)) + from lib.mailer import render_template, send_mail + + body = render_template( + "system_test", + admin_email=admin, + default_from=from_addr, + host=state.get("smtp_host", ""), + timestamp=str(int(time.time())), + ) + subj = "[runv.club] Email de teste do sistema" + if dry_run: + log().info("[dry-run] enviaria teste para %s", admin) + return + send_mail(admin, subj, body, from_addr=from_addr) + log().info("Email de teste enviado para %s", admin) + + +def prompt_yes_no(msg: str, default_no: bool = True) -> bool: + suf = " [s/N]: " if default_no else " [S/n]: " + r = input(msg + suf).strip().lower() + if not r: + return not default_no + return r in ("s", "sim", "y", "yes") + + +def prompt_line(msg: str, default: str = "") -> str: + d = f" [{default}]" if default else "" + r = input(f"{msg}{d}: ").strip() + return r if r else default + + +def interactive_config() -> dict[str, Any]: + print("\n=== Configuração SMTP (genérico — nenhum provedor assumido) ===\n") + host = prompt_line("Host SMTP") + if not host: + raise ValueError("Host SMTP obrigatório.") + + port_s = prompt_line("Porta SMTP", "587") + port = int(port_s) if port_s.isdigit() else 587 + + tls_on = prompt_yes_no("Usar TLS (tls)?", default_no=False) + starttls_on = prompt_yes_no("Usar STARTTLS (tls_starttls)?", default_no=False) + auth_on = prompt_yes_no("Autenticação SMTP (usuário/senha)?", default_no=False) + + user = "" + if auth_on: + user = prompt_line("Utilizador SMTP (login)") + if not user: + raise ValueError("Com auth on, o utilizador SMTP é obrigatório.") + + default_from = prompt_line("Remetente padrão (From)") + if not default_from or "@" not in default_from: + raise ValueError("Remetente (From) deve ser um endereço de email válido.") + + admin_email = prompt_line("Email do administrador (notificações)") + if not admin_email or "@" not in admin_email: + raise ValueError("Email do admin inválido.") + + password = "" + if auth_on: + p1 = getpass("Senha ou token SMTP (não ecoa): ") + p2 = getpass("Repita a senha: ") + if p1 != p2: + raise ValueError("Senhas não coincidem.") + password = p1 + + return { + "smtp_host": host, + "smtp_port": port, + "tls_on": tls_on, + "starttls_on": starttls_on, + "auth_on": auth_on, + "smtp_user": user, + "smtp_password": password, + "default_from": default_from, + "admin_email": admin_email, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Instala msmtp/sendmail e configura envio de email runv.club.", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--verbose", "-v", action="store_true") + parser.add_argument("--force", "-f", action="store_true", help="sobrescrever sem perguntar") + parser.add_argument( + "--test", + action="store_true", + help="enviar apenas email de teste (requer config e %s)" % STATE_PATH, + ) + parser.add_argument("--skip-apt", action="store_true", help="não executar apt-get") + args = parser.parse_args() + + setup_logging(args.verbose) + require_root() + + try: + if args.test: + run_test_send(dry_run=args.dry_run) + print("Teste concluído.") + return 0 + + if not args.skip_apt: + apt_install(args.dry_run) + + touch_logfile(dry_run=args.dry_run) + install_passwordeval_script(dry_run=args.dry_run) + + cfg = interactive_config() + + if not confirm_overwrite(MSMPTRC_PATH, force=args.force): + print("Cancelado.") + return 1 + backup_if_exists(MSMPTRC_PATH, dry_run=args.dry_run, force=args.force) + + if cfg["auth_on"]: + if not cfg.get("smtp_password"): + raise ValueError("Com autenticação ligada, a senha/token é obrigatório.") + if not confirm_overwrite(NETRC_PATH, force=args.force): + print("Cancelado.") + return 1 + backup_if_exists(NETRC_PATH, dry_run=args.dry_run, force=args.force) + upsert_netrc_machine( + cfg["smtp_host"], + cfg["smtp_user"], + cfg["smtp_password"], + dry_run=args.dry_run, + ) + + mc = build_msmtprc( + host=cfg["smtp_host"], + port=int(cfg["smtp_port"]), + tls_on=bool(cfg["tls_on"]), + starttls_on=bool(cfg["starttls_on"]), + auth_on=bool(cfg["auth_on"]), + user=cfg["smtp_user"], + default_from=cfg["default_from"], + use_aliases=True, + ) + write_msmtprc(mc, dry_run=args.dry_run) + + if not confirm_overwrite(ALIASES_PATH, force=args.force): + print("Cancelado.") + return 1 + write_aliases(cfg["admin_email"], dry_run=args.dry_run) + + state_public = { + "admin_email": cfg["admin_email"], + "default_from": cfg["default_from"], + "smtp_host": cfg["smtp_host"], + "smtp_port": cfg["smtp_port"], + } + write_state(state_public, dry_run=args.dry_run) + + if not args.dry_run and prompt_yes_no("\nEnviar email de teste agora?", default_no=True): + try: + run_test_send(dry_run=False) + log().info("Teste enviado.") + except Exception as e: + log().warning("Teste falhou (config pode estar correta mesmo assim): %s", e) + + print("\n=== Resumo ===") + print(f" msmtp: {MSMPTRC_PATH}") + print(f" aliases: {ALIASES_PATH}") + print(f" netrc: {NETRC_PATH} (credenciais — não partilhar)") + print(f" estado: {STATE_PATH}") + print(f" sendmail: /usr/sbin/sendmail (msmtp-mta)") + print("\nDocumentação: email/docs/INSTALL.md") + print("Teste posterior: sudo python3 email/configure_msmtp.py --test") + return 0 + + except (KeyboardInterrupt, EOFError): + print("\nInterrompido.", file=sys.stderr) + return 130 + except Exception as e: + log().error("%s", e) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/email/docs/ADMIN.md b/email/docs/ADMIN.md @@ -0,0 +1,50 @@ +# Administração — email runv.club + +## Alterar remetente padrão (From) + +1. Edite `/etc/msmtprc` na conta `runv`: linha `from ...`. +2. Actualize `/etc/runv-email.json` campo `default_from` (consistência com `--test` e documentação interna). +3. Valide com `sudo python3 configure_msmtp.py --test` ou envio manual via `mail`. + +Faça **cópia de segurança** antes: `sudo cp /etc/msmtprc /etc/msmtprc.bak.$(date +%s)`. + +## Alterar email do administrador + +1. Edite `/etc/msmtp_aliases` — linhas `root:`, `cron:`, `default:` para o novo endereço. +2. Actualize `admin_email` em `/etc/runv-email.json`. +3. Actualize também `admin_email` em `/opt/runv/terminal/config.toml` se usar o fluxo **entre**. + +## Trocar host, porta ou TLS + +1. Edite `/etc/msmtprc` (`host`, `port`, `tls`, `tls_starttls`, `user` se aplicável). +2. Se mudar o **hostname** SMTP, actualize `/root/.netrc`: + - a linha `machine` deve coincidir com o novo `host`; + - ou volte a correr `configure_msmtp.py` (com `--force`) para regenerar de forma coerente. + +## Credenciais + +- Senha/token **só** em `/root/.netrc` (ou volte a correr `configure_msmtp.py` para reprompt seguro). +- **Nunca** coloque senhas em `/etc/runv-email.json` nem em `msmtprc` em claro. + +## Reenviar email de teste + +```bash +sudo python3 /caminho/runv-server/email/configure_msmtp.py --test +``` + +Requer `/etc/runv-email.json` e configuração msmtp válida. + +## Integrar outros scripts + +Ver [INTEGRATION.md](INTEGRATION.md). Resumo: definir `RUNV_EMAIL_ROOT` e usar `lib.mailer.send_mail` ou funções de template. + +## Aliases e limitações + +- **msmtp** expande aliases conforme o seu ficheiro `aliases` — útil para `mail root` redirecionar para o admin. +- Isto **não** substitui um servidor de correio completo: endereços locais fictícios só funcionam na medida em que o `mail`/pipeline os passa e o msmtp resolve via aliases. +- **`newaliases`** (estilo Sendmail) **não** actualiza este ficheiro. + +## Log + +- Ficheiro configurado em `msmtprc`: por defeito `/var/log/msmtp.log`. +- Permissões: criado pelo instalador; ajuste se necessário para rotação (logrotate). diff --git a/email/docs/INSTALL.md b/email/docs/INSTALL.md @@ -0,0 +1,115 @@ +# Instalação — módulo email runv.club + +Debian 13 (ou próximo). **Apenas envio** — SMTP externo via **msmtp**; interface **`/usr/sbin/sendmail`**. + +## Dependências + +Instaladas automaticamente por `configure_msmtp.py`: + +- `msmtp`, `msmtp-mta`, `ca-certificates`, `bsd-mailx` + +**Porque `bsd-mailx` e não `mailutils`?** O meta-pacote `mailutils` no Debian **recomenda** `default-mta`, o que em instalações interativas pode puxar **Postfix ou Exim**. O objetivo aqui é **não** ter servidor de correio local — só um cliente que invoque `sendmail` (na prática msmtp). + +## Pré-requisitos + +- Acesso **root** ao servidor. +- Conta SMTP relay (qualquer fornecedor — não é assumido no código). +- Firewall a permitir saída TCP para o host/porta SMTP. + +## Executar o instalador + +```bash +cd /caminho/para/runv-server/email +sudo python3 configure_msmtp.py +``` + +O script pergunta (de forma genérica): + +- host e porta SMTP; +- TLS e STARTTLS (sim/não); +- autenticação (sim/não), utilizador e senha/token (**não ecoa**); +- remetente padrão (From); +- email do administrador. + +Gera (com backup se já existir): + +| Ficheiro | Descrição | +|----------|-----------| +| `/etc/msmtprc` | Conta `runv`, `default : runv`, log, aliases. **0600** root. | +| `/root/.netrc` | Entrada `machine <host>` com `login` e `password`. **0600** root. | +| `/etc/msmtp_aliases` | `root`, `cron`, `default` → email do admin. **0644** root. | +| `/etc/runv-email.json` | Metadados **sem segredos** (`admin_email`, `default_from`, host) para `--test`. **0600** root. | +| `/usr/local/lib/runv-email/netrc_password.py` | Helper para `passwordeval` ler a senha do `.netrc`. | + +## Flags + +| Flag | Efeito | +|------|--------| +| `--dry-run` | Mostra acções; não grava ficheiros nem apt (exceto prompts interactivos). | +| `--verbose` / `-v` | Log DEBUG. | +| `--force` / `-f` | Sobrescreve sem confirmar. | +| `--test` | Só envia [system_test.txt](../templates/system_test.txt) usando estado existente. | +| `--skip-apt` | Não corre `apt-get` (útil se pacotes já instalados). | + +Exemplo de teste após configuração: + +```bash +sudo python3 configure_msmtp.py --test +``` + +## Verificar `/etc/msmtprc` + +```bash +sudo ls -l /etc/msmtprc +sudo msmtp --version +# Conteúdo (sem partilhar publicamente): +# sudo cat /etc/msmtprc +``` + +Deve conter `tls_trust_file`, `account runv`, `account default : runv`, e se usar auth, `passwordeval` apontando para `/usr/local/lib/runv-email/netrc_password.py HOST`. + +## Verificar `/root/.netrc` + +```bash +sudo ls -l /root/.netrc # deve ser -rw------- root root +``` + +A linha `machine` deve ser **exactamente** o mesmo hostname que o campo `host` no msmtprc (o helper recebe esse host como argumento). + +## Verificar `sendmail` + +```bash +ls -l /usr/sbin/sendmail +readlink -f /usr/sbin/sendmail +``` + +Deve resolver para o binário **msmtp** (pacote `msmtp-mta`). + +## Testar envio + +1. `sudo python3 configure_msmtp.py --test` +2. Ou: `sudo sh scripts/send_test_mail.sh admin@seu-dominio` +3. Ou linha directa (Python), com `RUNV_EMAIL_ROOT`: + +```bash +sudo RUNV_EMAIL_ROOT=/caminho/runv-server/email python3 -c " +import sys +sys.path.insert(0, '/caminho/runv-server/email') +from lib.mailer import send_mail +send_mail('voce@exemplo.com', 'Teste', 'Corpo.', from_addr='noreply@exemplo.com') +" +``` + +## Aliases msmtp + +O ficheiro `/etc/msmtp_aliases` usa o formato **msmtp** (`local: email@externo`). **Não** é o mesmo que aliases Sendmail; **`newaliases` não aplica** aqui. Qualquer alteração: editar o ficheiro e manter coerência com a directiva `aliases` no `msmtprc`. + +## Checklist pós-instalação + +- [ ] Pacotes `msmtp`, `msmtp-mta`, `ca-certificates`, `bsd-mailx` instalados. +- [ ] `/usr/sbin/sendmail` → msmtp. +- [ ] Permissões 600 em `/etc/msmtprc` e `/root/.netrc`. +- [ ] Email de teste recebido. +- [ ] [INTEGRATION.md](INTEGRATION.md) lido se for integrar com `entre` ou scripts admin. + +Próximo: [ADMIN.md](ADMIN.md) para operação corrente. diff --git a/email/docs/INTEGRATION.md b/email/docs/INTEGRATION.md @@ -0,0 +1,127 @@ +# Integração — email com o resto do runv-server + +## Variável de ambiente + +Defina **`RUNV_EMAIL_ROOT`** como caminho absoluto para a pasta **`email/`** do repositório (a que contém `lib/` e `templates/`). + +```bash +export RUNV_EMAIL_ROOT=/srv/runv-server/email +``` + +Em Python, antes de importar: + +```python +import os +import sys +ROOT = "/srv/runv-server/email" +os.environ.setdefault("RUNV_EMAIL_ROOT", ROOT) +sys.path.insert(0, ROOT) +from lib.mailer import ( + send_mail, + send_admin_notice, + send_user_notice, + render_template, +) +from lib import templates as T +``` + +**Nunca** use `shell=True` em `subprocess` para envio; a biblioteca já invoca `sendmail` com lista de argumentos. + +## API resumida (`lib/mailer.py`) + +| Função | Uso | +|--------|-----| +| `render_template(nome, **kwargs)` | Lê `templates/<nome>.txt` e substitui `{placeholders}`. | +| `send_mail(to, subject, body, from_addr=..., sendmail=..., headers=...)` | Mensagem texto; `to` pode ser string ou lista. | +| `send_admin_notice(template, admin_email, subject=..., from_addr=..., **kwargs)` | Template → admin. | +| `send_user_notice(template, user_email, subject=..., from_addr=..., **kwargs)` | Template → utilizador. | + +`sendmail` por defeito: `/usr/sbin/sendmail` (msmtp-mta). + +## Mapa evento → template → script + +| Evento | Template(s) | Onde disparar | +|--------|-------------|----------------| +| Novo pedido na fila `entre` | `admin_new_request` → admin; opcional `user_request_received` → email do visitante | Após `save_request_json` em [`terminal/entre_core.py`](../../terminal/entre_core.py) / [`entre_app.py`](../../terminal/entre_app.py). **Hoje** só há email admin via `sendmail_notify` + `admin_mail.txt` — manter compatível ou migrar para templates deste módulo. | +| Pedido aprovado (manual) | `user_approved` | Processo admin / script que marca pedido aprovado. | +| Pedido rejeitado | `user_rejected` (+ `reason`) | Idem. | +| Conta criada | `admin_user_created`, `user_account_created` | Final com sucesso de [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py). | +| Conta removida | `admin_user_deleted`, `user_account_removed` | Final de [`scripts/admin/del-user.py`](../../scripts/admin/del-user.py) (se tiver email em metadados para o utilizador). | +| Erro operacional | `admin_error` | Blocos `except` em scripts admin ou cron. | +| Quota | `user_quota_warning` | Monitorização de disco / `update_user` / quotas. | +| Teste | `system_test` | `configure_msmtp.py --test` ou scripts de CI manual. | + +## Fluxo **entre** (terminal) + +- **Comportamento actual:** [`entre_core.sendmail_notify`](../../terminal/entre_core.py) envia corpo já montado a partir de `templates/admin_mail.txt`, via `sendmail -t -i`. +- **Compatibilidade:** Depois de configurar este módulo, `/usr/sbin/sendmail` passa a ser o msmtp — **nenhuma alteração obrigatória** no código `entre` se `sendmail_path` em `config.toml` for `/usr/sbin/sendmail`. +- **Opcional:** Unificar com `send_admin_notice(T.ADMIN_NEW_REQUEST, ...)` e placeholders alinhados ao JSON do pedido — exige refactor pequeno em `entre_app.py` e testes. + +### Exemplo (opcional) — notificação admin com template do módulo + +```python +# Pseudocódigo: após gravação do pedido +send_admin_notice( + T.ADMIN_NEW_REQUEST, + admin_email, + subject="[runv.club] Novo pedido", + from_addr=mail_from, + request_id=request_id, + timestamp=..., + username=username, + email=email, + fingerprint=fingerprint, +) +``` + +Para email ao **visitante** (`user_request_received`), é preciso endereço válido do utilizador (já recolhido no fluxo). + +## `create_runv_user.py` + +Após criação bem-sucedida da conta (e sabendo `email` metadado e `admin_email` de config ou estado): + +```python +send_admin_notice( + T.ADMIN_USER_CREATED, + admin_email, + subject="[runv.club] Conta criada", + from_addr=default_from, + username=username, + email=user_email, + operator_info="create_runv_user.py", + timestamp=str(int(time.time())), +) +send_user_notice( + T.USER_ACCOUNT_CREATED, + user_email, + subject="[runv.club] A sua conta", + from_addr=default_from, + username=username, + email=user_email, +) +``` + +Obtenha `admin_email` / `default_from` de `/etc/runv-email.json` ou de variáveis de ambiente definidas pelo operador — **não** hardcodar. + +## `del-user.py` + +Após remoção bem-sucedida: + +- `send_admin_notice(T.ADMIN_USER_DELETED, ...)` +- Se existir email de contacto em metadados: `send_user_notice(T.USER_ACCOUNT_REMOVED, ...)`. + +## Configuração paralela com `entre` + +| Ficheiro | Campos | +|----------|--------| +| `/etc/runv-email.json` | `admin_email`, `default_from` — estado global runv. | +| `/opt/runv/terminal/config.toml` | `admin_email`, `mail_from`, `sendmail_path` — fluxo entre. | + +Recomenda-se manter **o mesmo** `admin_email` e remetente coerente entre ambos. + +## Checklist de integração + +- [ ] `RUNV_EMAIL_ROOT` definido em cron/systemd que invoque scripts Python. +- [ ] `sendmail` = msmtp testado com `configure_msmtp.py --test`. +- [ ] Templates revistos (português, placeholders). +- [ ] Nenhum segredo em logs ou `print()`. diff --git a/email/docs/TROUBLESHOOTING.md b/email/docs/TROUBLESHOOTING.md @@ -0,0 +1,53 @@ +# Resolução de problemas — email runv.club + +## Autenticação SMTP falha + +- Confirme `user` no `msmtprc` e `login` no `.netrc` para o mesmo `machine <host>` que o **host** SMTP. +- Teste credenciais com outro cliente (mesmo host/porta/TLS) para isolar. +- Ver `/var/log/msmtp.log` (sem publicar conteúdo com dados sensíveis). + +## TLS / STARTTLS a falhar + +- Combinações típicas: porta **587** + `tls on` + `tls_starttls on`; porta **465** muitas vezes `tls on` + `tls_starttls off`. +- Confirme `tls_trust_file /etc/ssl/certs/ca-certificates.crt` e pacote `ca-certificates` instalado. + +## Erro de certificado + +- Relógio do sistema correcto (`timedatectl`). +- Se o servidor usar certificado não padrão, a política de msmtp pode exigir ajuste (documentação msmtp — fora do âmbito normal runv). + +## `sendmail` não encontrado + +- Instale `msmtp-mta`: `apt-get install -y msmtp-mta`. +- Verifique `ls -l /usr/sbin/sendmail`. + +## `mail` não funciona + +- Instale `bsd-mailx` (não confundir com ausência total de `mail`). +- Sem `sendmail` funcional, `mail` também falha. + +## Template ausente (`lib/mailer.py`) + +- Defina `RUNV_EMAIL_ROOT` para a pasta **`email/`** do repositório (que contém `templates/`). +- Ou execute scripts a partir da árvore completa do repositório. + +## Permissões em `/root/.netrc` + +- Deve ser **600** e dono **root**. Corrigir: `sudo chmod 600 /root/.netrc && sudo chown root:root /root/.netrc`. + +## Permissões em `/etc/msmtprc` + +- Recomendado **600** root. `msmtp` em modo system-wide exige que o ficheiro não seja legível por utilizadores não privilegiados. + +## `passwordeval` / `netrc_password.py` + +- Deve existir `/usr/local/lib/runv-email/netrc_password.py` executável. +- Reinstale com `configure_msmtp.py` ou copie manualmente desde `email/scripts/netrc_password.py`. + +## Senha com caracteres especiais no `.netrc` + +- O formato `.netrc` clássico **não** trata bem todos os caracteres; senhas muito complexas podem exigir escape ou outro método (ver documentação netrc). Em caso de dúvida, use token SMTP dedicado com caracteres seguros para ficheiros texto. + +## `--test` diz que falta estado + +- Corra primeiro `configure_msmtp.py` sem `--test` para criar `/etc/runv-email.json`. diff --git a/email/lib/__init__.py b/email/lib/__init__.py @@ -0,0 +1 @@ +"""Biblioteca de envio de email runv.club (sendmail/msmtp).""" diff --git a/email/lib/mailer.py b/email/lib/mailer.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Envio de email via interface sendmail compatível (msmtp-mta). + +Sem shell=True. Sem dependências PyPI — apenas stdlib. +""" + +from __future__ import annotations + +import os +import subprocess +from email.message import EmailMessage +from email.utils import formataddr +from pathlib import Path +from typing import Mapping, Sequence + +from . import templates as T + +_DEFAULT_SENDMAIL = "/usr/sbin/sendmail" + + +def _email_root() -> Path: + env = os.environ.get("RUNV_EMAIL_ROOT", "").strip() + if env: + return Path(env).resolve() + return Path(__file__).resolve().parents[1] + + +def templates_dir() -> Path: + return _email_root() / "templates" + + +def render_template(name: str, **kwargs: object) -> str: + """ + Lê templates/<name>.txt e substitui {chaves} pelos kwargs. + Chaves em falta deixam o placeholder visível (não falha silenciosamente). + """ + base = name.removesuffix(".txt") + path = templates_dir() / f"{base}.txt" + if not path.is_file(): + raise FileNotFoundError(f"template de email não encontrado: {path}") + text = path.read_text(encoding="utf-8") + str_kw = {k: str(v) for k, v in kwargs.items()} + try: + return text.format(**str_kw) + except KeyError as e: + raise KeyError(f"placeholder em falta no template {name}: {e}") from e + + +def send_mail( + to_addrs: str | Sequence[str], + subject: str, + body: str, + *, + from_addr: str, + sendmail: str = _DEFAULT_SENDMAIL, + headers: Mapping[str, str] | None = None, + timeout: int = 120, +) -> None: + """ + Envia mensagem texto puro via sendmail -t -i. + + :param to_addrs: um endereço ou lista de endereços (cabeçalho To). + :raises FileNotFoundError: sendmail inexistente. + :raises RuntimeError: sendmail devolveu código != 0. + """ + sm = Path(sendmail) + if not sm.is_file(): + raise FileNotFoundError(f"sendmail não encontrado: {sendmail}") + + if isinstance(to_addrs, str): + recipients: list[str] = [to_addrs.strip()] + else: + recipients = [a.strip() for a in to_addrs if a and str(a).strip()] + + if not recipients: + raise ValueError("lista de destinatários vazia") + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = ", ".join(recipients) + if headers: + for k, v in headers.items(): + if k.lower() in ("subject", "from", "to", "bcc", "cc"): + continue + msg[k] = v + msg.set_content(body, subtype="plain", charset="utf-8") + + try: + proc = subprocess.run( + [str(sm), "-t", "-i"], + input=msg.as_bytes(), + capture_output=True, + timeout=timeout, + ) + except OSError as e: + raise RuntimeError(f"erro ao executar sendmail: {e}") from e + except subprocess.TimeoutExpired as e: + raise RuntimeError("timeout ao executar sendmail") from e + + if proc.returncode != 0: + err = (proc.stderr or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"sendmail falhou (código {proc.returncode})" + (f": {err}" if err else "") + ) + + +def send_admin_notice( + template_name: str, + admin_email: str, + *, + subject: str, + from_addr: str, + sendmail: str = _DEFAULT_SENDMAIL, + **kwargs: object, +) -> None: + """Renderiza template administrativo e envia para admin_email.""" + body = render_template(template_name, **kwargs) + send_mail( + admin_email, + subject, + body, + from_addr=from_addr, + sendmail=sendmail, + ) + + +def send_user_notice( + template_name: str, + user_email: str, + *, + subject: str, + from_addr: str, + sendmail: str = _DEFAULT_SENDMAIL, + **kwargs: object, +) -> None: + """Renderiza template para utilizador e envia para user_email.""" + body = render_template(template_name, **kwargs) + send_mail( + user_email, + subject, + body, + from_addr=from_addr, + sendmail=sendmail, + ) + + +def format_from_display(name: str, addr: str) -> str: + """Cabeçalho From com nome amigável (opcional).""" + return formataddr((name, addr)) diff --git a/email/lib/templates.py b/email/lib/templates.py @@ -0,0 +1,41 @@ +""" +Nomes canónicos dos templates de email (texto puro) em templates/. + +Placeholders comuns: {username}, {email}, {request_id}, {admin_email}, +{default_from}, {host}, {reason}, {quota_info}, {timestamp}, {error_summary} +""" + +from __future__ import annotations + +from typing import Final + +# --- Admin --- +ADMIN_NEW_REQUEST: Final[str] = "admin_new_request" +ADMIN_USER_CREATED: Final[str] = "admin_user_created" +ADMIN_USER_DELETED: Final[str] = "admin_user_deleted" +ADMIN_ERROR: Final[str] = "admin_error" + +# --- Utilizador --- +USER_REQUEST_RECEIVED: Final[str] = "user_request_received" +USER_APPROVED: Final[str] = "user_approved" +USER_REJECTED: Final[str] = "user_rejected" +USER_ACCOUNT_CREATED: Final[str] = "user_account_created" +USER_QUOTA_WARNING: Final[str] = "user_quota_warning" +USER_ACCOUNT_REMOVED: Final[str] = "user_account_removed" + +# --- Sistema --- +SYSTEM_TEST: Final[str] = "system_test" + +ALL_TEMPLATES: Final[tuple[str, ...]] = ( + ADMIN_NEW_REQUEST, + ADMIN_USER_CREATED, + ADMIN_USER_DELETED, + ADMIN_ERROR, + USER_REQUEST_RECEIVED, + USER_APPROVED, + USER_REJECTED, + USER_ACCOUNT_CREATED, + USER_QUOTA_WARNING, + USER_ACCOUNT_REMOVED, + SYSTEM_TEST, +) diff --git a/email/scripts/diagnose_msmtp.sh b/email/scripts/diagnose_msmtp.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Diagnóstico rápido: pacotes, sendmail, msmtp, permissões, estado runv. +# Não imprime segredos. + +set -e +echo "=== Pacotes ===" +dpkg -l msmtp msmtp-mta ca-certificates bsd-mailx 2>/dev/null || true + +echo "" +echo "=== sendmail ===" +if [ -e /usr/sbin/sendmail ]; then + ls -l /usr/sbin/sendmail + readlink -f /usr/sbin/sendmail || true +else + echo "Falta /usr/sbin/sendmail" +fi + +echo "" +echo "=== msmtp ===" +command -v msmtp >/dev/null 2>&1 && msmtp --version || echo "msmtp não no PATH" + +echo "" +echo "=== Ficheiros de configuração ===" +for f in /etc/msmtprc /etc/msmtp_aliases /root/.netrc /etc/runv-email.json; do + if [ -f "$f" ]; then + ls -l "$f" + else + echo "(ausente) $f" + fi +done + +echo "" +echo "=== Log msmtp (últimas 15 linhas, se existir) ===" +if [ -f /var/log/msmtp.log ]; then + tail -n 15 /var/log/msmtp.log +else + echo "(sem /var/log/msmtp.log)" +fi + +echo "" +echo "=== passwordeval helper ===" +if [ -f /usr/local/lib/runv-email/netrc_password.py ]; then + ls -l /usr/local/lib/runv-email/netrc_password.py +else + echo "(ausente) /usr/local/lib/runv-email/netrc_password.py" +fi diff --git a/email/scripts/netrc_password.py b/email/scripts/netrc_password.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +Lê a palavra-passe SMTP de /root/.netrc para a máquina indicada (argv[1]). + +Usado por msmtp passwordeval. Executar apenas como root; saída só na stdout. +Código de saída != 0 se não encontrar entrada. +""" +from __future__ import annotations + +import netrc +import sys +from pathlib import Path + +NETRC_PATH = Path("/root/.netrc") + + +def main() -> int: + if len(sys.argv) != 2: + return 2 + host = sys.argv[1].strip() + if not host: + return 2 + if not NETRC_PATH.is_file(): + return 1 + try: + n = netrc.netrc(str(NETRC_PATH)) + tup = n.authenticators(host) + if not tup: + return 1 + _login, _account, password = tup + if not password: + return 1 + sys.stdout.write(password) + sys.stdout.flush() + except (netrc.NetrcParseError, OSError): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/email/scripts/send_test_mail.sh b/email/scripts/send_test_mail.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Envia um email de teste mínimo via mail(1) -> sendmail (msmtp). +# Uso: ./send_test_mail.sh destino@exemplo.com +# Requer: bsd-mailx, msmtp-mta (sendmail). + +set -e +if [ -z "${1:-}" ]; then + echo "Uso: $0 destino@exemplo.com" >&2 + exit 1 +fi +DEST="$1" +SUBJ="[runv.club] Teste send_test_mail.sh" +BODY="Mensagem de teste gerada em $(date -u +%Y-%m-%dT%H:%M:%SZ)." + +if ! command -v mail >/dev/null 2>&1; then + echo "Comando 'mail' não encontrado. Instale bsd-mailx." >&2 + exit 1 +fi + +printf '%s\n' "$BODY" | mail -s "$SUBJ" "$DEST" +echo "Pedido de envio feito para $DEST (verifique caixa e /var/log/msmtp.log)." diff --git a/email/templates/admin_error.txt b/email/templates/admin_error.txt @@ -0,0 +1,9 @@ +runv.club — alerta de erro (admin) + +Ocorreu um erro que requer atenção administrativa. + +Resumo: {error_summary} +Contexto: {context} +Timestamp: {timestamp} + +Verifique logs do serviço e o estado do sistema. diff --git a/email/templates/admin_new_request.txt b/email/templates/admin_new_request.txt @@ -0,0 +1,13 @@ +runv.club — novo pedido de entrada + +Foi recebido um novo pedido através do fluxo «entre». + +ID do pedido: {request_id} +Data (UTC): {timestamp} + +Nome de utilizador desejado: {username} +Email de contacto: {email} +Fingerprint SHA256 da chave: {fingerprint} + +--- +Este aviso foi gerado automaticamente. Consulte a fila em /var/lib/runv/entre-queue/ diff --git a/email/templates/admin_user_created.txt b/email/templates/admin_user_created.txt @@ -0,0 +1,9 @@ +runv.club — conta de utilizador criada + +Foi criada uma conta Unix runv para o seguinte utilizador: + +Utilizador: {username} +Email (metadado): {email} +Operador/script: {operator_info} + +Timestamp: {timestamp} diff --git a/email/templates/admin_user_deleted.txt b/email/templates/admin_user_deleted.txt @@ -0,0 +1,7 @@ +runv.club — conta de utilizador removida + +Uma conta foi removida do sistema. + +Utilizador: {username} +Operador/script: {operator_info} +Timestamp: {timestamp} diff --git a/email/templates/system_test.txt b/email/templates/system_test.txt @@ -0,0 +1,10 @@ +runv.club — email de teste + +Este é um email automático de verificação do subsistema de envio. + +Administrador: {admin_email} +Remetente configurado: {default_from} +Host SMTP registado: {host} +Timestamp UNIX: {timestamp} + +Se recebeu esta mensagem, msmtp/sendmail e a biblioteca estão operacionais. diff --git a/email/templates/user_account_created.txt b/email/templates/user_account_created.txt @@ -0,0 +1,12 @@ +runv.club — a sua conta foi criada + +Olá {username}, + +A sua conta no runv.club foi criada. + +Email associado (metadado administrativo): {email} + +Instruções de acesso (SSH, palavra-passe inicial, etc.) seguem a política do servidor — consulte a documentação ou a mensagem que lhe foi enviada separadamente. + +Cumprimentos, +Equipa runv.club diff --git a/email/templates/user_account_removed.txt b/email/templates/user_account_removed.txt @@ -0,0 +1,10 @@ +runv.club — conta encerrada + +Olá, + +A conta associada ao utilizador {username} foi removida do runv.club. + +Se não esperava esta mensagem, contacte a administração. + +Cumprimentos, +Equipa runv.club diff --git a/email/templates/user_approved.txt b/email/templates/user_approved.txt @@ -0,0 +1,10 @@ +runv.club — pedido aprovado + +Olá, + +O seu pedido de entrada (referência {request_id}) foi aprovado pela administração. + +Os próximos passos ser-lhe-ão comunicados por email ou pelo processo habitual do serviço. + +Cumprimentos, +Equipa runv.club diff --git a/email/templates/user_quota_warning.txt b/email/templates/user_quota_warning.txt @@ -0,0 +1,12 @@ +runv.club — aviso de quota + +Olá {username}, + +O seu uso de disco aproxima-se ou excedeu o limite configurado. + +Detalhes: {quota_info} + +Liberte espaço ou contacte a administração se precisar de assistência. + +Cumprimentos, +Equipa runv.club diff --git a/email/templates/user_rejected.txt b/email/templates/user_rejected.txt @@ -0,0 +1,13 @@ +runv.club — pedido não aprovado + +Olá, + +O seu pedido de entrada (referência {request_id}) não foi aprovado. + +Motivo indicado pela administração (se aplicável): +{reason} + +Para mais esclarecimentos, contacte a administração pelo canal habitual. + +Cumprimentos, +Equipa runv.club diff --git a/email/templates/user_request_received.txt b/email/templates/user_request_received.txt @@ -0,0 +1,13 @@ +runv.club — pedido recebido + +Olá, + +O seu pedido de entrada foi registado com sucesso. + +Referência: {request_id} +Nome de utilizador pedido: {username} + +A equipa irá analisar o pedido. Não é necessário reenviar a mesma informação várias vezes. + +Cumprimentos, +{default_from} diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -0,0 +1,1422 @@ +#!/usr/bin/env python3 +""" +Ferramenta interna de administração: provisiona contas Unix no runv.club (Debian). + +Contrato de provisionamento (ordem garantida após validação): + +1. **Criar o usuário** — ``adduser --disabled-password``. +2. **Instalar a chave** — ``~/.ssh/authorized_keys`` com modos ``700`` / ``600``. +3. **Preparar public_html** — diretório ``755``, ``index.html`` estático ``644``. +4. **Copiar o skel** — o Debian copia ``/etc/skel`` para a home **durante** o passo 1; depois, + após ``public_html``, o script acrescenta ``README.md`` runv (português), sem apagar o que + veio do skel (use ``--force-readme`` para substituir). Prepare ``/etc/skel`` com ``skel.py`` + antes das contas, se for política do servidor. +5. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, site e README com modos + e donos corretos, antes da quota e da verificação final. + +Quota ext4, metadados JSON e logging seguem após estes passos. + +É a **fonte principal** da política de provisionamento — sem depender de ``adduser.local``, +``QUOTAUSER`` ou regras espalhadas em ``/etc/adduser.conf``. + +Não é signup público: executar manualmente como root/sudo no servidor. +Requer Linux (Debian). Quota: ext4 com ``usrquota``/``usrjquota`` via ``setquota`` (não altera fstab). + +Versão 0.01 — desenvolvido por pmurad, 2026. +""" + +from __future__ import annotations + +import argparse +import fcntl +import getpass +import json +import logging +import os +import pwd +import re +import shutil +import stat as statmod +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Final, NoReturn + +# Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script não entra em sys.path; +# necessário para «from runv_mount» dentro das funções de quota/mount. +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") + +# Email pragmático (não RFC completo) +EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$" +) + +RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( + { + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "_apt", + "nobody", + "admin", + "postmaster", + } +) + +ALLOWED_KEY_TYPES: Final[tuple[str, ...]] = ( + "ssh-ed25519", + "sk-ssh-ed25519@openssh.com", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-rsa", +) + +FINGERPRINT_SHA256_RE: Final[re.Pattern[str]] = re.compile(r"\b(SHA256:[+A-Za-z0-9/_=-]+)\b") + +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" + +# Quota ext4 (valores padrão runv; limites em MiB = 1024² bytes → setquota usa kiB de 1024 B) +DEFAULT_QUOTA_SOFT_MIB: Final[int] = 450 +DEFAULT_QUOTA_HARD_MIB: Final[int] = 500 +DEFAULT_QUOTA_INODE_SOFT: Final[int] = 10_000 +DEFAULT_QUOTA_INODE_HARD: Final[int] = 12_000 + +VERSION: Final[str] = "0.01" +AUTHOR: Final[str] = "pmurad" +COPYRIGHT_YEAR: Final[str] = "2026" + +EXIT_OK: Final[int] = 0 +EXIT_VALIDATION: Final[int] = 1 +EXIT_SYSTEM: Final[int] = 2 +EXIT_INCONSISTENT: Final[int] = 3 + + +class ProvisionError(Exception): + """Erro genérico de provisionamento.""" + + +class ValidationError(ProvisionError): + """Entrada ou estado inválido (exit 1).""" + + +class SystemProvisionError(ProvisionError): + """Falha de sistema/subprocess (exit 2).""" + + +class QuotaNotAvailableError(ValidationError): + """Sistema de quotas não preparado (ext4 usrquota ausente, ferramentas, etc.).""" + + +# --------------------------------------------------------------------------- +# Validação de username / email +# --------------------------------------------------------------------------- + + +def validate_username(username: str) -> str: + """ + Valida username conservador; rejeita vazio, reservados e contas existentes. + Retorna o username normalizado (sem espaços). + """ + if username is None or username == "": + raise ValidationError("username é obrigatório") + if username != username.strip(): + raise ValidationError("username não pode ter espaços no início ou fim") + u = username.strip() + if not USERNAME_PATTERN.fullmatch(u): + raise ValidationError( + "username inválido: use apenas letras minúsculas, dígitos, _ e -; " + "comece com letra; comprimento total 2–32 caracteres" + ) + if u in RESERVED_USERNAMES: + raise ValidationError(f"username reservado ou perigoso: {u!r}") + try: + pwd.getpwnam(u) + except KeyError: + pass + else: + raise ValidationError(f"usuário já existe no sistema: {u!r}") + return u + + +def validate_email(email: str) -> str: + if email is None or email == "": + raise ValidationError("email é obrigatório") + if email != email.strip(): + raise ValidationError("email não pode ter espaços no início ou fim") + e = email.strip() + if not EMAIL_PATTERN.fullmatch(e): + raise ValidationError("formato de email inválido") + return e + + +# --------------------------------------------------------------------------- +# Chave pública OpenSSH +# --------------------------------------------------------------------------- + + +def normalize_public_key(raw: str) -> str: + """ + Aceita uma única linha OpenSSH authorized_keys. + Rejeita newlines internos e normaliza espaços internos de forma segura. + """ + if raw is None or raw == "": + raise ValidationError("public_key é obrigatória") + if "\n" in raw or "\r" in raw: + raise ValidationError("public_key deve ser uma única linha (sem quebras de linha)") + if raw != raw.strip(): + raise ValidationError("public_key não pode ter espaços extras no início ou fim") + line = raw.strip() + if not line or line.isspace(): + raise ValidationError("public_key vazia") + parts = line.split() + if len(parts) < 2: + raise ValidationError("public_key malformada (esperado: tipo, dados-base64, [comentário])") + key_type = parts[0] + if key_type not in ALLOWED_KEY_TYPES: + raise ValidationError( + f"tipo de chave não permitido: {key_type!r}; permitidos: {', '.join(ALLOWED_KEY_TYPES)}" + ) + # Uma linha: tipo + blob base64 + comentário opcional (pode conter espaços) + blob = parts[1] + comment = parts[2:] if len(parts) > 2 else [] + if not re.fullmatch(r"[A-Za-z0-9+/]+=*", blob): + raise ValidationError("dados da chave pública (base64) inválidos") + normalized = key_type + " " + blob + if comment: + normalized += " " + " ".join(comment) + return normalized + + +def compute_public_key_fingerprint(public_key_line: str, tmp_dir: Path | None = None) -> str: + """ + Calcula fingerprint no formato OpenSSH SHA256 (ex.: SHA256:...). + Usa `ssh-keygen -lf -E sha256` (requer pacote openssh-client no Debian). + """ + line = normalize_public_key(public_key_line) + fd, tmppath = tempfile.mkstemp(prefix="runv-key-", suffix=".pub", dir=tmp_dir) + path = Path(tmppath) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(line + "\n") + proc = subprocess.run( + ["ssh-keygen", "-l", "-E", "sha256", "-f", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + raise ValidationError(f"chave pública rejeitada pelo ssh-keygen: {err}") + out = (proc.stdout or "").strip().splitlines() + if not out: + raise SystemProvisionError("ssh-keygen não devolveu saída") + first = out[0] + m = FINGERPRINT_SHA256_RE.search(first) + if not m: + raise SystemProvisionError(f"não foi possível extrair SHA256 da saída: {first!r}") + return m.group(1) + finally: + try: + path.unlink(missing_ok=True) + except OSError: + pass + + +def validate_public_key(public_key_line: str, tmp_dir: Path | None = None) -> tuple[str, str]: + """ + Valida e normaliza a chave; retorna (linha_normalizada, fingerprint_sha256). + """ + normalized = normalize_public_key(public_key_line) + fp = compute_public_key_fingerprint(normalized, tmp_dir=tmp_dir) + return normalized, fp + + +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") + if pub: + return pub + if pub_file: + text = pub_file.read_text(encoding="utf-8") + if len(text.splitlines()) > 1: + raise ValidationError("arquivo de chave deve conter uma única linha") + line = text.strip() + return line + raise ValidationError("forneça --public-key ou --public-key-file") + + +# --------------------------------------------------------------------------- +# Caminhos seguros sob /home +# --------------------------------------------------------------------------- + + +def home_directory(username: str) -> Path: + p = Path(f"/home/{username}").resolve() + home_root = Path("/home").resolve() + try: + p.relative_to(home_root) + except ValueError as e: + raise ValidationError("caminho home inválido") from e + if p.name != username: + raise ValidationError("inconsistência no nome do diretório home") + return p + + +# --------------------------------------------------------------------------- +# SSH authorized_keys +# --------------------------------------------------------------------------- + + +def install_authorized_keys( + home: Path, + uid: int, + gid: int, + public_key_line: str, + log: logging.Logger, +) -> None: + """Cria ~/.ssh/authorized_keys com permissões corretas.""" + ssh_dir = home / ".ssh" + auth = ssh_dir / "authorized_keys" + line = normalize_public_key(public_key_line) + + ssh_dir.mkdir(parents=True, exist_ok=True) + os.chmod(ssh_dir, 0o700) + try: + os.chown(ssh_dir, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {ssh_dir}: {e}") from e + + if auth.exists(): + existing = auth.read_text(encoding="utf-8") + if line in existing.splitlines(): + log.info("authorized_keys já continha esta chave; nada a acrescentar") + else: + with open(auth, "a", encoding="utf-8") as f: + f.write(line + "\n") + else: + auth.write_text(line + "\n", encoding="utf-8") + + os.chmod(auth, 0o600) + try: + os.chown(auth, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {auth}: {e}") from e + + +# --------------------------------------------------------------------------- +# public_html +# --------------------------------------------------------------------------- + + +def default_index_html(username: str) -> str: + """HTML estático mínimo: sem JavaScript, sem CDN, sem conteúdo dinâmico.""" + return f"""<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>~{username} no runv.club</title> +</head> +<body> + <h1>Olá, ~{username}</h1> + <p>Bem-vindo(a) ao runv.club — espaço pubnix com shell e site pessoal.</p> + <p>Edite este ficheiro em <code>~/public_html/index.html</code> (ficheiros estáticos apenas).</p> + <p>Leia também <code>~/README.md</code> na shell para instruções e permissões.</p> +</body> +</html> +""" + + +def default_readme_md(username: str, base_url: str) -> str: + """Texto de ajuda inicial em português (política runv.club).""" + base = base_url.rstrip("/") + user_url = f"{base}/~{username}/" + return f"""# Bem-vindo(a) ao runv.club + +O **runv.club** é um servidor partilhado (pubnix): tens acesso por **SSH com chave** +e uma **página web pessoal** servida pelo Apache com `mod_userdir`. + +## A tua página pessoal + +- Ficheiros públicos ficam em **`~/public_html/`**. +- A página principal é **`~/public_html/index.html`** (HTML estático; sem PHP obrigatório nesta fase). +- A URL pública é: + + **{user_url}** + +Edita o HTML com o teu editor na shell (ex.: `nano ~/public_html/index.html`). + +## Permissões recomendadas + +| Local | Modo | Notas | +|-------|------|--------| +| A tua home (`~`) | `755` | O Apache precisa de atravessar a home para chegar a `public_html`. | +| `~/public_html` | `755` | Diretório listável pelo servidor web. | +| Ficheiros do site | `644` | Ficheiros normais dentro de `public_html`. | +| `~/.ssh` | `700` | Só o teu utilizador deve aceder. | +| `~/.ssh/authorized_keys` | `600` | Chaves SSH autorizadas. | + +Se alterares permissões e o site deixar de abrir, volta a `755` na home e em `public_html`, +e `644` nos ficheiros servidos. + +## Ficheiros públicos + +Tudo o que colocares em **`public_html`** pode ser lido pelo mundo via HTTP no endereço +`~{username}/...`. Não coloques aí segredos, chaves privadas nem dados sensíveis. + +## Comandos úteis na shell + +```bash +pwd # diretório atual +ls -la # listar com detalhes +cd ~/public_html # ir à pasta do site +mkdir -p ~/public_html/img # criar subpastas +chmod 755 ~ ~/public_html +chmod 644 ~/public_html/index.html +``` + +Documentação do projeto (admin): repositório **runv-server**, script `create_runv_user.py`. + +— Equipa runv.club +""" + + +def prepare_public_html( + home: Path, + username: str, + uid: int, + gid: int, + force_index: bool, + log: logging.Logger, +) -> None: + pub = home / "public_html" + pub.mkdir(parents=True, exist_ok=True) + os.chmod(pub, 0o755) + try: + os.chown(pub, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {pub}: {e}") from e + + index = pub / "index.html" + if index.exists() and not force_index: + log.info("%s já existe; não sobrescrevendo (use --force-index)", index) + return + if index.exists() and force_index: + log.warning("sobrescrevendo %s (--force-index)", index) + index.write_text(default_index_html(username), encoding="utf-8") + os.chmod(index, 0o644) + try: + os.chown(index, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {index}: {e}") from e + + +def prepare_user_readme( + home: Path, + username: str, + uid: int, + gid: int, + base_url: str, + force_readme: bool, + log: logging.Logger, +) -> None: + """Garante ~/README.md com texto de ajuda em português (não sobrescreve sem --force-readme).""" + readme = home / "README.md" + if readme.exists() and not force_readme: + log.info("%s já existe; não sobrescrevendo (use --force-readme)", readme) + return + if readme.exists() and force_readme: + log.warning("sobrescrevendo %s (--force-readme)", readme) + readme.write_text(default_readme_md(username, base_url), encoding="utf-8") + os.chmod(readme, 0o644) + try: + os.chown(readme, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {readme}: {e}") from e + + +# --------------------------------------------------------------------------- +# Metadados JSON +# --------------------------------------------------------------------------- + + +@dataclass +class UserRecord: + username: str + email: str + public_key_fingerprint: str + created_at: str + created_by: str + home_directory: str + status: str + quota_enabled: bool + quota_soft_mb: int | None + quota_hard_mb: int | None + quota_inode_soft: int | None + quota_inode_hard: int | None + quota_filesystem: str | None + quota_mountpoint: str | None + quota_applied_at: str | None + quota_status: str + + def to_dict(self) -> dict[str, Any]: + return { + "username": self.username, + "email": self.email, + "public_key_fingerprint": self.public_key_fingerprint, + "created_at": self.created_at, + "created_by": self.created_by, + "home_directory": self.home_directory, + "status": self.status, + "quota_enabled": self.quota_enabled, + "quota_soft_mb": self.quota_soft_mb, + "quota_hard_mb": self.quota_hard_mb, + "quota_inode_soft": self.quota_inode_soft, + "quota_inode_hard": self.quota_inode_hard, + "quota_filesystem": self.quota_filesystem, + "quota_mountpoint": self.quota_mountpoint, + "quota_applied_at": self.quota_applied_at, + "quota_status": self.quota_status, + } + + +def append_user_metadata( + metadata_path: Path, + lock_path: Path, + record: UserRecord, + log: logging.Logger, +) -> None: + """ + Acrescenta registro a uma lista JSON com lock (flock) e escrita atômica. + """ + metadata_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.parent.mkdir(parents=True, exist_ok=True) + + lock_f = open(lock_path, "a+", encoding="utf-8") + try: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) + data: list[dict[str, Any]] + if metadata_path.exists(): + raw = metadata_path.read_text(encoding="utf-8").strip() + if not raw: + data = [] + else: + parsed = json.loads(raw) + if not isinstance(parsed, list): + raise SystemProvisionError(f"formato inválido em {metadata_path}: esperado lista JSON") + data = parsed + else: + data = [] + for item in data: + if isinstance(item, dict) and item.get("username") == record.username: + raise ValidationError(f"username já registrado em metadados: {record.username!r}") + data.append(record.to_dict()) + tmp_fd, tmp_name = tempfile.mkstemp( + prefix="users.", + suffix=".tmp", + dir=str(metadata_path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as out: + json.dump(data, out, indent=2, ensure_ascii=False) + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_path, metadata_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + log.info("metadados gravados em %s", metadata_path) + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + lock_f.close() + + +# --------------------------------------------------------------------------- +# adduser / rollback +# --------------------------------------------------------------------------- + + +def run_adduser(username: str, log: logging.Logger) -> None: + env = os.environ.copy() + env["DEBIAN_FRONTEND"] = "noninteractive" + env["LC_ALL"] = "C" + log.info("executando adduser --disabled-password para %r", username) + try: + proc = subprocess.run( + ["adduser", "--disabled-password", "--gecos", "", username], + capture_output=True, + text=True, + env=env, + timeout=120, + ) + except FileNotFoundError as e: + raise SystemProvisionError("comando adduser não encontrado (instale o pacote adduser)") from e + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + detail = f": {err}" if err else "" + log.error("adduser stderr/stdout: %s", err or "(vazio)") + raise SystemProvisionError(f"adduser falhou (código {proc.returncode}){detail}") + + +def run_deluser_remove_home(username: str, log: logging.Logger) -> bool: + """Remove usuário e home. Retorna True se sucesso.""" + log.warning("rollback: removendo usuário %r com deluser --remove-home", username) + try: + r = subprocess.run( + ["deluser", "--remove-home", username], + capture_output=True, + text=True, + timeout=120, + ) + if r.returncode != 0: + log.error("deluser stderr: %s", r.stderr) + return False + return True + except FileNotFoundError: + log.error("deluser não encontrado") + return False + + +def apply_runv_permissions(home: Path, uid: int, gid: int) -> None: + """ + Aplica modos e donos esperados na home e nos artefactos runv (passo 5 do contrato). + + Deve ser chamado após criar o utilizador, chave SSH, ``public_html`` e ``README.md``, + para garantir home ``755`` (Apache atravessa até ``public_html``), ``.ssh`` ``700``, + ``authorized_keys`` ``600``, site ``755``/``644``. + """ + try: + os.chmod(home, 0o755) + os.chown(home, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {home}: {e}") from e + + ssh_dir = home / ".ssh" + if ssh_dir.is_dir(): + try: + os.chmod(ssh_dir, 0o700) + os.chown(ssh_dir, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {ssh_dir}: {e}") from e + auth = ssh_dir / "authorized_keys" + if auth.is_file(): + try: + os.chmod(auth, 0o600) + os.chown(auth, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {auth}: {e}") from e + + pub = home / "public_html" + if pub.is_dir(): + try: + os.chmod(pub, 0o755) + os.chown(pub, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {pub}: {e}") from e + index = pub / "index.html" + if index.is_file(): + try: + os.chmod(index, 0o644) + os.chown(index, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {index}: {e}") from e + + readme = home / "README.md" + if readme.is_file(): + try: + os.chmod(readme, 0o644) + os.chown(readme, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {readme}: {e}") from e + + +def verify_user_artifact_permissions(home: Path, uid: int, gid: int) -> None: + """ + Confirma existência, dono e modos esperados após o provisionamento (falha explícita se algo estiver errado). + """ + checks: list[tuple[Path, int, str]] = [ + (home, 0o755, "home"), + (home / ".ssh", 0o700, ".ssh"), + (home / ".ssh" / "authorized_keys", 0o600, "authorized_keys"), + (home / "public_html", 0o755, "public_html"), + (home / "public_html" / "index.html", 0o644, "index.html"), + (home / "README.md", 0o644, "README.md"), + ] + for path, want_mode, label in checks: + if not path.exists(): + raise SystemProvisionError(f"em falta após provisionamento ({label}): {path}") + st = path.stat() + if st.st_uid != uid or st.st_gid != gid: + raise SystemProvisionError( + f"donos incorretos em {path} ({label}): esperado uid/gid {uid}/{gid}, " + f"obtido {st.st_uid}/{st.st_gid}" + ) + got = statmod.S_IMODE(st.st_mode) + if got != want_mode: + raise SystemProvisionError( + f"permissões incorretas em {path} ({label}): {oct(got)} (esperado {oct(want_mode)})" + ) + + +# --------------------------------------------------------------------------- +# Quota ext4 (setquota, usrquota) +# --------------------------------------------------------------------------- + + +def quota_probe_path(home: Path) -> Path: + """ + Caminho existente no disco para descobrir o mount (antes de adduser, /home/user pode não existir). + Sobe até encontrar um diretório existente (tipicamente /home ou /). + """ + p = home + while True: + try: + if p.exists(): + return p.resolve() + except OSError: + pass + if p == p.parent: + return Path("/").resolve() + p = p.parent + + +def mib_to_setquota_kib(mib: int) -> int: + """ + Converte **MiB** (mebibytes = 1024² bytes) para as unidades de **blocos** do setquota + em filesystems ext4 (vfsv0): cada unidade conta **1024 bytes** (1 KiB). + + Ex.: 450 MiB → 450 × 1024 = 460_800 (KiB de espaço contabilizado pelo quota). + """ + if mib < 0: + raise ValidationError("quota em MiB não pode ser negativa") + return mib * 1024 + + +def validate_quota_limits( + soft_mib: int, + hard_mib: int, + inode_soft: int, + inode_hard: int, +) -> None: + if soft_mib > hard_mib: + raise ValidationError( + f"quota blocos: soft ({soft_mib} MiB) não pode exceder hard ({hard_mib} MiB)" + ) + if inode_soft > inode_hard: + raise ValidationError( + f"quota inodes: soft ({inode_soft}) não pode exceder hard ({inode_hard})" + ) + + +def find_mount_for_path(path: Path) -> tuple[str, str, str]: + """ + Retorna (target_canonical, fstype, options_csv) para o filesystem que contém path. + Implementação partilhada: ``runv_mount.find_mount_triple``. + """ + from runv_mount import MountLookupError, find_mount_triple + + try: + return find_mount_triple(path) + except MountLookupError as e: + raise SystemProvisionError(str(e)) from e + + +def mount_options_allow_user_quota(options: str) -> bool: + """True se usrquota ou usrjquota= (ext4 com quota em journal) está ativo.""" + from runv_mount import quota_opts_allow_user + + return quota_opts_allow_user(options) + + +def ensure_setquota_available() -> str: + """Caminho do executável setquota ou levanta SystemProvisionError.""" + p = shutil.which("setquota") + if not p: + raise QuotaNotAvailableError( + "comando 'setquota' não encontrado — instale o pacote Debian 'quota' " + "(ex.: apt install quota)" + ) + return p + + +def preflight_quota_for_home( + home: Path, + log: logging.Logger, +) -> tuple[str, str, str]: + """ + Verifica ext4 + usrquota no mount da home (ou ascendente). + Retorna (mountpoint, fstype, options). + """ + log.info("quota: início da verificação (pré-voo)") + probe = quota_probe_path(home) + log.info("quota: path de sonda para findmnt: %s", probe) + target, fstype, opts = find_mount_for_path(probe) + log.info("quota: mountpoint=%s fstype=%s options=%s", target, fstype, opts) + if fstype != "ext4": + raise QuotaNotAvailableError( + f"quota runv: só ext4 com quota tradicional é suportado neste script; " + f"encontrado fstype={fstype!r} em {target!r}" + ) + if not mount_options_allow_user_quota(opts): + raise QuotaNotAvailableError( + f"quota de utilizador não está ativa no mount {target!r}: " + f"opções atuais não incluem usrquota nem usrjquota=. " + f"Ajuste /etc/fstab (usrquota), remonte, quotacheck e quotaon — " + f"o script não altera fstab nem montagens." + ) + ensure_setquota_available() + log.info("quota: pré-voo OK (ext4 + usrquota/usrjquota + setquota)") + return target, fstype, opts + + +def run_setquota_user( + username: str, + mountpoint: str, + block_soft_kib: int, + block_hard_kib: int, + inode_soft: int, + inode_hard: int, + log: logging.Logger, +) -> None: + """Aplica limites com setquota -u (lista de argumentos, sem shell).""" + cmd = [ + "setquota", + "-u", + username, + str(block_soft_kib), + str(block_hard_kib), + str(inode_soft), + str(inode_hard), + mountpoint, + ] + log.info("quota: executando %s", " ".join(cmd)) + try: + r = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + except FileNotFoundError as e: + raise SystemProvisionError("setquota desapareceu do PATH durante a execução") from e + + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise SystemProvisionError( + f"setquota falhou (código {r.returncode})" + (f": {err}" if err else "") + ) + log.info("quota: setquota concluído com sucesso para %r em %r", username, mountpoint) + + +@dataclass +class QuotaResult: + """Estado da etapa de quota para metadados e saída.""" + + enabled: bool + soft_mib: int | None + hard_mib: int | None + inode_soft: int | None + inode_hard: int | None + filesystem: str | None + mountpoint: str | None + applied_at: str | None + status: str # skipped | applied | failed | not_configured + + +def try_apply_quota( + username: str, + home: Path, + soft_mib: int, + hard_mib: int, + inode_soft: int, + inode_hard: int, + log: logging.Logger, +) -> QuotaResult: + """ + Tenta aplicar quota após o utilizador existir. Não remove o utilizador em caso de falha. + """ + try: + target, fstype, _opts = preflight_quota_for_home(home, log) + except QuotaNotAvailableError as e: + log.error("quota indisponível: %s", e) + return QuotaResult( + enabled=True, + soft_mib=soft_mib, + hard_mib=hard_mib, + inode_soft=inode_soft, + inode_hard=inode_hard, + filesystem=None, + mountpoint=None, + applied_at=None, + status="not_configured", + ) + + try: + bs = mib_to_setquota_kib(soft_mib) + bh = mib_to_setquota_kib(hard_mib) + run_setquota_user(username, target, bs, bh, inode_soft, inode_hard, log) + except (SystemProvisionError, ValidationError) as e: + log.error("quota falhou ao aplicar: %s", e) + return QuotaResult( + enabled=True, + soft_mib=soft_mib, + hard_mib=hard_mib, + inode_soft=inode_soft, + inode_hard=inode_hard, + filesystem=fstype, + mountpoint=target, + applied_at=None, + status="failed", + ) + + now = datetime.now(timezone.utc).isoformat() + return QuotaResult( + enabled=True, + soft_mib=soft_mib, + hard_mib=hard_mib, + inode_soft=inode_soft, + inode_hard=inode_hard, + filesystem=fstype, + mountpoint=target, + applied_at=now, + status="applied", + ) + + +# --------------------------------------------------------------------------- +# CLI e main +# --------------------------------------------------------------------------- + + +def print_banner() -> None: + print() + print(" create_runv_user — provisionamento runv.club") + print(f" versão {VERSION}") + print(f" desenvolvido por {AUTHOR} — {COPYRIGHT_YEAR}") + print() + + +def prompt_yes_no(pergunta: str, default_no: bool = True) -> bool: + suf = " [s/N]: " if default_no else " [S/n]: " + r = input(pergunta + suf).strip().lower() + if not r: + return not default_no + return r in ("s", "sim", "y", "yes") + + +def interactive_fill(args: argparse.Namespace) -> None: + """Preenche args a partir de perguntas no terminal.""" + print_banner() + print("Modo interativo — responda às perguntas (Ctrl+C para cancelar).\n") + + while True: + u = input("Nome de usuário Unix (minúsculas, ex.: maria): ").strip() + if u: + args.username = u + break + print(" (obrigatório)") + + while True: + e = input("Email administrativo (metadado, ex.: maria@example.com): ").strip() + if e: + args.email = e + break + print(" (obrigatório)") + + print() + print("Chave pública SSH (OpenSSH, uma linha).") + modo = input(" (1) colar a linha agora (2) ler de arquivo .pub [1]: ").strip() or "1" + if modo == "2": + while True: + caminho = input(" Caminho absoluto do arquivo .pub: ").strip() + if not caminho: + print(" (obrigatório)") + continue + p = Path(caminho).expanduser() + if not p.is_file(): + print(f" Arquivo não encontrado: {p}") + continue + args.public_key = None + args.public_key_file = p + break + else: + while True: + print(" Cole a linha completa (ssh-ed25519 AAAA... ou ssh-rsa ...):") + linha = input(" > ").strip() + if linha: + args.public_key = linha + args.public_key_file = None + break + print(" (obrigatório)") + + print() + args.dry_run = prompt_yes_no("Apenas validar (dry-run), sem criar usuário?", default_no=True) + if not args.dry_run: + args.force_index = prompt_yes_no( + "Se já existir ~/public_html/index.html, sobrescrever (--force-index)?", + default_no=True, + ) + args.force_readme = prompt_yes_no( + "Se já existir ~/README.md, sobrescrever (--force-readme)?", + default_no=True, + ) + else: + args.force_index = False + args.force_readme = False + + args.verbose = prompt_yes_no("Log verboso no terminal?", default_no=True) + + if not args.dry_run: + if prompt_yes_no("Criar utilizador sem quota de disco (--no-quota)?", default_no=True): + args.no_quota = True + if not args.no_quota: + if prompt_yes_no( + "Abortar se quota ext4 não estiver pronta antes de criar (--require-quota)?", + default_no=True, + ): + args.require_quota = True + + print() + conf = input("Confirmar e continuar? [S/n]: ").strip().lower() + if conf in ("n", "nao", "não", "no"): + print("Cancelado.") + raise SystemExit(EXIT_VALIDATION) + + +def setup_logging(log_path: Path, verbose: bool) -> logging.Logger: + logger = logging.getLogger("runv") + logger.setLevel(logging.DEBUG if verbose else logging.INFO) + logger.handlers.clear() + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_path, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(fmt) + logger.addHandler(fh) + except OSError as e: + print(f"Aviso: não foi possível gravar log em {log_path}: {e}", file=sys.stderr) + sh = logging.StreamHandler(sys.stderr) + sh.setLevel(logging.DEBUG if verbose else logging.WARNING) + sh.setFormatter(fmt) + logger.addHandler(sh) + return logger + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description=( + "Provisiona conta Unix interna (runv.club). Executar como root no servidor. " + f"Versão {VERSION} — {AUTHOR} {COPYRIGHT_YEAR}." + ), + ) + p.add_argument( + "-i", + "--interactive", + action="store_true", + help="modo interativo (perguntas no terminal); também é o padrão se não passar nenhum argumento", + ) + p.add_argument("--username", default=None, help="nome de usuário Unix (minúsculas)") + p.add_argument("--email", default=None, help="email administrativo (metadado)") + g = p.add_mutually_exclusive_group(required=False) + g.add_argument("--public-key", dest="public_key", default=None, help="linha authorized_keys (OpenSSH)") + g.add_argument( + "--public-key-file", + type=Path, + dest="public_key_file", + default=None, + help="arquivo com uma linha .pub", + ) + p.add_argument("--dry-run", action="store_true", help="valida e mostra o plano sem alterar o sistema") + p.add_argument("--verbose", action="store_true", help="log detalhado no stderr") + p.add_argument( + "--force-index", + action="store_true", + help="sobrescrever ~/public_html/index.html se já existir", + ) + p.add_argument( + "--force-readme", + action="store_true", + help="sobrescrever ~/README.md se já existir", + ) + p.add_argument( + "--metadata-file", + type=Path, + default=DEFAULT_METADATA_PATH, + help=f"caminho do JSON de metadados (padrão: {DEFAULT_METADATA_PATH})", + ) + p.add_argument( + "--lock-file", + type=Path, + default=DEFAULT_LOCK_PATH, + help=f"arquivo de lock flock (padrão: {DEFAULT_LOCK_PATH})", + ) + p.add_argument( + "--log-file", + type=Path, + default=DEFAULT_LOG_PATH, + help=f"log local (padrão: {DEFAULT_LOG_PATH})", + ) + p.add_argument( + "--base-url", + default=DEFAULT_BASE_URL, + help=f"URL base para o resumo (padrão: {DEFAULT_BASE_URL})", + ) + p.add_argument( + "--no-quota", + action="store_true", + help="não aplica quota de disco (ignora setquota)", + ) + p.add_argument( + "--require-quota", + action="store_true", + help=( + "exige sistema de quotas pronto (ext4 + usrquota + setquota) antes de criar o utilizador; " + "aborta sem adduser se não estiver configurado" + ), + ) + p.add_argument( + "--quota-soft-mb", + type=int, + default=DEFAULT_QUOTA_SOFT_MIB, + metavar="MiB", + help=f"limite soft de blocos em MiB (1024² B); padrão {DEFAULT_QUOTA_SOFT_MIB}", + ) + p.add_argument( + "--quota-hard-mb", + type=int, + default=DEFAULT_QUOTA_HARD_MIB, + metavar="MiB", + help=f"limite hard de blocos em MiB; padrão {DEFAULT_QUOTA_HARD_MIB}", + ) + p.add_argument( + "--quota-inode-soft", + type=int, + default=DEFAULT_QUOTA_INODE_SOFT, + metavar="N", + help=f"limite soft de inodes; padrão {DEFAULT_QUOTA_INODE_SOFT}", + ) + p.add_argument( + "--quota-inode-hard", + type=int, + default=DEFAULT_QUOTA_INODE_HARD, + metavar="N", + help=f"limite hard de inodes; padrão {DEFAULT_QUOTA_INODE_HARD}", + ) + p.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION} — desenvolvido por {AUTHOR}, {COPYRIGHT_YEAR}", + ) + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + if not argv: + argv = ["--interactive"] + + args = parse_args(argv) + if args.interactive: + try: + interactive_fill(args) + except KeyboardInterrupt: + print("\nInterrompido (Ctrl+C).", file=sys.stderr) + return EXIT_VALIDATION + except SystemExit as e: + code = e.code + if code is None: + return EXIT_VALIDATION + if isinstance(code, int): + return code + return EXIT_VALIDATION + + if not args.username or not args.email: + print( + "Erro: informe --username e --email, ou use --interactive / execute sem argumentos.", + file=sys.stderr, + ) + return EXIT_VALIDATION + if not args.public_key and not args.public_key_file: + print( + "Erro: informe --public-key ou --public-key-file, ou use modo interativo.", + file=sys.stderr, + ) + return EXIT_VALIDATION + + log = setup_logging(args.log_file, args.verbose) + log.info( + "=== início operação create_runv_user (versão %s) dry_run=%s interactive=%s", + VERSION, + args.dry_run, + args.interactive, + ) + + 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") + return EXIT_SYSTEM + + try: + log.info("=== fase: validação de entrada (username, email, chave SSH)") + raw_key = read_public_key_from_args(args.public_key, args.public_key_file) + user = validate_username(args.username) + email = validate_email(args.email) + normalized_key, fingerprint = validate_public_key(raw_key) + log.info( + "=== validação OK: user=%s email=%s fingerprint=%s", + user, + email, + fingerprint, + ) + except ValidationError as e: + log.error("validação falhou: %s", e) + print(f"Validação: {e}", file=sys.stderr) + return EXIT_VALIDATION + + home = home_directory(user) + + if args.no_quota and args.require_quota: + print( + "Erro: --no-quota e --require-quota não podem ser usados em conjunto.", + file=sys.stderr, + ) + return EXIT_VALIDATION + + if not args.no_quota: + try: + validate_quota_limits( + args.quota_soft_mb, + args.quota_hard_mb, + args.quota_inode_soft, + args.quota_inode_hard, + ) + except ValidationError as e: + print(f"Validação: {e}", file=sys.stderr) + return EXIT_VALIDATION + + if args.dry_run: + print("[dry-run] Nenhuma alteração será feita.") + print(f" username: {user}") + print(f" email: {email}") + print(f" home: {home}") + print(f" fingerprint: {fingerprint}") + print( + " ações: (1) adduser + /etc/skel (2) authorized_keys (3) public_html " + "(4) README.md (5) permissões consolidadas + quota (se ativa) + metadados JSON" + ) + if args.no_quota: + print(" quota: desativada (--no-quota)") + else: + print( + f" quota: MiB soft/hard {args.quota_soft_mb}/{args.quota_hard_mb}; " + f"inodes {args.quota_inode_soft}/{args.quota_inode_hard}" + ) + print( + " quota: tentará setquota após criar utilizador (ext4 + usrquota/usrjquota + pacote quota)" + ) + if args.require_quota and not args.no_quota: + print( + " quota: --require-quota — aborta antes de adduser se o sistema de quotas não estiver pronto" + ) + return EXIT_OK + + created_user = False + try: + if args.require_quota and not args.no_quota: + log.info("=== fase: pré-voo de quota (require-quota)") + preflight_quota_for_home(home, log) + + log.info("=== fase 1: criação de conta Unix (adduser; /etc/skel copiado pelo Debian)") + run_adduser(user, log) + created_user = True + pw = pwd.getpwnam(user) + uid, gid = pw.pw_uid, pw.pw_gid + log.info("=== adduser concluído: uid=%s gid=%s home=%s", uid, gid, home) + + log.info("=== fase 2: SSH authorized_keys (~/.ssh 700, arquivo 600)") + install_authorized_keys(home, uid, gid, normalized_key, log) + + log.info("=== fase 3: public_html e index.html estático") + prepare_public_html(home, user, uid, gid, args.force_index, log) + + log.info("=== fase 4: README.md runv (após skel /etc/skel do adduser; texto em português)") + prepare_user_readme(home, user, uid, gid, args.base_url, args.force_readme, log) + + log.info("=== fase 5: permissões consolidadas (home, .ssh, site, README)") + apply_runv_permissions(home, uid, gid) + + log.info("=== fase: quota (setquota em ext4 com usrquota)") + if args.no_quota: + qr = QuotaResult( + enabled=False, + soft_mib=None, + hard_mib=None, + inode_soft=None, + inode_hard=None, + filesystem=None, + mountpoint=None, + applied_at=None, + status="skipped", + ) + log.info("quota: ignorada (--no-quota)") + else: + qr = try_apply_quota( + user, + home, + args.quota_soft_mb, + args.quota_hard_mb, + args.quota_inode_soft, + args.quota_inode_hard, + log, + ) + log.info( + "quota: estado final status=%s mount=%s fs=%s", + qr.status, + qr.mountpoint, + qr.filesystem, + ) + + overall_status = "active" + if not args.no_quota and qr.status in ("failed", "not_configured"): + overall_status = "partial_quota" + + log.info("=== fase: verificação final de permissões e artefactos") + verify_user_artifact_permissions(home, uid, gid) + + record = UserRecord( + username=user, + email=email, + public_key_fingerprint=fingerprint, + created_at=datetime.now(timezone.utc).isoformat(), + created_by=os.environ.get("SUDO_USER") or getpass.getuser(), + home_directory=str(home), + status=overall_status, + quota_enabled=qr.enabled, + quota_soft_mb=qr.soft_mib, + quota_hard_mb=qr.hard_mib, + quota_inode_soft=qr.inode_soft, + quota_inode_hard=qr.inode_hard, + quota_filesystem=qr.filesystem, + quota_mountpoint=qr.mountpoint, + quota_applied_at=qr.applied_at, + quota_status=qr.status, + ) + log.info("=== fase: gravação de metadados JSON (%s)", args.metadata_file) + append_user_metadata(args.metadata_file, args.lock_file, record, log) + + log.info( + "=== resultado final: status=%s quota_status=%s (operação concluída)", + overall_status, + qr.status, + ) + print("Usuário criado com sucesso.") + print(f" home: {home}") + print(" ssh: authorized_keys instalado") + print(" public_html: pronto (index.html estático)") + print(" README.md: criado em ~/README.md (pt-BR)") + print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") + print(f" fingerprint: {fingerprint}") + print(f" metadados: {args.metadata_file}") + if args.no_quota: + print(" quota: omitida (--no-quota)") + else: + print( + f" quota: status={qr.status} " + f"(MiB {args.quota_soft_mb}/{args.quota_hard_mb}, " + f"inodes {args.quota_inode_soft}/{args.quota_inode_hard})" + ) + if qr.mountpoint: + print(f" quota mount: {qr.mountpoint} ({qr.filesystem or '?'})") + + if not args.no_quota and qr.status in ("failed", "not_configured"): + print( + "\n*** AVISO: conta criada mas quota NÃO aplicada ou sistema não configurado. " + "Estado em metadados: partial_quota / quota_status. " + "Corrija usrquota+quotaon e aplique setquota manualmente ou remova o utilizador se foi engano.", + file=sys.stderr, + ) + return EXIT_INCONSISTENT + + return EXIT_OK + + except ValidationError as e: + log.error("validação: %s", e) + print(f"Validação: {e}", file=sys.stderr) + if created_user: + if run_deluser_remove_home(user, log): + print("Rollback: usuário removido após falha de validação tardia.", file=sys.stderr) + else: + print( + f"ERRO: estado parcial — usuário {user!r} pode existir; remova manualmente se necessário.", + file=sys.stderr, + ) + return EXIT_INCONSISTENT + return EXIT_VALIDATION + + except SystemProvisionError as e: + log.exception("falha de sistema: %s", e) + print(f"Erro de sistema: {e}", file=sys.stderr) + if created_user: + if run_deluser_remove_home(user, log): + print("Rollback: usuário e home removidos.", file=sys.stderr) + else: + print( + f"FALHA NO ROLLBACK: revise o usuário {user!r} e o home em {home} manualmente.", + file=sys.stderr, + ) + return EXIT_INCONSISTENT + return EXIT_SYSTEM + + except Exception as e: + log.exception("erro inesperado: %s", e) + print(f"Erro inesperado: {e}", file=sys.stderr) + if created_user: + if run_deluser_remove_home(user, log): + print("Rollback: usuário removido.", file=sys.stderr) + else: + print( + f"FALHA NO ROLLBACK: revise o usuário {user!r} manualmente.", + file=sys.stderr, + ) + return EXIT_INCONSISTENT + return EXIT_SYSTEM + + +def run() -> NoReturn: + raise SystemExit(main()) + + +if __name__ == "__main__": + run() diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +""" +Remove permanentemente uma conta Unix (banimento) no servidor runv.club (Debian). + +Usa ``deluser`` com remoção da home. Opcionalmente remove o registro em +``/var/lib/runv/users.json`` se existir. + +Executar como root. Não altera Apache nem SSH diretamente. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import pwd +import shutil +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Final + +# Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script não entra em sys.path. +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") + +# Contas de sistema / serviço — nunca remover por engano +RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( + { + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "_apt", + "nobody", + } +) + +DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") +DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock") + +VERSION: Final[str] = "0.01" + +EXIT_OK: Final[int] = 0 +EXIT_VALIDATION: Final[int] = 1 +EXIT_SYSTEM: Final[int] = 2 + +MIN_UID_NORMAL_USER: Final[int] = 1000 + + +# --------------------------------------------------------------------------- +# Validação e privilégios +# --------------------------------------------------------------------------- + + +def validate_privileges() -> None: + if os.geteuid() != 0: + print( + "Este script deve ser executado como root (ou com sudo).", + file=sys.stderr, + ) + raise SystemExit(EXIT_VALIDATION) + + +def validate_username_syntax(username: str) -> str: + if not username or not username.strip(): + print("Erro: username é obrigatório.", file=sys.stderr) + raise SystemExit(EXIT_VALIDATION) + u = username.strip() + if u != username: + print("Erro: username não pode ter espaços no início ou fim.", file=sys.stderr) + raise SystemExit(EXIT_VALIDATION) + if not USERNAME_PATTERN.fullmatch(u): + print( + "Erro: username inválido (use letras minúsculas, dígitos, _ e -; " + "2–32 caracteres, começando com letra).", + file=sys.stderr, + ) + raise SystemExit(EXIT_VALIDATION) + return u + + +def check_user_exists(username: str) -> tuple[int, Path]: + """Retorna (uid, home) ou encerra com erro.""" + try: + pw = pwd.getpwnam(username) + except KeyError: + print(f"Erro: usuário {username!r} não existe neste sistema.", file=sys.stderr) + raise SystemExit(EXIT_VALIDATION) + return pw.pw_uid, Path(pw.pw_dir) + + +def enforce_safety_rules( + username: str, + uid: int, + *, + force: bool, +) -> None: + """Impede remoção acidental de contas críticas.""" + if username == "root": + print("Erro: remover 'root' não é permitido.", file=sys.stderr) + raise SystemExit(EXIT_VALIDATION) + + if username in RESERVED_USERNAMES and not force: + print( + f"Erro: {username!r} é uma conta reservada do sistema. " + "Se tem certeza absoluta, repita com --force (não recomendado).", + file=sys.stderr, + ) + raise SystemExit(EXIT_VALIDATION) + + if uid < MIN_UID_NORMAL_USER and not force: + print( + f"Erro: UID {uid} < {MIN_UID_NORMAL_USER} (conta de sistema). " + "Para remover, use --force (perigoso).", + file=sys.stderr, + ) + raise SystemExit(EXIT_VALIDATION) + + +def confirm_interactive(username: str) -> bool: + print() + print(" ATENÇÃO: esta operação remove a conta, a home e o acesso SSH por chave") + print(" (o utilizador deixa de existir no sistema).") + print() + typed = input(f" Digite exatamente o username para confirmar [{username}]: ").strip() + return typed == username + + +# --------------------------------------------------------------------------- +# deluser +# --------------------------------------------------------------------------- + + +def clear_user_quota_before_removal( + username: str, + home: Path, + *, + verbose: bool, + dry_run: bool, +) -> None: + """ + Se existir ext4+usrquota no mount da home, repõe limites a zero antes de apagar o utilizador + (alinhado ao mount detetado por create_runv_user / runv_mount). + """ + from runv_mount import MountLookupError, find_mount_triple, quota_opts_allow_user + + if not shutil.which("setquota"): + if verbose: + print(" [info] setquota ausente; não limpo quotas antes de deluser.") + return + try: + tgt, fst, opts = find_mount_triple(home) + except MountLookupError as e: + if verbose: + print(f" [info] mount da home não resolvido ({e}); salto limpeza de quota.") + return + if fst != "ext4" or not quota_opts_allow_user(opts): + if verbose: + print(" [info] sem ext4+usrquota neste mount; salto limpeza de quota.") + return + cmd = ["setquota", "-u", username, "0", "0", "0", "0", tgt] + if dry_run: + print(f" [dry-run] executaria: {' '.join(cmd)}") + return + r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + print( + f" [aviso] setquota para limpar quotas falhou (código {r.returncode}): {err}", + file=sys.stderr, + ) + print( + " Continuo com deluser; verifique repquota/edquota se necessário.", + file=sys.stderr, + ) + elif verbose: + print(f" [ok] quotas repostas a ilimitado para {username!r} em {tgt!r}") + + +def run_deluser( + username: str, + *, + purge_all_files: bool, + dry_run: bool, + verbose: bool, +) -> None: + if dry_run: + cmd = ["deluser", username] + if purge_all_files: + cmd.insert(1, "--remove-all-files") + else: + cmd.insert(1, "--remove-home") + print(f" [dry-run] executaria: {' '.join(cmd)}") + return + + env = os.environ.copy() + env["DEBIAN_FRONTEND"] = "noninteractive" + env["LC_ALL"] = "C" + + cmd: list[str] = ["deluser"] + if purge_all_files: + cmd.append("--remove-all-files") + else: + cmd.append("--remove-home") + cmd.append(username) + + if verbose: + print(f" [exec] {' '.join(cmd)}") + + try: + r = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, + env=env, + ) + except FileNotFoundError: + print( + "Erro: comando 'deluser' não encontrado (pacote adduser no Debian).", + file=sys.stderr, + ) + raise SystemExit(EXIT_SYSTEM) from None + + if r.returncode != 0: + print(f"Erro: deluser falhou (código {r.returncode}).", file=sys.stderr) + if r.stdout: + print(r.stdout, file=sys.stderr) + if r.stderr: + print(r.stderr, file=sys.stderr) + raise SystemExit(EXIT_SYSTEM) + + if verbose and r.stdout: + print(r.stdout.rstrip()) + + +# --------------------------------------------------------------------------- +# Metadados runv (users.json) +# --------------------------------------------------------------------------- + + +def remove_user_metadata( + metadata_path: Path, + lock_path: Path, + username: str, + *, + dry_run: bool, + verbose: bool, +) -> str: + """ + Remove entrada com mesmo 'username' da lista JSON. + Retorna: 'removed' | 'absent' | 'skipped' | 'dry-run' + """ + if not metadata_path.is_file(): + if verbose: + print(f" [metadata] ficheiro inexistente, nada a fazer: {metadata_path}") + return "skipped" + + if dry_run: + raw = metadata_path.read_text(encoding="utf-8").strip() + if not raw: + return "dry-run" + try: + data = json.loads(raw) + except json.JSONDecodeError: + print( + f"Aviso: {metadata_path} não é JSON válido; não alterado no dry-run.", + file=sys.stderr, + ) + return "dry-run" + if isinstance(data, list) and any( + isinstance(x, dict) and x.get("username") == username for x in data + ): + print(f" [dry-run] removeria entrada de {username!r} em {metadata_path}") + else: + print(f" [dry-run] sem entrada para {username!r} em {metadata_path}") + return "dry-run" + + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_f = open(lock_path, "a+", encoding="utf-8") + try: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) + raw = metadata_path.read_text(encoding="utf-8").strip() + if not raw: + return "absent" + parsed = json.loads(raw) + if not isinstance(parsed, list): + print( + f"Erro: formato inválido em {metadata_path} (esperada lista JSON).", + file=sys.stderr, + ) + raise SystemExit(EXIT_SYSTEM) + before = len(parsed) + data = [x for x in parsed if not (isinstance(x, dict) and x.get("username") == username)] + after = len(data) + if before == after: + if verbose: + print(f" [metadata] nenhum registo para {username!r} em {metadata_path}") + return "absent" + + tmp_fd, tmp_name = tempfile.mkstemp( + prefix="users.", + suffix=".tmp", + dir=str(metadata_path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as out: + json.dump(data, out, indent=2, ensure_ascii=False) + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_path, metadata_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + print(f" [metadata] removido registo de {username!r} em {metadata_path}") + return "removed" + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + lock_f.close() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Remove permanentemente um utilizador Unix (banimento, runv.club).", + ) + parser.add_argument( + "--username", + "-u", + required=True, + metavar="USER", + help="nome de utilizador Unix a remover", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="mostra o que seria feito sem remover nada (não exige root)", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="mais detalhes na saída", + ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="não pedir confirmação interativa (para scripts)", + ) + parser.add_argument( + "--force", + action="store_true", + help="permite remover contas reservadas ou UID de sistema (muito perigoso)", + ) + parser.add_argument( + "--purge-all-files", + action="store_true", + help="usa deluser --remove-all-files em vez de só --remove-home", + ) + parser.add_argument( + "--skip-metadata", + action="store_true", + help="não altera /var/lib/runv/users.json", + ) + parser.add_argument( + "--metadata-file", + type=Path, + default=DEFAULT_METADATA_PATH, + help=f"caminho do JSON de metadados (default: {DEFAULT_METADATA_PATH})", + ) + parser.add_argument( + "--lock-file", + type=Path, + default=DEFAULT_LOCK_PATH, + help=f"ficheiro de lock flock (default: {DEFAULT_LOCK_PATH})", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION} — runv.club", + ) + args = parser.parse_args() + + username = validate_username_syntax(args.username) + + uid, home = check_user_exists(username) + enforce_safety_rules(username, uid, force=args.force) + + if args.dry_run: + print("del-user.py — modo dry-run (nenhuma alteração)\n") + print(f" utilizador: {username!r}") + print(f" UID: {uid}") + print(f" home: {home}") + clear_user_quota_before_removal( + username, + home, + verbose=args.verbose, + dry_run=True, + ) + run_deluser( + username, + purge_all_files=args.purge_all_files, + dry_run=True, + verbose=args.verbose, + ) + if not args.skip_metadata: + remove_user_metadata( + args.metadata_file, + args.lock_file, + username, + dry_run=True, + verbose=args.verbose, + ) + print("\nNada foi alterado. Execute sem --dry-run como root para aplicar.") + return EXIT_OK + + if not args.yes: + if not confirm_interactive(username): + print("Cancelado: confirmação não coincide.") + return EXIT_VALIDATION + + validate_privileges() + + print(f"\ndel-user.py — removendo {username!r} (UID {uid})\n") + + clear_user_quota_before_removal( + username, + home, + verbose=args.verbose, + dry_run=False, + ) + + run_deluser( + username, + purge_all_files=args.purge_all_files, + dry_run=False, + verbose=args.verbose, + ) + print(f" [ok] deluser concluído para {username!r}") + + if not args.skip_metadata: + remove_user_metadata( + args.metadata_file, + args.lock_file, + username, + dry_run=False, + verbose=args.verbose, + ) + + print("\n--- Resumo ---") + print(f" Conta removida: {username!r}") + print(" Próximo passo: verificar se não restam processos desse UID e revogar acessos externos se aplicável.") + + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/runv_mount.py b/scripts/admin/runv_mount.py @@ -0,0 +1,82 @@ +""" +Descoberta de montagens para quotas runv — partilhado por starthere, create_runv_user, del-user. + +O ponto de verdade para «em que disco estão as homes» é o path físico (tipicamente /home): +o mesmo algoritmo deve usar findmnt/proc em todos os scripts. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +class MountLookupError(RuntimeError): + """Não foi possível resolver o mount para um path.""" + + +def find_mount_triple(path: Path) -> tuple[str, str, str]: + """ + Retorna (target, fstype, options_csv) do filesystem que contém ``path``. + + ``target`` é o mountpoint canónico (ex.: ``/`` se /home está na raiz, ou ``/home`` + se /home é um ponto de montagem separado). + """ + try: + r = subprocess.run( + ["findmnt", "-J", "-T", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + if r.returncode == 0 and r.stdout.strip(): + data = json.loads(r.stdout) + fss = data.get("filesystems") or [] + if fss: + e = fss[0] + tgt = str(e.get("target", "")) + fst = str(e.get("fstype", "")) + opts = str(e.get("options", "")) + if tgt and fst: + return tgt, fst, opts + except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired): + pass + + try: + resolved = path.resolve() + rpath = str(resolved) + best: tuple[str, str, str, int] = ("", "", "", -1) + with open("/proc/mounts", encoding="utf-8") as f: + for line in f: + parts = line.split() + if len(parts) < 4: + continue + _dev, mountpoint, fstype, opts = parts[0], parts[1], parts[2], parts[3] + mp = mountpoint.replace("\\040", " ") + if rpath == mp or rpath.startswith(mp.rstrip("/") + "/") or rpath.startswith(mp + "/"): + ln = len(mp) + if ln > best[3]: + best = (mp, fstype, opts, ln) + if best[3] >= 0: + return best[0], best[1], best[2] + except OSError: + pass + + raise MountLookupError( + f"não foi possível determinar o mountpoint do filesystem para {path} " + "(findmnt e /proc/mounts falharam)" + ) + + +def quota_opts_allow_user(options: str) -> bool: + """True se usrquota ou usrjquota= está ativo nas opções de mount.""" + if not options: + return False + for raw in options.split(","): + opt = raw.strip() + if opt == "usrquota": + return True + if opt.startswith("usrjquota="): + return True + return False diff --git a/scripts/admin/skel.py b/scripts/admin/skel.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Prepara /etc/skel para novos usuários do runv.club (Debian). +Executar como root. Não cria usuários, não altera Apache nem SSH. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Final + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +SKEL_ROOT: Final[Path] = Path("/etc/skel") +PUBLIC_HTML_DIR: Final[Path] = SKEL_ROOT / "public_html" +INDEX_HTML: Final[Path] = PUBLIC_HTML_DIR / "index.html" +README_MD: Final[Path] = SKEL_ROOT / "README.md" + +VERSION: Final[str] = "0.01" + +EXIT_OK: Final[int] = 0 +EXIT_ERROR: Final[int] = 1 +EXIT_PRIVILEGE: Final[int] = 2 + + +# --------------------------------------------------------------------------- +# Validação de privilégios +# --------------------------------------------------------------------------- + + +def validate_privileges() -> None: + """Exige UID 0 (root) para alterar /etc/skel.""" + if os.geteuid() != 0: + print( + "Erro: este script precisa ser executado como root (sudo).", + file=sys.stderr, + ) + raise SystemExit(EXIT_PRIVILEGE) + + +# --------------------------------------------------------------------------- +# Geração de conteúdo +# --------------------------------------------------------------------------- + + +def render_index_html() -> str: + """HTML estático com CSS embutido; visual simples e textual, sem dependências externas.""" + return """<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>A sua página no runv.club</title> + <style> + :root { + --bg: #f4f0e8; + --fg: #1a1a12; + --muted: #5c5a52; + --accent: #2d6a4f; + --rule: #c4c0b8; + } + * { box-sizing: border-box; } + body { + margin: 0; + padding: 1.5rem 1rem 3rem; + font-family: "Georgia", "Times New Roman", serif; + font-size: 1rem; + line-height: 1.55; + color: var(--fg); + background: var(--bg); + max-width: 38rem; + margin-left: auto; + margin-right: auto; + } + h1 { + font-size: 1.35rem; + font-weight: normal; + letter-spacing: 0.02em; + border-bottom: 1px solid var(--rule); + padding-bottom: 0.5rem; + margin-top: 0; + } + .tagline { + font-style: italic; + color: var(--muted); + margin: 0.25rem 0 1.25rem; + font-size: 0.95rem; + } + pre, code { + font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; + font-size: 0.88rem; + } + pre { + background: #e8e4dc; + border: 1px solid var(--rule); + padding: 0.75rem 1rem; + overflow-x: auto; + margin: 0.75rem 0; + } + section { + margin: 1.5rem 0; + } + h2 { + font-size: 1.05rem; + font-weight: normal; + margin: 0 0 0.5rem; + color: var(--accent); + } + .url-box { + border-left: 3px solid var(--accent); + padding-left: 0.75rem; + margin: 0.75rem 0; + } + footer { + margin-top: 2rem; + padding-top: 0.75rem; + border-top: 1px solid var(--rule); + font-size: 0.85rem; + color: var(--muted); + } + </style> +</head> +<body> + <h1>Bem-vindo ao runv.club</h1> + <p class="tagline">Um cantinho na rede — pubnix runv.club.</p> + + <p> + Esta página foi gerada automaticamente quando sua conta foi criada. + Você pode editá-la quando quiser: o arquivo fica em + <code>~/public_html/index.html</code>. + </p> + + <section> + <h2>Próximos passos</h2> + <ol> + <li>Entrar no servidor por SSH.</li> + <li>Ir para a pasta do site pessoal.</li> + <li>Editar este HTML com um editor de texto.</li> + <li>Salvar e recarregar a página no navegador.</li> + </ol> + </section> + + <section> + <h2>Comandos úteis</h2> + <pre>cd ~/public_html +nano index.html +ls -la</pre> + </section> + + <section> + <h2>Sua URL</h2> + <p>Quando estiver no ar, seu site costuma aparecer em:</p> + <div class="url-box"> + <code>http://runv.club/~SEU_USUARIO/</code> + </div> + <p>Substitua <code>SEU_USUARIO</code> pelo seu nome de usuário Unix.</p> + </section> + + <footer> + runv.club — servidor multiusuário. Edite esta página à vontade. + </footer> +</body> +</html> +""" + + +def render_readme_md() -> str: + """README em Markdown para a home inicial (copiado de /etc/skel).""" + return """# Bem-vindo ao runv.club + +O **runv.club** é um servidor multiutilizador: cada pessoa tem uma conta Unix e um +site pessoal servido pelo Apache. + +## Onde fica o seu site + +- **Pasta:** `~/public_html/` +- **Arquivo principal:** `~/public_html/index.html` — edite este primeiro. + +## URL pública + +Depois de publicar, seu site costuma ficar em: + +```text +http://runv.club/~SEU_USUARIO/ +``` + +Troque `SEU_USUARIO` pelo seu nome de usuário Unix (o mesmo do login). + +## Permissões (referência) + +Após a criação da conta, costuma ser assim para o site aparecer: + +| Caminho | Permissão típica | +|---------|------------------| +| `~` (home) | `755` | +| `~/public_html` | `755` | +| `~/public_html/index.html` | `644` | + +Se algo não carregar no navegador, peça ajuda a um admin e mencione estas pastas. + +## Comandos básicos + +```bash +cd ~/public_html +nano index.html +ls -la +``` + +## Servidor multiusuário + +- Muitas pessoas usam a mesma máquina. **Não guarde segredos** em arquivos dentro de + `public_html` ou em qualquer lugar que o site possa expor. +- O que está em `public_html` é pensado para ser **público na web**. + +## Dúvidas + +Leia também a documentação do projeto ou fale com a equipe no canal indicado pelo runv.club. + +— Equipe runv.club +""" + + +# --------------------------------------------------------------------------- +# Diretórios e ficheiros +# --------------------------------------------------------------------------- + + +def ensure_directories( + dry_run: bool, + verbose: bool, +) -> tuple[list[Path], list[Path]]: + """ + Garante que os diretórios necessários existem. + Retorna (criados, já existentes). + """ + created: list[Path] = [] + existed: list[Path] = [] + for d in (SKEL_ROOT, PUBLIC_HTML_DIR): + if d.is_dir(): + existed.append(d) + if verbose: + print(f" [dir] já existe: {d}") + continue + if dry_run: + created.append(d) + print(f" [dry-run] criaria diretório: {d}") + continue + d.mkdir(parents=True, exist_ok=True) + created.append(d) + print(f" [dir] criado: {d}") + return created, existed + + +def apply_permissions(paths: list[Path], verbose: bool) -> None: + """Aplica modos 755 para diretórios e 644 para ficheiros.""" + for p in paths: + if not p.exists(): + continue + if p.is_dir(): + mode = 0o755 + else: + mode = 0o644 + if verbose: + print(f" [chmod] {oct(mode)} {p}") + try: + p.chmod(mode) + except OSError as e: + print(f"Erro ao definir permissões em {p}: {e}", file=sys.stderr) + raise SystemExit(EXIT_ERROR) from e + + +def write_file_safe( + path: Path, + content: str, + *, + force: bool, + dry_run: bool, + verbose: bool, +) -> str: + """ + Escreve conteúdo se o ficheiro não existir ou se force=True. + Retorna: 'created' | 'updated' | 'preserved' + """ + existed_before = path.is_file() + + if dry_run: + if existed_before and not force: + print(f" [dry-run] preservaria (sem alterar; use --force para regenerar): {path}") + return "preserved" + verb = "atualizaria" if existed_before else "criaria" + print(f" [dry-run] {verb} arquivo: {path}") + return "updated" if existed_before else "created" + + if existed_before and not force: + hint = " (use --force para sobrescrever)" if verbose else "" + print(f" [file] preservado{hint}: {path}") + return "preserved" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + label = "atualizado" if existed_before else "criado" + print(f" [file] {label}: {path}") + return "updated" if existed_before else "created" + + +def run_dry_run(verbose: bool) -> int: + """Mostra o plano sem escrever em disco (não exige root).""" + print("Modo dry-run — nenhuma alteração em disco.\n") + ensure_directories(dry_run=True, verbose=verbose) + write_file_safe( + INDEX_HTML, render_index_html(), force=False, dry_run=True, verbose=verbose + ) + write_file_safe( + README_MD, render_readme_md(), force=False, dry_run=True, verbose=verbose + ) + print("\nResumo: nada foi gravado. Execute sem --dry-run como root para aplicar.") + return EXIT_OK + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Prepara /etc/skel para novos usuários do runv.club (Debian).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="mostra o que seria feito sem alterar arquivos", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="mais detalhes na saída", + ) + parser.add_argument( + "--force", + action="store_true", + help="sobrescreve index.html e README.md se já existirem", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION} — runv.club", + ) + args = parser.parse_args() + + if args.dry_run: + return run_dry_run(args.verbose) + + validate_privileges() + + print("skel.py — preparando /etc/skel para runv.club\n") + + dirs_created, _dirs_existed = ensure_directories(dry_run=False, verbose=args.verbose) + + results: dict[str, str] = {} + for label, path, content in ( + ("index.html", INDEX_HTML, render_index_html()), + ("README.md", README_MD, render_readme_md()), + ): + results[label] = write_file_safe( + path, + content, + force=args.force, + dry_run=False, + verbose=args.verbose, + ) + + # Permissões + to_chmod = [PUBLIC_HTML_DIR, INDEX_HTML, README_MD] + print("\nAplicando permissões...") + apply_permissions(to_chmod, verbose=args.verbose) + + # Resumo + print("\n--- Resumo ---") + print(f" Diretórios criados agora: {len(dirs_created)}") + if dirs_created: + for d in dirs_created: + print(f" - {d}") + print(f" index.html: {results.get('index.html', '?')}") + print(f" README.md: {results.get('README.md', '?')}") + print(" Permissões: public_html → 755; index.html e README.md → 644") + + print("\n--- Próximos passos sugeridos ---") + print(" 1. Crie um usuário de teste: sudo adduser --disabled-password testuser") + print(" 2. Verifique se a home copiou de /etc/skel:") + print(" ls -la ~/ (como esse usuário)") + print(" ls -la ~/public_html/") + print(" 3. Teste no navegador: http://runv.club/~testuser/ (ajuste DNS/host)") + + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/starthere.py b/scripts/admin/starthere.py @@ -0,0 +1,719 @@ +#!/usr/bin/env python3 +""" +starthere.py - bootstrap seguro para runv.club em Debian/ext4 + +O que faz: +- atualiza índices APT +- instala um conjunto conservador de pacotes úteis para o projeto +- faz limpeza segura (autoremove + autoclean) +- enable/start apache2; UFW inativo → allow SSH/80/443 e enable +- descobre automaticamente o filesystem que contém /home (pode ser / ou /home, etc.) +- habilita usrquota nesse mountpoint ext4 no /etc/fstab +- remount + quotacheck + quotaon nesse mesmo ponto +- roda quotacheck (-cu, depois -cuM, depois variantes com -f se quotas já ativas no mount) +- quotaon -vu trata EBUSY (quotas já ativas após remount) como sucesso com aviso +- ativa quotas de usuário + +O que NÃO faz: +- não purga pacotes arbitrariamente +- não mexe em SSH +- não mexe no Apache além de instalar o pacote se faltar +- não cria usuários +- não instala stack de email +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Final + +# Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script deixa de ir para sys.path; +# sem isto, «from runv_mount» falha mesmo com runv_mount.py ao lado. +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +try: + from runv_mount import MountLookupError, find_mount_triple +except ModuleNotFoundError: + print( + "Erro: módulo 'runv_mount' não encontrado. " + "O ficheiro runv_mount.py tem de estar no mesmo diretório que starthere.py.\n" + f" Esperado: {_SCRIPT_DIR / 'runv_mount.py'}", + file=sys.stderr, + ) + raise SystemExit(1) from None + +DEFAULT_QUOTA_PROBE: Final[Path] = Path("/home") + +VERSION: Final[str] = "0.01" + +FSTAB = Path("/etc/fstab") +BACKUP_DIR = Path("/root/runv-fstab-backups") + +BASE_PACKAGES = [ + "apache2", + "openssh-server", + "sudo", + "ufw", + "quota", + "curl", + "wget", + "git", + "rsync", + "tmux", + "htop", + "vim", + "nano", + "tree", + "jq", + "acl", + "zip", + "unzip", + "less", + "ca-certificates", + "man-db", + "build-essential", + "python3-venv", + "python3-pip", + "ripgrep", + "shellcheck", + "e2fsprogs", +] + +@dataclass +class CmdResult: + cmd: list[str] + returncode: int + stdout: str + stderr: str + + +class BootstrapError(RuntimeError): + pass + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def require_root() -> None: + if os.geteuid() != 0: + raise BootstrapError("Este script precisa rodar como root (use sudo).") + + +def run( + cmd: list[str], + *, + dry_run: bool = False, + verbose: bool = False, + check: bool = True, + env: dict[str, str] | None = None, +) -> CmdResult: + if verbose or dry_run: + eprint("$ " + " ".join(shlex.quote(part) for part in cmd)) + if dry_run: + return CmdResult(cmd, 0, "", "") + proc = subprocess.run( + cmd, + text=True, + capture_output=True, + env=env, + check=False, + ) + if verbose and proc.stdout: + eprint(proc.stdout.rstrip()) + if verbose and proc.stderr: + eprint(proc.stderr.rstrip()) + if check and proc.returncode != 0: + raise BootstrapError( + f"Comando falhou ({proc.returncode}): {' '.join(cmd)}\n" + f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + ) + return CmdResult(cmd, proc.returncode, proc.stdout, proc.stderr) + + +def command_exists(name: str) -> bool: + return shutil.which(name) is not None + + +def apt_env() -> dict[str, str]: + env = os.environ.copy() + env["DEBIAN_FRONTEND"] = "noninteractive" + return env + + +def apt_update(verbose: bool, dry_run: bool) -> None: + run(["apt-get", "update"], verbose=verbose, dry_run=dry_run, env=apt_env()) + + +def apt_install(packages: list[str], verbose: bool, dry_run: bool) -> None: + run( + ["apt-get", "install", "-y", *packages], + verbose=verbose, + dry_run=dry_run, + env=apt_env(), + ) + + +def apt_cleanup(verbose: bool, dry_run: bool) -> None: + run(["apt-get", "autoremove", "-y"], verbose=verbose, dry_run=dry_run, env=apt_env()) + run(["apt-get", "autoclean", "-y"], verbose=verbose, dry_run=dry_run, env=apt_env()) + + +def get_mount_kernel_view(mountpoint: str, *, verbose: bool) -> tuple[str, str, list[str]]: + """Lê TARGET,FSTYPE,OPTIONS do kernel para um mountpoint (ex. ``/`` ou ``/home``).""" + res = run( + ["findmnt", "-no", "TARGET,FSTYPE,OPTIONS", mountpoint], + verbose=verbose, + dry_run=False, + ) + line = res.stdout.strip() + if not line: + raise BootstrapError( + f"Não consegui obter informações do mount {mountpoint!r} com findmnt." + ) + parts = line.split(maxsplit=2) + if len(parts) != 3: + raise BootstrapError(f"Saída inesperada do findmnt: {line!r}") + target, fstype, options = parts + opts_list = [opt.strip() for opt in options.split(",") if opt.strip()] + return target, fstype, opts_list + + +def mount_options_indicate_user_quota(options: list[str]) -> bool: + """usrquota explícito ou journaled (usrjquota=...).""" + blob = ",".join(options) + return "usrquota" in blob or "usrjquota" in blob + + +def discover_quota_mountpoint(home_probe: Path, verbose: bool) -> str: + """ + O mesmo critério que create_runv_user / setquota: filesystem que contém o path de sonda + (por omissão ``/home``). Pode ser ``/`` ou ``/home`` (volume dedicado), etc. + """ + try: + tgt, fst, opts_csv = find_mount_triple(home_probe) + except MountLookupError as e: + raise BootstrapError( + f"Não foi possível descobrir em que filesystem {home_probe} está montado. " + f"Detalhe: {e}" + ) from e + if verbose: + eprint( + f"Deteção automática: {home_probe} → mount {tgt!r}, fstype {fst}, opções {opts_csv!r}" + ) + if fst != "ext4": + raise BootstrapError( + f"O filesystem que contém {home_probe} está em {tgt!r} com tipo {fst!r}. " + "Só configuramos quotas automaticamente para ext4 (alinhado a create_runv_user.py). " + "Noutro tipo de FS, configure quotas manualmente ou use uma VPS com /home em ext4." + ) + return tgt + + +def backup_fstab(verbose: bool, dry_run: bool) -> Path: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = BACKUP_DIR / f"fstab.{timestamp}.bak" + if verbose or dry_run: + eprint(f"Backup do fstab: {backup_path}") + if not dry_run: + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(FSTAB, backup_path) + return backup_path + + +def ensure_usrquota_in_fstab(mountpoint: str, *, dry_run: bool, verbose: bool) -> bool: + """ + Garante usrquota na linha do fstab que monta ``mountpoint`` como ext4. + Retorna True se o arquivo foi alterado. + """ + content = FSTAB.read_text(encoding="utf-8").splitlines(keepends=True) + changed = False + new_lines: list[str] = [] + + for line in content: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + new_lines.append(line) + continue + + parts = line.split() + if len(parts) < 4: + new_lines.append(line) + continue + + _device, mp, fstype, options = parts[:4] + if mp == mountpoint and fstype == "ext4": + opts = [o for o in options.split(",") if o] + if "usrquota" not in opts: + opts.append("usrquota") + parts[3] = ",".join(opts) + newline = "\t".join(parts) + if not newline.endswith("\n"): + newline += "\n" + new_lines.append(newline) + changed = True + continue + new_lines.append(line) + + if changed: + backup_fstab(verbose, dry_run) + if verbose or dry_run: + eprint(f"Atualizando /etc/fstab para incluir usrquota em {mountpoint!r}") + if not dry_run: + FSTAB.write_text("".join(new_lines), encoding="utf-8") + else: + if verbose: + eprint(f"/etc/fstab já contém usrquota para {mountpoint!r} (ext4)") + return changed + + +def remount_with_usrquota(mountpoint: str, verbose: bool, dry_run: bool) -> None: + run( + ["mount", "-o", "remount,usrquota", mountpoint], + verbose=verbose, + dry_run=dry_run, + ) + if dry_run or mount_has_user_quota(mountpoint, verbose): + return + if verbose: + eprint( + f"Aviso: usrquota ainda não aparece em {mountpoint!r}; " + "tentando remount genérico..." + ) + run(["mount", "-o", "remount", mountpoint], verbose=verbose, dry_run=dry_run) + + +def mount_has_user_quota(mountpoint: str, verbose: bool = False) -> bool: + _, _, options = get_mount_kernel_view(mountpoint, verbose=verbose) + return mount_options_indicate_user_quota(options) + + +def dry_run_assume_quota_active( + *, + dry_run: bool, + fstab_changed: bool, + skip_remount: bool, +) -> bool: + """ + Em --dry-run não escrevemos fstab nem remontamos de verdade; o findmnt + continua sem usrquota. Assumimos sucesso só para completar o plano de quotas. + """ + if not dry_run: + return False + if skip_remount and fstab_changed: + eprint( + "AVISO (dry-run): com --skip-remount e fstab a alterar, " + "em execução real seria preciso remount ou reboot antes de quotacheck." + ) + return True + + +def quota_mount_ready( + mountpoint: str, + verbose: bool, + *, + dry_run: bool, + dry_run_trust: bool, +) -> bool: + if dry_run_trust: + return True + return mount_has_user_quota(mountpoint, verbose) + + +def quota_tools_present() -> list[str]: + required = ["quotacheck", "quotaon", "setquota", "quota"] + return [tool for tool in required if command_exists(tool)] + + +def run_quotacheck_escalation( + mountpoint: str, + *, + verbose: bool, + dry_run: bool, + allow_live_scan: bool, +) -> None: + """ + Cria/atualiza aquota.* com quotacheck. + + Após ``remount,usrquota``, o kernel pode já reportar quotas de utilizador + ativas; nesse caso o quotacheck recusa-se sem ``-f`` («use -f to force»). + Tentamos primeiro varreduras normais e só depois variantes com ``-f``. + """ + if allow_live_scan: + sequences: list[tuple[list[str], str]] = [ + (["quotacheck", "-cuM", mountpoint], "quotacheck -cuM"), + (["quotacheck", "-cuM", "-f", mountpoint], "quotacheck -cuM -f"), + ] + else: + sequences = [ + (["quotacheck", "-cu", mountpoint], "quotacheck -cu"), + (["quotacheck", "-cuM", mountpoint], "quotacheck -cuM"), + (["quotacheck", "-cuM", "-f", mountpoint], "quotacheck -cuM -f"), + (["quotacheck", "-cu", "-f", mountpoint], "quotacheck -cu -f"), + ] + + last_exc: BootstrapError | None = None + for i, (cmd, label) in enumerate(sequences): + try: + run(cmd, verbose=verbose, dry_run=dry_run) + return + except BootstrapError as exc: + last_exc = exc + if dry_run: + raise + if i + 1 < len(sequences): + eprint(f"{label} falhou; a tentar método seguinte...") + + assert last_exc is not None + tried = ", ".join(label for _cmd, label in sequences) + raise BootstrapError( + f"quotacheck falhou após tentar: {tried}.\nÚltimo erro:\n{last_exc}\n" + "Se a mensagem falar de quotas nativas ext4 (tune2fs -O quota) vs ficheiros " + "aquota.*, veja a secção de quotas em starthere.md; em muitos casos " + "-f resolve após remount com usrquota já ativo." + ) from last_exc + + +def _quotaon_stderr_implies_already_active(text: str) -> bool: + """EBUSY típico quando usrquota já ficou ativo no remount e aquota.* está em uso.""" + t = text.lower() + return "device or resource busy" in t or ( + "resource busy" in t and "quotaon" in t + ) + + +def run_quotaon_user_vu(mountpoint: str, *, verbose: bool, dry_run: bool) -> None: + """ + Executa ``quotaon -vu``. Se falhar com EBUSY, assume quotas de utilizador + já ativas (comum após ``remount,usrquota`` + quotacheck) e continua. + """ + res = run( + ["quotaon", "-vu", mountpoint], + verbose=verbose, + dry_run=dry_run, + check=False, + ) + if dry_run: + return + if res.returncode == 0: + return + combined = (res.stderr or "") + (res.stdout or "") + if _quotaon_stderr_implies_already_active(combined): + qmp = shlex.quote(mountpoint) + eprint( + "quotaon: quotas de utilizador já ativas neste mount (Device or resource busy); " + f"a continuar. Confirme com «quota -vs» ou «sudo repquota -s {qmp}»." + ) + return + raise BootstrapError( + f"Comando falhou ({res.returncode}): quotaon -vu {shlex.quote(mountpoint)}\n" + f"STDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}" + ) + + +def init_quotas( + mountpoint: str, + verbose: bool, + dry_run: bool, + *, + allow_live_scan: bool, +) -> None: + present = quota_tools_present() + missing = [tool for tool in ["quotacheck", "quotaon", "setquota", "quota"] if tool not in present] + if missing: + raise BootstrapError( + "Ferramentas de quota ausentes mesmo após instalar o pacote quota: " + + ", ".join(missing) + ) + + if not dry_run and not mount_has_user_quota(mountpoint, False): + raise BootstrapError( + f"O mount {mountpoint!r} ainda não mostra usrquota ativo. " + "Reinicie a VM ou confirme o remount antes de prosseguir." + ) + + run_quotacheck_escalation( + mountpoint, + verbose=verbose, + dry_run=dry_run, + allow_live_scan=allow_live_scan, + ) + + run_quotaon_user_vu(mountpoint, verbose=verbose, dry_run=dry_run) + + +def block_device_for_mount(mountpoint: str) -> str | None: + res = run( + ["findmnt", "-no", "SOURCE", mountpoint], + verbose=False, + dry_run=False, + check=False, + ) + if res.returncode != 0: + return None + dev = res.stdout.strip() + if not dev or dev == "none": + return None + return dev + + +def ext4_has_internal_quota_feature(device: str) -> bool | None: + """True se `tune2fs -l` lista a feature «quota» (quotas nativas ext4).""" + proc = subprocess.run( + ["tune2fs", "-l", device], + text=True, + capture_output=True, + check=False, + timeout=60, + ) + if proc.returncode != 0: + return None + for line in proc.stdout.splitlines(): + if line.startswith("Filesystem features:"): + _label, _, rest = line.partition(":") + parts = rest.split() + return "quota" in parts + return False + + +def note_ext4_quota_deprecation_context(mountpoint: str) -> None: + """ + Explica os avisos «external quota files» / tune2fs -O quota após sucesso. + """ + dev = block_device_for_mount(mountpoint) + if not dev: + return + internal = ext4_has_internal_quota_feature(dev) + if internal is True: + return + eprint( + "Nota (ext4): os avisos de quotacheck/quotaon sobre «external quota files» " + "e «tune2fs -O quota» aparecem quando a feature interna «quota» do ext4 " + "ainda não está ligada no dispositivo — o script usa o modo clássico " + "(usrquota + aquota.*), que continua válido; confirme com «quota -vs». " + "Migrar para o modo recomendado pelo kernel exige janela de manutenção " + f"(desmontar {dev}, «tune2fs -O quota», remontar); ver starthere.md." + ) + + +def ufw_status_text() -> str: + proc = subprocess.run( + ["ufw", "status"], + text=True, + capture_output=True, + check=False, + ) + return (proc.stdout or "") + (proc.stderr or "") + + +def configure_ufw(verbose: bool, dry_run: bool) -> None: + """Habilita UFW só se estiver inativo; preserva SSH antes de fechar.""" + if not command_exists("ufw"): + eprint("AVISO: comando ufw ausente; instale o pacote ufw ou não use --no-install.") + return + txt = ufw_status_text().lower() + if "inactive" not in txt: + if verbose: + eprint("UFW já está ativo ou estado não reconhecido; não altero regras.") + return + if verbose or dry_run: + eprint("UFW inativo: permitindo SSH, HTTP, HTTPS e ativando.") + run(["ufw", "allow", "OpenSSH"], verbose=verbose, dry_run=dry_run) + run(["ufw", "allow", "80/tcp"], verbose=verbose, dry_run=dry_run) + run(["ufw", "allow", "443/tcp"], verbose=verbose, dry_run=dry_run) + run(["ufw", "--force", "enable"], verbose=verbose, dry_run=dry_run) + + +def configure_apache(verbose: bool, dry_run: bool) -> None: + if not command_exists("systemctl"): + eprint("AVISO: systemctl ausente; não configurei Apache.") + return + run(["systemctl", "enable", "apache2"], verbose=verbose, dry_run=dry_run) + run(["systemctl", "start", "apache2"], verbose=verbose, dry_run=dry_run) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Bootstrap seguro do servidor runv.club (Debian/ext4 + quotas)." + ) + p.add_argument("--dry-run", action="store_true", help="Mostra o plano sem executar.") + p.add_argument("--verbose", action="store_true", help="Mostra mais detalhes.") + p.add_argument( + "--packages", + nargs="*", + default=BASE_PACKAGES, + help="Lista de pacotes a instalar (padrão conservador incluído).", + ) + p.add_argument( + "--no-cleanup", + action="store_true", + help="Pula apt autoremove/autoclean.", + ) + p.add_argument( + "--no-install", + action="store_true", + help="Não instala pacotes; só verifica/configura quotas.", + ) + p.add_argument( + "--no-quota", + action="store_true", + help="Não mexe em quotas; só instala pacotes e faz limpeza segura.", + ) + p.add_argument( + "--quota-probe", + type=Path, + default=DEFAULT_QUOTA_PROBE, + metavar="PATH", + help=( + "Caminho para descobrir o filesystem de quotas (deve refletir onde ficam as homes " + f"runv; predefinido: {DEFAULT_QUOTA_PROBE})." + ), + ) + p.add_argument( + "--skip-remount", + action="store_true", + help="Não tenta remount após editar /etc/fstab.", + ) + p.add_argument( + "--allow-live-scan", + action="store_true", + help=( + "Usa só quotacheck -cuM (sem tentar antes -cu). " + "Por omissão o script já tenta -cu e cai para -cuM se necessário." + ), + ) + p.add_argument( + "--no-services", + action="store_true", + help="Não ativa Apache nem configura/ativa UFW (pacotes podem ser instalados).", + ) + p.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION} — runv.club", + ) + return p + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + require_root() + + quota_mp: str | None = None + if not args.no_quota: + quota_mp = discover_quota_mountpoint(args.quota_probe, args.verbose) + + print(f"== runv.club / starthere.py v{VERSION} ==") + print("Bootstrap conservador para Debian/ext4.") + if quota_mp is not None: + print( + f"Quotas: mount detetado para {args.quota_probe} → {quota_mp!r} (ext4, alinhado a create_runv_user)." + ) + print() + + print("[1/6] Atualizando índices APT...") + if not args.no_install: + apt_update(args.verbose, args.dry_run) + + print("[2/6] Instalando pacotes-base...") + if not args.no_install: + apt_install(args.packages, args.verbose, args.dry_run) + + print("[3/6] Limpeza segura...") + if not args.no_cleanup and not args.no_install: + apt_cleanup(args.verbose, args.dry_run) + else: + print("Pulando limpeza segura.") + + print("[4/6] Serviços (Apache, UFW)...") + if not args.no_services: + configure_apache(args.verbose, args.dry_run) + configure_ufw(args.verbose, args.dry_run) + else: + print("Pulado por --no-services.") + + if not args.no_quota: + assert quota_mp is not None + print(f"[5/6] Ajustando /etc/fstab para usrquota em {quota_mp!r} ...") + changed = ensure_usrquota_in_fstab( + quota_mp, + dry_run=args.dry_run, + verbose=args.verbose, + ) + + if changed and not args.skip_remount: + print(f"Tentando remount de {quota_mp!r} com usrquota ...") + try: + remount_with_usrquota(quota_mp, args.verbose, args.dry_run) + except BootstrapError as exc: + raise BootstrapError( + f"Não consegui remount de {quota_mp!r} com usrquota.\n{exc}\n" + "Caminho recomendado: reinicie a VM e rode o script novamente." + ) from exc + + dry_trust = dry_run_assume_quota_active( + dry_run=args.dry_run, + fstab_changed=changed, + skip_remount=args.skip_remount, + ) + + if quota_mount_ready( + quota_mp, + args.verbose, + dry_run=args.dry_run, + dry_run_trust=dry_trust, + ): + print("[6/6] Inicializando e ativando quotas...") + init_quotas( + quota_mp, + args.verbose, + args.dry_run, + allow_live_scan=args.allow_live_scan, + ) + print(f"Quotas de usuário ativadas em {quota_mp!r}.") + if not args.dry_run: + note_ext4_quota_deprecation_context(quota_mp) + else: + raise BootstrapError( + f"usrquota ainda não aparece ativo em {quota_mp!r}. " + "Reinicie a VM e rode o script novamente." + ) + else: + print("[5/6] Quotas puladas por --no-quota") + print("[6/6] Nada a fazer em quotas") + + print() + print("Concluído.") + print("Próximos passos:") + if quota_mp is not None: + print(f"- Confirmar mount de quotas: mount | grep ' on {quota_mp} '") + else: + print("- Confirmar mounts e usrquota conforme a sua configuração") + print("- Checar quotas: quota -vs") + print("- Contas: usar create_runv_user.py (setquota) conforme create_runv_user.md") + print("- Reinício (se precisar): sudo reboot ou /sbin/reboot") + return 0 + + except BootstrapError as exc: + eprint(f"ERRO: {exc}") + return 2 + except KeyboardInterrupt: + eprint("Interrompido pelo usuário.") + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/update_user.py b/scripts/admin/update_user.py @@ -0,0 +1,752 @@ +#!/usr/bin/env python3 +""" +Atualiza utilizador Unix existente no runv.club: metadados (email), chave SSH, +palavra-passe de login (chpasswd) e quotas ext4 (setquota). + +Executar como root. Alinha-se a create_runv_user / del-user / runv_mount. + +Modo interativo no terminal (sem argumentos ou -i) ou flags CLI. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import fcntl +import getpass +import json +import os +import pwd +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from collections.abc import Callable +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)) + +USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") +EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$" +) +ALLOWED_KEY_TYPES: Final[tuple[str, ...]] = ( + "ssh-ed25519", + "sk-ssh-ed25519@openssh.com", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-rsa", +) +FINGERPRINT_SHA256_RE: Final[re.Pattern[str]] = re.compile(r"\b(SHA256:[+A-Za-z0-9/_=-]+)\b") + +DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") +DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock") + +DEFAULT_QUOTA_SOFT_MIB: Final[int] = 450 +DEFAULT_QUOTA_HARD_MIB: Final[int] = 500 +DEFAULT_QUOTA_INODE_SOFT: Final[int] = 10_000 +DEFAULT_QUOTA_INODE_HARD: Final[int] = 12_000 + +VERSION: Final[str] = "0.01" +EXIT_OK: Final[int] = 0 +EXIT_VALIDATION: Final[int] = 1 +EXIT_SYSTEM: Final[int] = 2 + +MIN_UID_NORMAL_USER: Final[int] = 1000 + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def require_root(*, dry_run: bool) -> None: + if not dry_run and os.geteuid() != 0: + eprint("Erro: execute como root (sudo).") + raise SystemExit(EXIT_VALIDATION) + + +def validate_username_syntax(username: str) -> str: + if not username or not username.strip(): + eprint("Erro: username é obrigatório.") + raise SystemExit(EXIT_VALIDATION) + u = username.strip() + if not USERNAME_PATTERN.fullmatch(u): + eprint( + "Erro: username inválido (letras minúsculas, dígitos, _ e -; 2–32 chars, começa com letra)." + ) + raise SystemExit(EXIT_VALIDATION) + return u + + +def validate_email(email: str) -> str: + e = email.strip() + if not EMAIL_PATTERN.fullmatch(e): + raise ValueError("formato de email inválido") + return e + + +def check_user_exists(username: str) -> tuple[int, int, Path]: + try: + pw = pwd.getpwnam(username) + except KeyError: + eprint(f"Erro: utilizador {username!r} não existe no sistema.") + raise SystemExit(EXIT_VALIDATION) + if pw.pw_uid < MIN_UID_NORMAL_USER: + eprint(f"Erro: UID {pw.pw_uid} < {MIN_UID_NORMAL_USER} (conta de sistema).") + raise SystemExit(EXIT_VALIDATION) + return pw.pw_uid, pw.pw_gid, Path(pw.pw_dir) + + +def normalize_public_key(raw: str) -> str: + if "\n" in raw or "\r" in raw: + raise ValueError("chave deve ser uma única linha") + line = raw.strip() + if not line: + raise ValueError("chave vazia") + parts = line.split() + if len(parts) < 2: + raise ValueError("chave malformada") + if parts[0] not in ALLOWED_KEY_TYPES: + raise ValueError(f"tipo de chave não permitido: {parts[0]!r}") + blob = parts[1] + if not re.fullmatch(r"[A-Za-z0-9+/]+=*", blob): + raise ValueError("dados base64 inválidos") + out = parts[0] + " " + blob + if len(parts) > 2: + out += " " + " ".join(parts[2:]) + return out + + +def compute_public_key_fingerprint(public_key_line: str) -> str: + line = normalize_public_key(public_key_line) + fd, tmppath = tempfile.mkstemp(prefix="runv-upd-key-", suffix=".pub") + path = Path(tmppath) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(line + "\n") + proc = subprocess.run( + ["ssh-keygen", "-l", "-E", "sha256", "-f", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + raise ValueError(f"ssh-keygen: {err}") + first = (proc.stdout or "").strip().splitlines()[0] + m = FINGERPRINT_SHA256_RE.search(first) + if not m: + raise ValueError(f"fingerprint não encontrado: {first!r}") + return m.group(1) + finally: + path.unlink(missing_ok=True) + + +def mib_to_setquota_kib(mib: int) -> int: + if mib < 0: + raise ValueError("MiB negativo") + return mib * 1024 + + +def quota_probe_path(home: Path) -> Path: + p = home.resolve() + if p.is_dir(): + return p + return p.parent if p.parent != p else Path("/").resolve() + + +def apply_setquota( + username: str, + home: Path, + soft_mib: int, + hard_mib: int, + inode_soft: int, + inode_hard: int, + *, + dry_run: bool, +) -> tuple[str, str]: + from runv_mount import MountLookupError, find_mount_triple, quota_opts_allow_user + + if soft_mib > hard_mib or inode_soft > inode_hard: + raise ValueError("soft não pode exceder hard (blocos ou inodes)") + probe = quota_probe_path(home) + try: + target, fstype, opts = find_mount_triple(probe) + except MountLookupError as e: + raise RuntimeError(str(e)) from e + if fstype != "ext4" or not quota_opts_allow_user(opts): + raise RuntimeError(f"sem ext4+usrquota em {target!r}") + if not shutil.which("setquota"): + raise RuntimeError("comando setquota não encontrado (apt install quota)") + bs = mib_to_setquota_kib(soft_mib) + bh = mib_to_setquota_kib(hard_mib) + cmd = ["setquota", "-u", username, str(bs), str(bh), str(inode_soft), str(inode_hard), target] + if dry_run: + print(f" [dry-run] {' '.join(cmd)}") + return target, fstype + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"setquota falhou: {err}") + return target, fstype + + +def write_authorized_keys_replace( + home: Path, + uid: int, + gid: int, + public_key_line: str, + *, + dry_run: bool, +) -> None: + line = normalize_public_key(public_key_line) + ssh_dir = home / ".ssh" + auth = ssh_dir / "authorized_keys" + if dry_run: + print(f" [dry-run] escreveria {auth} com uma linha") + return + ssh_dir.mkdir(parents=True, exist_ok=True) + os.chmod(ssh_dir, 0o700) + os.chown(ssh_dir, uid, gid) + auth.write_text(line + "\n", encoding="utf-8") + os.chmod(auth, 0o600) + os.chown(auth, uid, gid) + + +def write_authorized_keys_append( + home: Path, + uid: int, + gid: int, + public_key_line: str, + *, + dry_run: bool, +) -> None: + line = normalize_public_key(public_key_line) + ssh_dir = home / ".ssh" + auth = ssh_dir / "authorized_keys" + if dry_run: + print(f" [dry-run] acrescentaria chave em {auth}") + return + ssh_dir.mkdir(parents=True, exist_ok=True) + os.chmod(ssh_dir, 0o700) + os.chown(ssh_dir, uid, gid) + if auth.exists(): + existing = auth.read_text(encoding="utf-8") + if line in existing.splitlines(): + print(" [info] authorized_keys já continha esta chave.") + else: + with open(auth, "a", encoding="utf-8") as f: + f.write(line + "\n") + else: + auth.write_text(line + "\n", encoding="utf-8") + os.chmod(auth, 0o600) + os.chown(auth, uid, gid) + + +def set_password_chpasswd(username: str, password: str, *, dry_run: bool) -> None: + if dry_run: + print(f" [dry-run] chpasswd para {username!r}") + return + r = subprocess.run( + ["chpasswd"], + input=f"{username}:{password}\n", + text=True, + capture_output=True, + timeout=60, + ) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"chpasswd falhou: {err}") + + +def mutate_metadata( + metadata_path: Path, + lock_path: Path, + *, + dry_run: bool, + mutator: Callable[[list[dict[str, Any]]], bool], +) -> bool: + """ + Lê lista JSON sob flock, chama mutator(data) -> True se deve gravar. + Gravação atómica na mesma secção crítica. + """ + metadata_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_f = open(lock_path, "a+", encoding="utf-8") + try: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) + if not metadata_path.is_file(): + data: list[dict[str, Any]] = [] + else: + raw = metadata_path.read_text(encoding="utf-8").strip() + if not raw: + data = [] + else: + parsed = json.loads(raw) + if not isinstance(parsed, list): + raise ValueError("users.json: esperada lista JSON") + data = parsed + if not mutator(data): + return False + if dry_run: + print(f" [dry-run] gravaria {len(data)} entradas em {metadata_path}") + return True + tmp_fd, tmp_name = tempfile.mkstemp( + prefix="users.", + suffix=".tmp", + dir=str(metadata_path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as out: + json.dump(data, out, indent=2, ensure_ascii=False) + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_path, metadata_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + return True + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + lock_f.close() + + +def find_metadata_index(data: list[dict[str, Any]], username: str) -> int | None: + for i, row in enumerate(data): + if isinstance(row, dict) and row.get("username") == username: + return i + return None + + +def update_metadata_email( + metadata_path: Path, + lock_path: Path, + username: str, + email: str, + *, + dry_run: bool, +) -> bool: + def m(data: list[dict[str, Any]]) -> bool: + idx = find_metadata_index(data, username) + if idx is None: + eprint( + f"Aviso: sem entrada em {metadata_path} para {username!r}; email não gravado em JSON." + ) + return False + data[idx]["email"] = email + return True + + ok = mutate_metadata(metadata_path, lock_path, dry_run=dry_run, mutator=m) + if ok: + print(f" [ok] email em metadados atualizado para {email!r}") + return ok + + +def update_metadata_after_key( + metadata_path: Path, + lock_path: Path, + username: str, + fingerprint: str, + *, + dry_run: bool, +) -> None: + def m(data: list[dict[str, Any]]) -> bool: + idx = find_metadata_index(data, username) + if idx is None: + eprint(f"Aviso: sem entrada em metadados para {username!r}; fingerprint não gravado.") + return False + data[idx]["public_key_fingerprint"] = fingerprint + return True + + if mutate_metadata(metadata_path, lock_path, dry_run=dry_run, mutator=m): + print(f" [ok] fingerprint em metadados: {fingerprint}") + + +def update_metadata_after_quota( + metadata_path: Path, + lock_path: Path, + username: str, + soft_mib: int, + hard_mib: int, + inode_soft: int, + inode_hard: int, + mountpoint: str, + fstype: str, + *, + dry_run: bool, +) -> None: + def m(data: list[dict[str, Any]]) -> bool: + idx = find_metadata_index(data, username) + if idx is None: + eprint( + f"Aviso: sem entrada em metadados para {username!r}; quotas não reflectidas no JSON." + ) + return False + now = datetime.now(timezone.utc).isoformat() + row = data[idx] + row["quota_enabled"] = True + row["quota_soft_mb"] = soft_mib + row["quota_hard_mb"] = hard_mib + row["quota_inode_soft"] = inode_soft + row["quota_inode_hard"] = inode_hard + row["quota_mountpoint"] = mountpoint + row["quota_filesystem"] = fstype + row["quota_applied_at"] = now + row["quota_status"] = "applied" + if row.get("status") == "partial_quota": + row["status"] = "active" + return True + + if mutate_metadata(metadata_path, lock_path, dry_run=dry_run, mutator=m): + print(" [ok] campos de quota actualizados em metadados") + + +def prompt_line(msg: str, default: str | None = None) -> str: + if default is not None: + s = input(f"{msg} [{default}]: ").strip() + return s if s else default + return input(f"{msg}: ").strip() + + +def interactive_loop( + username: str, + uid: int, + gid: int, + home: Path, + metadata_path: Path, + lock_path: Path, + *, + dry_run: bool, + skip_metadata: bool, +) -> None: + print() + print(f"Utilizador: {username} (uid={uid}, home={home})") + print("Escolha o que alterar (número). Repita até terminar.") + print(" 1) Email (apenas metadados users.json)") + print(" 2) Substituir ~/.ssh/authorized_keys por UMA chave (política runv típica)") + print(" 3) Acrescentar chave a authorized_keys") + print(" 4) Definir palavra-passe de login (chpasswd) — o runv costuma usar só SSH por chave") + print(" 5) Aplicar quota (MiB soft/hard + inodes, como create_runv_user)") + print(" 0) Sair") + print() + while True: + choice = input("Opção [0]: ").strip() or "0" + if choice == "0": + break + if choice == "1": + if skip_metadata: + print(" [skip] --skip-metadata activo.") + continue + em = prompt_line("Novo email administrativo") + if not em: + continue + try: + em = validate_email(em) + except ValueError as e: + eprint(f"Erro: {e}") + continue + update_metadata_email(metadata_path, lock_path, username, em, dry_run=dry_run) + elif choice == "2": + print("Cole UMA linha de chave pública OpenSSH (Enter para cancelar):") + line = input().strip() + if not line: + continue + try: + fp = compute_public_key_fingerprint(line) + write_authorized_keys_replace(home, uid, gid, line, dry_run=dry_run) + if not skip_metadata: + update_metadata_after_key( + metadata_path, lock_path, username, fp, dry_run=dry_run + ) + except ValueError as e: + eprint(f"Erro: {e}") + elif choice == "3": + print("Cole linha de chave a acrescentar:") + line = input().strip() + if not line: + continue + try: + write_authorized_keys_append(home, uid, gid, line, dry_run=dry_run) + print(" [ok] chave acrescentada (metadados: use opção 2 ou edite JSON se quiser fingerprint único)") + except ValueError as e: + eprint(f"Erro: {e}") + elif choice == "4": + if not sys.stdin.isatty(): + eprint("Palavra-passe: use terminal interactivo ou não use esta opção.") + continue + p1 = getpass.getpass("Nova palavra-passe: ") + p2 = getpass.getpass("Repita: ") + if p1 != p2: + eprint("As palavras-passe não coincidem.") + continue + if not p1: + eprint("Palavra-passe vazia recusada.") + continue + try: + set_password_chpasswd(username, p1, dry_run=dry_run) + print(" [ok] palavra-passe alterada (login shell / chpasswd)") + except RuntimeError as e: + eprint(str(e)) + elif choice == "5": + try: + sm = int(prompt_line("MiB soft", str(DEFAULT_QUOTA_SOFT_MIB))) + hm = int(prompt_line("MiB hard", str(DEFAULT_QUOTA_HARD_MIB))) + isoft = int(prompt_line("Inode soft", str(DEFAULT_QUOTA_INODE_SOFT))) + ihard = int(prompt_line("Inode hard", str(DEFAULT_QUOTA_INODE_HARD))) + except ValueError: + eprint("Números inválidos.") + continue + try: + mp, fs = apply_setquota( + username, home, sm, hm, isoft, ihard, dry_run=dry_run + ) + if not skip_metadata: + update_metadata_after_quota( + metadata_path, + lock_path, + username, + sm, + hm, + isoft, + ihard, + mp, + fs, + dry_run=dry_run, + ) + except (ValueError, RuntimeError) as e: + eprint(str(e)) + else: + print("Opção desconhecida.") + print() + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Atualiza utilizador runv: email (JSON), SSH, palavra-passe, quota.", + ) + p.add_argument("--username", "-u", metavar="USER", help="utilizador Unix existente") + p.add_argument( + "-i", + "--interactive", + action="store_true", + help="menu interactivo (também é o padrão se não houver flags de alteração)", + ) + p.add_argument("--email", metavar="ADDR", help="email em users.json") + p.add_argument( + "--replace-public-key", + metavar="LINE", + help="substitui authorized_keys por esta linha OpenSSH", + ) + p.add_argument( + "--append-public-key", + metavar="LINE", + help="acrescenta linha a authorized_keys", + ) + p.add_argument( + "--ssh-replace-file", + type=Path, + metavar="PATH", + help="ficheiro com uma linha OpenSSH (substitui authorized_keys)", + ) + p.add_argument( + "--ssh-append-file", + type=Path, + metavar="PATH", + help="ficheiro com uma linha OpenSSH (acrescenta a authorized_keys)", + ) + p.add_argument( + "--set-password", + action="store_true", + help="pede nova palavra-passe (getpass); requer TTY", + ) + p.add_argument("--quota-soft-mb", type=int, metavar="MiB", default=None) + p.add_argument("--quota-hard-mb", type=int, metavar="MiB", default=None) + p.add_argument("--quota-inode-soft", type=int, default=None) + p.add_argument("--quota-inode-hard", type=int, default=None) + p.add_argument("--dry-run", action="store_true") + p.add_argument( + "--skip-metadata", + action="store_true", + help="não lê nem grava users.json", + ) + p.add_argument("--metadata-file", type=Path, default=DEFAULT_METADATA_PATH) + p.add_argument("--lock-file", type=Path, default=DEFAULT_LOCK_PATH) + p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club") + return p.parse_args(argv) + + +def read_key_file(path: Path) -> str: + raw = path.read_text(encoding="utf-8").strip() + lines = [ln.strip() for ln in raw.splitlines() if ln.strip() and not ln.strip().startswith("#")] + if len(lines) != 1: + raise ValueError("ficheiro deve conter exactamente uma linha de chave (sem comentários)") + return lines[0] + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + dry_run = args.dry_run + require_root(dry_run=dry_run) + + has_quota_flag = any( + [ + args.quota_soft_mb is not None, + args.quota_hard_mb is not None, + args.quota_inode_soft is not None, + args.quota_inode_hard is not None, + ] + ) + has_cli_change = any( + [ + args.email, + args.replace_public_key, + args.append_public_key, + args.ssh_replace_file is not None, + args.ssh_append_file is not None, + args.set_password, + has_quota_flag, + ] + ) + + if not args.username: + if not sys.stdin.isatty(): + eprint("Erro: indique --username ou execute em modo interactivo com TTY.") + return EXIT_VALIDATION + u = prompt_line("Username Unix a atualizar") + username = validate_username_syntax(u) + else: + username = validate_username_syntax(args.username) + + uid, gid, home = check_user_exists(username) + + if args.interactive or not has_cli_change: + if args.interactive and has_cli_change: + eprint("Aviso: com -i/--interactive o menu ignora outras flags de alteração nesta execução.") + if args.set_password and not sys.stdin.isatty(): + eprint("Erro: --set-password requer TTY.") + return EXIT_VALIDATION + print(f"== update_user.py v{VERSION} — runv.club ==") + interactive_loop( + username, + uid, + gid, + home, + args.metadata_file, + args.lock_file, + dry_run=dry_run, + skip_metadata=args.skip_metadata, + ) + return EXIT_OK + + pk_replace: str | None = args.replace_public_key + if args.ssh_replace_file is not None: + if pk_replace is not None: + eprint("Erro: use só uma de --replace-public-key ou --ssh-replace-file.") + return EXIT_VALIDATION + try: + pk_replace = read_key_file(args.ssh_replace_file) + except (OSError, ValueError) as e: + eprint(f"Erro: {e}") + return EXIT_VALIDATION + + pk_append: str | None = args.append_public_key + if args.ssh_append_file is not None: + if pk_append is not None: + eprint("Erro: use só uma de --append-public-key ou --ssh-append-file.") + return EXIT_VALIDATION + try: + pk_append = read_key_file(args.ssh_append_file) + except (OSError, ValueError) as e: + eprint(f"Erro: {e}") + return EXIT_VALIDATION + + if pk_replace is not None and pk_append is not None: + eprint("Erro: numa só execução use substituir chave OU acrescentar, não ambos.") + return EXIT_VALIDATION + + try: + if args.email: + if args.skip_metadata: + eprint("Erro: --email requer metadados; não use --skip-metadata.") + return EXIT_VALIDATION + em = validate_email(args.email) + update_metadata_email( + args.metadata_file, args.lock_file, username, em, dry_run=dry_run + ) + + if pk_replace: + fp = compute_public_key_fingerprint(pk_replace) + write_authorized_keys_replace(home, uid, gid, pk_replace, dry_run=dry_run) + if not args.skip_metadata: + update_metadata_after_key( + args.metadata_file, args.lock_file, username, fp, dry_run=dry_run + ) + + if pk_append: + write_authorized_keys_append(home, uid, gid, pk_append, dry_run=dry_run) + + if args.set_password: + if not sys.stdin.isatty(): + eprint("Erro: --set-password requer TTY (use modo interactivo).") + return EXIT_VALIDATION + p1 = getpass.getpass("Nova palavra-passe: ") + p2 = getpass.getpass("Repita: ") + if p1 != p2 or not p1: + eprint("Palavra-passe inválida ou não coincide.") + return EXIT_VALIDATION + set_password_chpasswd(username, p1, dry_run=dry_run) + print(" [ok] palavra-passe alterada") + + if ( + args.quota_soft_mb is not None + or args.quota_hard_mb is not None + or args.quota_inode_soft is not None + or args.quota_inode_hard is not None + ): + sm = args.quota_soft_mb if args.quota_soft_mb is not None else DEFAULT_QUOTA_SOFT_MIB + hm = args.quota_hard_mb if args.quota_hard_mb is not None else DEFAULT_QUOTA_HARD_MIB + iso = ( + args.quota_inode_soft + if args.quota_inode_soft is not None + else DEFAULT_QUOTA_INODE_SOFT + ) + ihd = ( + args.quota_inode_hard + if args.quota_inode_hard is not None + else DEFAULT_QUOTA_INODE_HARD + ) + mp, fs = apply_setquota(username, home, sm, hm, iso, ihd, dry_run=dry_run) + if not args.skip_metadata: + update_metadata_after_quota( + args.metadata_file, + args.lock_file, + username, + sm, + hm, + iso, + ihd, + mp, + fs, + dry_run=dry_run, + ) + except ValueError as e: + eprint(f"Erro: {e}") + return EXIT_VALIDATION + except RuntimeError as e: + eprint(str(e)) + return EXIT_SYSTEM + + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/create_runv_user.md b/scripts/create_runv_user.md @@ -0,0 +1,252 @@ +# create_runv_user — provisionamento interno (runv.club) + +**Versão 0.01** · **Desenvolvido por pmurad — 2026** + +Ferramenta de linha de comando para **administradores** criarem contas Unix no servidor **Debian/Linux** (runv.club). Não é cadastro público. + +É a **fonte principal** da política de provisionamento: usa `adduser`, mas o fluxo completo (SSH, `public_html`, `README.md`, permissões, quota, metadados, log) está centralizado aqui — sem depender de `adduser.local`, `QUOTAUSER` ou regras em `/etc/adduser.conf`. + +**Ambiente:** execute apenas no servidor (ou VM Debian). O script usa `pwd`, `fcntl`, `adduser`, `ssh-keygen`, `findmnt`/`setquota` — **não é suportado no Windows.** + +### O que o script garante (ordem de execução) + +1. **Criar o usuário** — `adduser --disabled-password` (conta Unix). +2. **Instalar a chave** — `~/.ssh/authorized_keys` com chave validada e modos `700` / `600`. +3. **Preparar `public_html`** — diretório `755` e `~/public_html/index.html` estático (sem JavaScript, sem CDN); não sobrescreve sem `--force-index`. +4. **Copiar o skel** — o Debian **copia `/etc/skel` para a home no passo 1**. Depois, o script acrescenta `~/README.md` runv em português (runv.club, URL `~/username/`, permissões, comandos, aviso sobre arquivos públicos); não sobrescreve sem **`--force-readme`**. Se o skel do sistema já tiver um `README.md`, ele permanece até usar `--force-readme`. Para padronizar o skel do servidor, use **[`skel.py`](skel.md)** antes de criar contas. +5. **Aplicar permissões** — `apply_runv_permissions` reforça home `755`, `.ssh` / `authorized_keys`, `public_html` / `index.html` e `README.md` com modos e donos corretos; em seguida quota (se ativa), verificação final e metadados. + +**Log** em arquivo (e stderr com `--verbose`) com estas fases numeradas, quota, metadados e verificação final. + +## Quota ext4 + +O `create_runv_user.py` descobre **automaticamente** o mount que contém `/home/username` (`findmnt` / `admin/runv_mount.py` no repositório) e aplica `setquota` nesse ponto — tanto se a home está na **raiz `/`** como se **`/home`** é um volume **ext4** separado. O filesystem tem de ser **ext4** com **`usrquota`** (ou **`usrjquota=`**) ativo nesse mount. + +- **Não** usa `xfs_quota` nem assume XFS. +- **Não** altera `/etc/fstab`, **não** remonta, **não** reinicia a máquina, **não** executa `quotaon` por si. +- **Apenas verifica** se o ambiente está pronto e, em caso afirmativo, aplica limites ao utilizador recém-criado. + +### Preparar o sistema (Debian 13) + +**Recomendado:** no servidor novo, correr **`admin/starthere.py`** (ver **[starthere.md](starthere.md)**) como root — instala pacotes, pode ativar Apache/UFW e configura **usrquota** no mount detetado a partir de `/home` (o mesmo critério que este script). + +**Alternativa manual** (se não usar `starthere.py`): + +1. Instalar ferramentas: `sudo apt install quota` +2. Em **`/etc/fstab`**, na linha do mount onde está a home (no caso típico, `/`), acrescentar **`usrquota`** (e opcionalmente `grpquota`) nas opções de mount, por exemplo: + `UUID=... / ext4 defaults,usrquota 0 1` +3. Remontar read-write com nova opção ou reiniciar: + `sudo mount -o remount /` +4. Inicializar ficheiros de quota (ajuste o mountpoint se não for `/`): + `sudo quotacheck -cum /` + (pode demorar; em sistemas com quota já ativa use os flags que a sua política recomendar.) +5. Ativar quotas: + `sudo quotaon -v /` +6. Confirmar: + `findmnt -n -o OPTIONS /` deve mostrar `usrquota` ou `usrjquota=...` + +Só depois disto o `create_runv_user.py` conseguirá aplicar limites automaticamente. + +### Política padrão runv (ajustável por flags) + +| Limite | Padrão | +|--------|--------| +| Blocos soft | 450 MiB | +| Blocos hard | 500 MiB | +| Inodes soft | 10000 | +| Inodes hard | 12000 | + +**Unidades:** os flags `--quota-soft-mb` e `--quota-hard-mb` usam o sufixo histórico `-mb`, mas os valores são **MiB** (mebibytes, 1024² bytes), **não** megabytes decimais (10⁶). Internamente convertem para as unidades de **1 KiB** que o `setquota` usa em ext4 (vfsv0): `kib = mib * 1024`. + +### Comportamento e política de falhas (v1) + +| Situação | Comportamento | +|----------|----------------| +| **Padrão** (sem `--no-quota`) | Após criar o utilizador, tenta aplicar quota. Se o sistema **não** estiver preparado ou `setquota` falhar, a conta **permanece**, metadados gravados com `status: partial_quota` e `quota_status: failed` ou `not_configured`, **saída 3** (`EXIT_INCONSISTENT`), mensagem de aviso forte no stderr. | +| **`--require-quota`** | Antes de `adduser`, verifica ext4 + `usrquota`/usrjquota + `setquota`. Se falhar, **aborta sem criar** utilizador (saída 1). | +| **`--no-quota`** | Não chama `setquota`; metadados com `quota_enabled: false`, `quota_status: skipped`. | + +Não há remoção automática da conta quando só a quota falha; o admin decide (ex.: `del-user.py` ou `deluser` manual). + +## Modo interativo (recomendado) + +Sem argumentos, o script entra em **modo interativo**: mostra o cabeçalho (versão e crédito), faz perguntas e você responde no terminal. + +```bash +sudo python3 /usr/local/bin/create_runv_user.py +``` + +Ou explicitamente: + +```bash +sudo python3 /usr/local/bin/create_runv_user.py --interactive +# ou +sudo python3 /usr/local/bin/create_runv_user.py -i +``` + +Fluxo típico: + +1. Nome de usuário Unix +2. Email administrativo (metadado) +3. Chave SSH: colar **uma linha** OpenSSH ou indicar **caminho** de um arquivo `.pub` +4. Dry-run (só validar, sem criar usuário) — sim/não +5. Se for criar de verdade: sobrescrever `index.html` existente — sim/não +6. Se for criar de verdade: sobrescrever `README.md` existente — sim/não +7. Log verboso — sim/não +8. Criar **sem** quota (`--no-quota`) — sim/não (padrão não) +9. Se for com quota: exigir sistema pronto **antes** de criar (`--require-quota`) — sim/não (padrão não) +10. Confirmação final antes de executar + +`Ctrl+C` cancela. Se responder “não” na confirmação final, o script encerra sem alterar o sistema. + +## Modo não interativo (CLI) + +Nos exemplos com caminho **`admin/create_runv_user.py`**, execute a partir do diretório **`scripts/`** do repositório (ou ajuste o caminho). Em produção, use normalmente **`/usr/local/bin/create_runv_user.py`** após `install`. + +### Criação normal com quota (padrão) + +```bash +sudo python3 admin/create_runv_user.py \ + --username alice \ + --email alice@example.com \ + --public-key "ssh-ed25519 AAAA... comentario" +``` + +### Sem quota + +```bash +sudo python3 admin/create_runv_user.py \ + --username alice \ + --email alice@example.com \ + --public-key-file /root/alice.pub \ + --no-quota +``` + +### Exigir quota configurada antes de criar + +```bash +sudo python3 admin/create_runv_user.py \ + -u alice \ + --email alice@example.com \ + --public-key "ssh-ed25519 AAAA..." \ + --require-quota +``` + +Se `usrquota` não estiver ativo em `/`, o script termina **sem** chamar `adduser`. + +### Dry-run + +```bash +python3 admin/create_runv_user.py \ + --username alice \ + --email alice@example.com \ + --public-key "ssh-ed25519 AAAA..." \ + --dry-run +``` + +(Não exige root.) + +### Limites personalizados + +```bash +sudo python3 admin/create_runv_user.py \ + -u bob \ + --email bob@example.com \ + --public-key "..." \ + --quota-soft-mb 400 \ + --quota-hard-mb 450 \ + --quota-inode-soft 8000 \ + --quota-inode-hard 9000 +``` + +### Exemplo: falha por quota não habilitada + +Com utilizador criado com sucesso mas mount **sem** `usrquota`: + +- Stderr: aviso forte de conta criada sem quota aplicada. +- Exit code **3**. +- Em `/var/lib/runv/users.json`: `status: partial_quota`, `quota_status: not_configured` (ou `failed` se `setquota` falhou). + +Versão e crédito: + +```bash +python3 admin/create_runv_user.py --version +``` + +## Pré-requisitos no servidor + +- Debian 13 (ou outro Linux com `adduser` e `deluser`) +- Python 3 (`python3`) +- Pacotes: `openssh-client` (`ssh-keygen`), `adduser`, **`quota`** (para `setquota`), **`util-linux`** (`findmnt`) +- Para quota: ext4 com **`usrquota`** (ou **`usrjquota=`**) no mount que contém `/home` +- Apache com `mod_userdir` já configurado (o script não altera o Apache) +- SSH com chaves (o script não altera `sshd_config`) + +## Instalação + +```bash +sudo install -m 755 admin/create_runv_user.py /usr/local/bin/create_runv_user.py +sudo mkdir -p /var/lib/runv +``` + +Log padrão: `/var/log/runv-user-provision.log` +Metadados: `/var/lib/runv/users.json` + +## Opções úteis (CLI) + +- `--dry-run` — valida tudo e mostra o plano sem criar usuário +- `--verbose` — mais detalhes no stderr +- `--force-index` — sobrescreve `~/public_html/index.html` se já existir +- `--force-readme` — sobrescreve `~/README.md` se já existir (útil se o skel do sistema já criou um README) +- `--no-quota` — não aplica `setquota` +- `--require-quota` — falha antes de `adduser` se quota não estiver disponível +- `--quota-soft-mb`, `--quota-hard-mb`, `--quota-inode-soft`, `--quota-inode-hard` — limites (MiB para blocos) +- `--metadata-file`, `--lock-file`, `--log-file` — caminhos alternativos (ex.: testes em VM) +- `--base-url` — URL base no resumo (padrão `http://runv.club`) + +## Metadados JSON (campos de quota) + +Cada registo pode incluir: + +- `quota_enabled` (bool) +- `quota_soft_mb`, `quota_hard_mb` (int ou null) +- `quota_inode_soft`, `quota_inode_hard` (int ou null) +- `quota_filesystem`, `quota_mountpoint` (string ou null) +- `quota_applied_at` (ISO 8601 ou null) +- `quota_status`: `skipped` | `applied` | `failed` | `not_configured` + +## Códigos de saída + +| Código | Significado | +|--------|-------------| +| 0 | Sucesso (utilizador criado e, se aplicável, quota aplicada) | +| 1 | Erro de validação ou argumentos (incl. `--require-quota` com sistema não pronto) | +| 2 | Falha de sistema (subprocess, permissões) antes/desde rollback completo | +| 3 | Estado inconsistente: utilizador criado mas quota não aplicada / não configurada; ou rollback falhou | + +## Como testar no Debian 13 (resumo) + +1. Configure quota no `/` conforme a secção “Preparar o sistema”. +2. `sudo python3 admin/create_runv_user.py --username testquota ... --verbose` +3. `sudo quota -u testquota` ou `repquota /` para ver limites. +4. Teste `--dry-run` sem root. +5. Teste `--require-quota` com fstab **sem** usrquota: deve sair **1** sem criar utilizador. +6. Remova o utilizador de teste com a sua ferramenta de banimento (`admin/del-user.py`) quando terminar. + +## Segurança (resumo) + +- Sem `shell=True`; subprocess só com lista de argumentos. +- Username e caminhos validados; sem path traversal. +- Chave pública validada com `ssh-keygen`; fingerprint SHA256 em metadados. +- Email é só metadado administrativo, não conta Unix. + +## Limitações + +- Quota suportada: **ext4** com quota de utilizador tradicional; outros filesystems recusados com mensagem clara. +- Sem remoção de utilizador por este script (use `admin/del-user.py` a partir de `scripts/` no repositório, ou a cópia em `/usr/local/bin` se instalou). +- O script **não** configura automaticamente fstab nem `quotaon`. +- Backup de `/var/lib/runv/users.json` é manual. + +## Dependências Python + +Nenhuma biblioteca PyPI — apenas a biblioteca padrão (ver `requirements.txt`). diff --git a/scripts/del-user.md b/scripts/del-user.md @@ -0,0 +1,122 @@ +# del-user.py — banimento / remoção de conta (runv.club) + +**Versão 0.01** · runv.club + +Ferramenta para **administradores** removerem **permanentemente** um utilizador Unix no Debian (banimento no runv.club): apaga a conta e, por defeito, a home com `deluser --remove-home`. + +- **Não** remove nem altera configuração do Apache ou SSH globalmente. +- Opcionalmente remove a entrada correspondente em `/var/lib/runv/users.json` (mesmo formato que `create_runv_user.py`). +- Se a home estiver num **ext4** com **usrquota** ativo, tenta **`setquota`** para repor limites a zero **antes** de `deluser` (mount detetado automaticamente, mesma lógica que `create_runv_user.py` / `runv_mount.py`). Se `setquota` falhar, a remoção da conta continua com aviso em stderr. + +**Ambiente:** servidor **Linux** (Debian). Executar como **root** ou `sudo`. No Windows use só para revisão do código. + +## Objetivo + +- Eliminar o utilizador do sistema (`deluser`). +- Remover a pasta home (`--remove-home`) ou, se pedido, todos os ficheiros detidos pelo UID (`--purge-all-files`). +- Manter o registo interno coerente ao apagar o username do JSON de metadados runv (opcional). + +## Segurança + +- **Nunca** remove `root`. +- Recusa contas **reservadas** (ex.: `www-data`, `nobody`) salvo `--force`. +- Recusa UID **&lt; 1000** (contas de sistema típicas) salvo `--force`. +- Confirmação interativa: tem de **digitar o username** à letra (salvo `-y`/`--yes`). +- Sem `shell=True`; usa `subprocess` com lista de argumentos. + +## Requisitos + +- Pacote Debian `adduser` (fornece o comando `deluser`). +- Python 3 (stdlib: `pathlib`, `fcntl`, `json`, etc.). + +## Uso + +Nos exemplos com **`admin/del-user.py`**, execute a partir do diretório **`scripts/`** do repositório. + +### Simular (sem root) + +```bash +python3 admin/del-user.py -u alguem --dry-run +python3 admin/del-user.py -u alguem --dry-run --verbose +``` + +### Remover (interativo) + +```bash +sudo python3 admin/del-user.py --username spammer +``` + +O script pede que escreva de novo o username para confirmar. + +### Remover sem pergunta (automação / scripts) + +```bash +sudo python3 admin/del-user.py -u spammer --yes +``` + +### Remover também ficheiros do utilizador fora da home + +Cuidado: apaga **todos** os ficheiros detidos por esse UID no sistema. + +```bash +sudo python3 admin/del-user.py -u spammer --yes --purge-all-files +``` + +### Não tocar no `users.json` + +```bash +sudo python3 admin/del-user.py -u spammer --yes --skip-metadata +``` + +### Forçar remoção de conta “de sistema” (perigoso) + +```bash +sudo python3 admin/del-user.py -u algum --yes --force +``` + +## Opções + +| Opção | Significado | +|--------|-------------| +| `-u`, `--username` | Utilizador a remover (obrigatório). | +| `--dry-run` | Só mostra o plano; não exige root. | +| `-v`, `--verbose` | Mais saída (comando `deluser`, etc.). | +| `-y`, `--yes` | Não pede confirmação interativa. | +| `--force` | Ignora bloqueio a contas reservadas / UID &lt; 1000. | +| `--purge-all-files` | Usa `deluser --remove-all-files` em vez de `--remove-home`. | +| `--skip-metadata` | Não altera `/var/lib/runv/users.json`. | +| `--metadata-file` | Caminho alternativo ao JSON de metadados. | +| `--lock-file` | Lock `flock` para escrita do JSON (default runv). | + +## Códigos de saída + +- `0` — sucesso. +- `1` — validação / utilizador inexistente / confirmação cancelada. +- `2` — falha de `deluser` ou erro ao gravar metadados. + +## Limitações + +- Se o utilizador tiver sessões ativas ou processos a correr, `deluser` pode falhar ou comportar-se de forma estranha — termine sessões antes, se necessário. +- `--purge-all-files` pode afetar ficheiros em diretórios partilhados se o UID tiver dono em mais sítios; use com consciência. +- O script **não** revoga tokens ou chaves noutros serviços (só o que o SO e os teus processos fizerem com a conta removida). + +## Exemplo de saída (trecho) + +``` +del-user.py — removendo 'spammer' (UID 1005) + + [exec] deluser --remove-home spammer + [ok] deluser concluído para 'spammer' + [metadata] removido registo de 'spammer' em /var/lib/runv/users.json + +--- Resumo --- + Conta removida: 'spammer' + Próximo passo: verificar se não restam processos desse UID ... +``` + +## Relação com outros scripts + +- **`create_runv_user.py`**: cria conta e acrescenta linha ao JSON. +- **`del-user.py`**: remove conta e remove a linha com o mesmo `username` no JSON (salvo `--skip-metadata`). + +— runv.club diff --git a/scripts/docs/1 - begining.md b/scripts/docs/1 - begining.md @@ -0,0 +1,184 @@ +# runv.club – Initial Server Setup (SSH Hardening Guide) + +## Configurações básicas do servidor (Debian) + +Antes do hardening SSH e dos scripts em `scripts/admin/` (`starthere.py`, `create_runv_user.py`, …), convém deixar o sistema **identificável**, com **hora fiável** e **locale** coerente. Executar como **root** ou `sudo` onde indicado. + +### Nome do host (hostname) + +Escolha um nome estável (ex.: `runv-debian`, `runv-prod`). Evite espaços e caracteres estranhos. + +```bash +sudo hostnamectl set-hostname runv-debian +hostnamectl status +``` + +Garanta que o FQDN local resolve (muitas stacks Debian esperam uma linha `127.0.1.1`): + +```bash +grep -E '^127\.0\.1\.1' /etc/hosts || \ + echo "127.0.1.1 runv-debian" | sudo tee -a /etc/hosts +``` + +(Ajuste `runv-debian` ao hostname que definiu.) + +### Fuso horário e relógio (NTP) + +Para servidores é comum **UTC**; se preferir hora local (ex. Portugal): + +```bash +sudo timedatectl set-timezone Europe/Lisbon +# ou: sudo timedatectl set-timezone UTC +sudo timedatectl set-ntp true +timedatectl status +``` + +Confirme que **NTP sync: yes** e que a data/hora estão corretas. Logs e metadados (`create_runv_user` grava timestamps) ficam alinhados. + +### Locale e teclado (opcional mas útil) + +```bash +sudo dpkg-reconfigure locales +``` + +Selecione pelo menos `en_US.UTF-8` ou `pt_PT.UTF-8` (UTF-8). Para consola: + +```bash +sudo dpkg-reconfigure keyboard-configuration +``` + +### Pacotes e índices APT + +Após timezone/locale: + +```bash +sudo apt update +sudo apt full-upgrade -y +``` + +### Notas para o projeto runv-server + +- **Debian recente** (ex. 13): alinhado a `scripts/requirements.txt` e aos guias em `scripts/*.md`. +- **Quotas ext4:** não edite à mão `fstab`/quotas se for usar **`starthere.py`** — ele deteta o mount de `/home` e prepara `usrquota` de forma coerente com **`create_runv_user.py`**. +- **Documentação interna:** anote hostname, IP público/privado e timezone num sítio da equipa (evita confusão entre VPS X / VPS Y). + +--- + +## Overview +This document describes the initial secure setup of the runv.club server on Debian 13. +The goal is to establish a safe baseline before installing pubnix / shared-hosting services for runv.club. + +--- + +## 1. Create Admin User + +```bash +adduser pmurad +adduser pmurad sudo +``` + +Verify: +```bash +id pmurad +``` + +Switch: +```bash +su - pmurad +``` + +Test: +```bash +sudo whoami +``` + +--- + +## 2. Generate SSH Key (Client) + +```powershell +ssh-keygen -t ed25519 -C "runv-sandbox" -f "$env:USERPROFILE\.ssh\runv-sandbox" +``` + +--- + +## 3. Install Public Key + +```bash +mkdir -p /home/pmurad/.ssh +chmod 700 /home/pmurad/.ssh + +cat > /home/pmurad/.ssh/authorized_keys <<'EOF' +<YOUR PUBLIC KEY> +EOF + +chmod 600 /home/pmurad/.ssh/authorized_keys +chown -R pmurad:pmurad /home/pmurad/.ssh +``` + +--- + +## 4. Test SSH Login + +```powershell +ssh -i "$env:USERPROFILE\.ssh\runv-sandbox" pmurad@SERVER_IP +``` + +--- + +## 5. Check SSH Config + +```bash +sudo sshd -T | grep -E 'passwordauthentication|pubkeyauthentication|permitrootlogin' +``` + +--- + +## 6. Disable Root Login + +```bash +sudo mkdir -p /etc/ssh/sshd_config.d + +sudo tee /etc/ssh/sshd_config.d/99-runv-hardening.conf > /dev/null <<'EOF' +PermitRootLogin no +EOF +``` + +Validate: +```bash +sudo sshd -t +sudo systemctl reload ssh +``` + +--- + +## 7. Disable Password Auth + +```bash +sudo tee /etc/ssh/sshd_config.d/99-runv-hardening.conf > /dev/null <<'EOF' +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +EOF +``` + +Reload: +```bash +sudo sshd -t +sudo systemctl reload ssh +``` + +Verify: +```bash +sudo sshd -T | grep -E 'passwordauthentication|pubkeyauthentication|permitrootlogin' +``` + +--- + +## Final State + +- Root login: disabled +- Password login: disabled +- Key authentication: enabled + +Secure SSH baseline achieved. diff --git a/scripts/docs/2 - server setup.md b/scripts/docs/2 - server setup.md @@ -0,0 +1,625 @@ +# runv.club – Fase 2: Apache, UserDir e o primeiro utilizador com site pessoal + +## Objetivo + +Este documento continua a configuração inicial do servidor **runv.club** em **Debian 13**. + +Neste ponto, o servidor já tem: + +- um usuário administrador sem root (`pmurad`) +- login por chave SSH funcionando +- login como root desativado no SSH +- autenticação por senha desativada no SSH + +Agora o objetivo é montar o **primeiro caminho real de publicação web por utilizador** (`~username`): + +- instalar o Apache +- habilitar páginas `~username` +- criar um usuário de teste +- fazer `~/public_html` funcionar +- confirmar que `http://SERVIDOR/~testuser/` carrega + +Esta é a primeira prova concreta de que a máquina serve sites pessoais por conta Unix no runv.club. + +--- + +## O que estamos construindo + +Neste desenho, cada utilizador publica um site a partir do diretório home. + +O padrão clássico é: + +- existe conta de usuário +- o usuário tem uma pasta chamada `public_html` +- o Apache serve essa pasta em: + +```text +http://seu-dominio/~username/ +``` + +Por exemplo, no runv.club, o alvo futuro é: + +```text +http://runv.club/~testuser/ +``` + +Para testes em VM local antes do DNS estar pronto, pode ser algo como: + +```text +http://192.168.x.x/~testuser/ +``` + +ou + +```text +http://runv-debian/~testuser/ +``` + +conforme sua rede e resolução de nomes. + +--- + +## Aviso importante antes de começar + +**Não** instale pacotes extras aleatórios ainda. + +Você **não** precisa agora de: + +- PHP +- MariaDB +- PostgreSQL +- Node.js +- Docker +- Certbot +- servidor de e-mail +- BBJ +- ttbp +- botany + +Isso seria prematuro e desnecessário. + +Por enquanto você só precisa do mínimo para provar: + +1. Apache funciona +2. `mod_userdir` funciona +3. permissões estão corretas +4. publicação a partir do home do usuário funciona + +Até isso funcionar, o resto é ruído. + +--- + +## Passo 1 – Atualizar listas de pacotes + +Entre como `pmurad` e execute: + +```bash +sudo apt update +``` + +Em seguida, atualize os pacotes instalados: + +```bash +sudo apt upgrade -y +``` + +Assim a máquina fica atualizada antes de instalar o Apache. + +--- + +## Passo 2 – Instalar o Apache + +Instale o Apache com: + +```bash +sudo apt install -y apache2 +``` + +Após a instalação, verifique se o serviço está em execução: + +```bash +sudo systemctl status apache2 +``` + +Você deve ver algo como: + +```text +active (running) +``` + +Se quiser uma verificação mais curta: + +```bash +systemctl is-active apache2 +``` + +Resultado esperado: + +```text +active +``` + +Se o Apache não estiver em execução, inicie-o: + +```bash +sudo systemctl start apache2 +``` + +E habilite na inicialização: + +```bash +sudo systemctl enable apache2 +``` + +--- + +## Passo 3 – Testar o Apache a partir da própria VM + +Teste localmente primeiro, de dentro do Debian: + +```bash +curl http://localhost +``` + +Você deve receber HTML da página padrão do Apache. + +Se o `curl` não estiver instalado: + +```bash +sudo apt install -y curl +``` + +Se o Apache estiver funcionando, isso confirma que o servidor web está ativo antes de mexer no `UserDir`. + +--- + +## Passo 4 – Liberar HTTP no firewall + +Se você habilitou o UFW antes, é preciso liberar o tráfego web. + +Verifique o status do firewall: + +```bash +sudo ufw status +``` + +Libere o Apache: + +```bash +sudo ufw allow 'Apache' +``` + +Verifique de novo: + +```bash +sudo ufw status +``` + +Você deve ver a porta 80 liberada. + +Se o UFW ainda não estiver habilitado, não é fatal em um sandbox de VM. Mesmo assim, se você pretende usá-lo, este é o momento certo para abrir o HTTP. + +--- + +## Passo 5 – Testar o Apache de outra máquina + +No seu Windows, abra o navegador e tente: + +```text +http://IP_DA_VM/ +``` + +Exemplo: + +```text +http://192.168.50.120/ +``` + +Você deve ver a página padrão do Apache. + +Se **não** vir, o problema é um destes: + +- Apache não está em execução +- firewall bloqueando a porta 80 +- IP da VM errado +- problema de bridge/rede no Proxmox +- o navegador está batendo na máquina errada + +**Não** continue até a página padrão do Apache funcionar. + +--- + +## Passo 6 – Habilitar o módulo UserDir + +É o recurso que permite: + +```text +/~username/ +``` + +Habilite com: + +```bash +sudo a2enmod userdir +``` + +Verifique a sintaxe da configuração do Apache: + +```bash +sudo apache2ctl configtest +``` + +Resultado esperado: + +```text +Syntax OK +``` + +Em seguida, recarregue o Apache: + +```bash +sudo systemctl reload apache2 +``` + +Neste ponto o Apache já conhece `~username`, mas ainda não há conteúdo de usuário para servir. + +--- + +## Passo 7 – Inspecionar a configuração do UserDir + +O pacote Apache do Debian costuma colocar a configuração do módulo em: + +```text +/etc/apache2/mods-available/userdir.conf +``` + +Leia o arquivo: + +```bash +cat /etc/apache2/mods-available/userdir.conf +``` + +Provavelmente você verá algo como: + +```apache +UserDir public_html +<Directory /home/*/public_html> + AllowOverride FileInfo AuthConfig Limit Indexes + Options MultiViews Indexes SymLinksIfOwnerMatch IncludesNoExec + Require method GET POST OPTIONS +</Directory> +``` + +O ponto principal é este: + +```apache +UserDir public_html +``` + +Isso significa que o Apache procurará conteúdo em: + +```text +/home/NOME_DO_USUARIO/public_html +``` + +Ótimo. É o que queremos. + +--- + +## Passo 8 – Criar um usuário de teste + +Crie uma conta de teste não administrativa para o teste de publicação via UserDir. + +**Não** use `pmurad` nesse teste. +Mantenha papéis de admin e usuário comum separados. + +Crie o usuário: + +```bash +sudo adduser testuser +``` + +Você pode dar uma senha temporária simples para uso em laboratório. + +Confirme que o home existe: + +```bash +ls -ld /home/testuser +``` + +--- + +## Passo 9 – Criar public_html e uma página de teste + +Crie a pasta de publicação: + +```bash +sudo -u testuser mkdir -p /home/testuser/public_html +``` + +Crie um arquivo HTML básico: + +```bash +sudo -u testuser tee /home/testuser/public_html/index.html > /dev/null <<'EOF' +<!doctype html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <title>testuser no runv.club</title> +</head> +<body> + <h1>Funcionou.</h1> + <p>Esta é a primeira página pessoal no runv.club.</p> +</body> +</html> +EOF +``` + +Verifique o arquivo: + +```bash +ls -l /home/testuser/public_html/index.html +``` + +--- + +## Passo 10 – Ajustar permissões do jeito certo + +É aqui que iniciantes costumam errar. + +O Apache precisa conseguir: + +1. entrar em `/home/testuser` +2. entrar em `/home/testuser/public_html` +3. ler `/home/testuser/public_html/index.html` + +Defina as permissões assim: + +```bash +sudo chmod 755 /home/testuser +sudo chmod 755 /home/testuser/public_html +sudo chmod 644 /home/testuser/public_html/index.html +``` + +Confira: + +```bash +namei -l /home/testuser/public_html/index.html +``` + +Se o `namei` não for encontrado, instale o pacote que o fornece: + +```bash +sudo apt install -y util-linux +``` + +O importante é que o usuário do Apache (`www-data`) consiga percorrer e ler o que precisa. + +--- + +## Passo 11 – Testar a página do usuário localmente + +De dentro do Debian: + +```bash +curl http://localhost/~testuser/ +``` + +A saída esperada deve incluir o seu HTML. + +Você também pode testar só o cabeçalho: + +```bash +curl -I http://localhost/~testuser/ +``` + +Um resultado saudável parece com: + +```text +HTTP/1.1 200 OK +``` + +Se aparecer: + +```text +403 Forbidden +``` + +o problema quase certamente são permissões. + +Se aparecer: + +```text +404 Not Found +``` + +o caminho, o nome de usuário ou a configuração do módulo está errado. + +--- + +## Passo 12 – Testar no navegador + +Agora, no Windows, abra: + +```text +http://IP_DA_VM/~testuser/ +``` + +Se a página carregar, você tem o primeiro caminho real de publicação por utilizador. + +Esse é o marco. + +--- + +## Passo 13 – Entender os três modos de falha mais comuns + +### Falha 1 – 403 Forbidden + +Causa: +- `/home/testuser` está muito restritivo +- permissões de `public_html` erradas +- permissões do arquivo erradas + +Correção: +```bash +sudo chmod 755 /home/testuser +sudo chmod 755 /home/testuser/public_html +sudo chmod 644 /home/testuser/public_html/index.html +``` + +### Falha 2 – 404 Not Found + +Causa: +- módulo `userdir` não habilitado +- nome da pasta errado +- nome do arquivo ausente +- erro de digitação no nome de usuário + +Verifique: +```bash +sudo a2query -m userdir +ls -l /home/testuser/public_html +``` + +### Falha 3 – Página do Apache funciona, mas `~testuser` não + +Causa: +- módulo carregado, mas permissões quebradas +- `userdir.conf` alterado incorretamente +- Apache não recarregado após habilitar o módulo + +Correção: +```bash +sudo apache2ctl configtest +sudo systemctl reload apache2 +``` + +--- + +## Passo 14 – Comandos úteis para diagnóstico + +Se algo falhar, estes são os primeiros comandos a usar. + +### Status do Apache +```bash +sudo systemctl status apache2 +``` + +### Verificar sintaxe da configuração +```bash +sudo apache2ctl configtest +``` + +### Confirmar que o módulo está habilitado +```bash +sudo a2query -m userdir +``` + +### Ler o log de erro do Apache +```bash +sudo tail -n 50 /var/log/apache2/error.log +``` + +### Ler o log de acesso +```bash +sudo tail -n 50 /var/log/apache2/access.log +``` + +### Testar localmente +```bash +curl -I http://localhost/~testuser/ +``` + +Esses logs importam. Não adivinhe quando o Apache já está dizendo o que está quebrado. + +--- + +## Passo 15 – Como fica o sucesso + +Você termina esta fase quando **tudo** isto for verdade: + +- Apache instalado +- Apache inicia automaticamente +- página padrão acessível +- módulo `userdir` habilitado +- `testuser` existe +- `/home/testuser/public_html/index.html` existe +- `http://localhost/~testuser/` retorna `200 OK` +- `http://IP_DA_VM/~testuser/` abre no navegador + +Se algum item for falso, a fase não terminou. + +--- + +## Passo 16 – O que vem depois + +Só depois disso funcionando você deve ir para a camada seguinte: + +1. preparar `/etc/skel` +2. definir os arquivos padrão que novos usuários recebem +3. criar um modelo de homepage inicial mais limpo +4. documentar como novos usuários publicam páginas +5. mais tarde: adicionar ttbp, botany e outras ferramentas sociais + +**Não** pule para software de comunidade antes de provar que o caminho de publicação web funciona. + +--- + +## Resumo rápido de comandos + +Para conveniência, o fluxo principal de novo: + +```bash +sudo apt update +sudo apt upgrade -y +sudo apt install -y apache2 curl +sudo systemctl enable apache2 +sudo systemctl start apache2 + +curl http://localhost + +sudo ufw allow 'Apache' + +sudo a2enmod userdir +sudo apache2ctl configtest +sudo systemctl reload apache2 + +sudo adduser testuser + +sudo -u testuser mkdir -p /home/testuser/public_html + +sudo -u testuser tee /home/testuser/public_html/index.html > /dev/null <<'EOF' +<!doctype html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <title>testuser no runv.club</title> +</head> +<body> + <h1>Funcionou.</h1> + <p>Esta é a primeira página pessoal no runv.club.</p> +</body> +</html> +EOF + +sudo chmod 755 /home/testuser +sudo chmod 755 /home/testuser/public_html +sudo chmod 644 /home/testuser/public_html/index.html + +curl -I http://localhost/~testuser/ +``` + +--- + +## Nota final + +Este documento é propositalmente detalhado porque iniciantes costumam falhar aqui por motivos chatos: + +- permissões erradas +- esquecer de recarregar o serviço +- IP errado +- firewall não aberto +- testar na ordem errada + +Faça na ordem e funciona. +Faça aleatoriamente e você perde tempo. diff --git a/scripts/doom/doom.md b/scripts/doom/doom.md @@ -0,0 +1,88 @@ +# `doom.py` — reset em massa de utilizadores runv + +Script de administração que **remove todas as contas runv** registadas em `users.json`, **excepto** um conjunto **protegido**. A remoção real (Unix, quotas, metadados) é feita pelo mesmo fluxo que o banimento manual: chama [`../admin/del-user.py`](../admin/del-user.py) com `-y` para cada utilizador a apagar. + +**Aviso:** operação **irreversível**. Use primeiro `--dry-run` se tiver dúvidas. + +## O que é “protegido” + +O script **nunca** passa ao `del-user.py` ninguém que pertença ao conjunto **protegido**: + +``` +protegido = { conta de referência (--keep / omissão) } ∪ { quem está ligado ao processo } +``` + +### Conta de referência (`keeper`) + +- Com **`--keep USER`**: essa conta é a referência explícita (e entra no protegido). +- Sem `--keep`, com **EUID root** e **`SUDO_USER`** definido (caso típico `sudo python3 doom.py`): a referência é o utilizador em `SUDO_USER`. +- **Root sem `SUDO_USER`**: é **obrigatório** `--keep USER` (evita ambiguidade). +- **Sem ser root** (ex.: só inspeção): a referência é o utilizador do **real UID** (útil em cenários limitados; a remoção real ainda exige root). + +### Quem está ligado ao processo (nunca apagado) + +Mesmo que `--keep` aponte para **outra** conta, o script **não apaga**: + +| Origem | Motivo | +|--------|--------| +| `SUDO_USER` | Quem executou `sudo`. | +| Real UID e **effective** UID | Cobre `sudo -u bob`: protege quem invocou e o utilizador efectivo. | +| `root` | Se o processo corre como root **e** não há `SUDO_USER`, `root` fica protegido se existir no JSON. | + +Os nomes são normalizados para bater com entradas típicas de `users.json` (ex.: minúsculas, regra de username runv). + +## Fonte da lista de utilizadores + +- Lê **apenas** os `username` presentes no ficheiro JSON de metadados (por omissão `/var/lib/runv/users.json`). +- **Não** enumera o `/etc/passwd` por conta própria: o que não está no JSON **não** é alvo do doom (mas contas órfãs no sistema continuam fora deste script). + +## Fluxo de execução + +1. Determina `keeper` e o conjunto **protegido** (referência + quem rodou). +2. Lista todos os utilizadores no JSON **fora** do protegido → **vítimas**. +3. Se não houver vítimas, termina sem chamar `del-user.py`. +4. **Confirmação:** a menos que use `--yes`, é preciso escrever **`DOOM`** em maiúsculas. +5. Em modo real, exige **root** (EUID 0). +6. Para cada vítima, invoca `del-user.py` com `--yes` e os mesmos caminhos de metadata/lock que indicar. + +## Modo dry-run + +`--dry-run` **não exige root**. Mostra quem seria removido e corre o `del-user.py` em dry-run por cada vítima (sem alterações reais). + +## Opções principais + +| Opção | Função | +|--------|--------| +| `--keep USER` | Referência explícita; obrigatório em root puro sem `SUDO_USER`. | +| `--yes` / `-y` | Sem prompt `DOOM` (automação; perigoso). | +| `--dry-run` | Simulação. | +| `--metadata-file`, `--lock-file` | Sobrescreve caminhos do JSON e do lock (útil em testes). | +| `--purge-all-files` | Repassa ao `del-user.py` (além de remover home). | +| `-v` / `--verbose` | Mais saída no `del-user.py`. | + +## Exemplos + +```bash +# Conta a manter = quem fez sudo (ex.: alice); apaga todos os outros no JSON +sudo python3 /caminho/scripts/doom/doom.py + +# Root em consola sem SUDO_USER: dizer explicitamente quem fica como referência +sudo python3 /caminho/scripts/doom/doom.py --keep alice + +# Simular sem apagar nada +python3 /caminho/scripts/doom/doom.py --dry-run + +# Automação (sem confirmação DOOM) +sudo python3 /caminho/scripts/doom/doom.py --yes +``` + +## Nota sobre “ficar só um utilizador” + +O objectivo habitual é deixar **uma** conta runv no JSON (a referência). Se `--keep` for diferente de quem invocou o script, **várias** contas podem permanecer no ficheiro (referência + todas as que o doom é obrigado a proteger). Isto é intencional: **nunca** apagar quem está ligado ao processo. + +## Dependências + +- Python 3, acesso root para execução real. +- `scripts/admin/del-user.py` no repositório, relativo a `doom.py` (`../admin/del-user.py`). + +Versão do script documentada aqui: alinhada a `doom.py` (ver `--version`). diff --git a/scripts/doom/doom.py b/scripts/doom/doom.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +Apaga todas as contas runv listadas em users.json, excepto o conjunto protegido. + +Nunca apaga quem está ligado ao processo: ``SUDO_USER``, real UID e effective UID +(cobre ``sudo -u bob``). O ``--keep USER`` define +a conta runv de referência; mesmo assim, quem rodou nunca entra na lista de +remoção. Em sessão root pura (sem SUDO_USER), é obrigatório ``--keep USER``. + +Para cada utilizador a remover, delega em ``scripts/admin/del-user.py`` (-y), +para manter o mesmo fluxo (deluser, quotas, users.json). + +Executar como root. Operação irreversível. + +Versão 0.02 — runv.club +""" + +from __future__ import annotations + +import argparse +import json +import os +import pwd +import re +import subprocess +import sys +from pathlib import Path +from typing import Final + +VERSION: Final[str] = "0.02" +EXIT_OK: Final[int] = 0 +EXIT_VALIDATION: Final[int] = 1 +EXIT_SYSTEM: Final[int] = 2 + +USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") + +DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") + +_DOOM_DIR = Path(__file__).resolve().parent +_REPO_SCRIPTS = _DOOM_DIR.parent +_DEL_USER_PY: Final[Path] = _REPO_SCRIPTS / "admin" / "del-user.py" + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def validate_privileges() -> None: + if os.geteuid() != 0: + eprint("Erro: execute como root (ex.: sudo python3 doom.py …).") + raise SystemExit(EXIT_VALIDATION) + + +def validate_username_syntax(username: str) -> str: + if not username or not username.strip(): + eprint("Erro: username vazio.") + raise SystemExit(EXIT_VALIDATION) + u = username.strip() + if not USERNAME_PATTERN.fullmatch(u): + eprint( + "Erro: username inválido (minúsculas, dígitos, _ e -; " + "2–32 caracteres, começando com letra).", + ) + raise SystemExit(EXIT_VALIDATION) + return u + + +def username_for_metadata_match(raw: str) -> str: + """Forma canónica para comparar com entradas de users.json (runv em minúsculas).""" + u = raw.strip() + if not u: + return u + if USERNAME_PATTERN.fullmatch(u): + return u + low = u.lower() + if USERNAME_PATTERN.fullmatch(low): + return low + return u + + +def collect_runners_who_must_survive() -> set[str]: + """ + Contas que não podem ser apagadas em relação a quem corre o processo. + + - SUDO_USER: quem invocou ``sudo`` (quando definido). + - Real UID e effective UID: cobre ``sudo -u bob`` (RUID ainda pode ser alice, + EUID é bob — ambos ficam protegidos). + - Root sem SUDO_USER: também protege ``root`` se existir no JSON. + """ + out: set[str] = set() + su = os.environ.get("SUDO_USER", "").strip() + if su: + out.add(username_for_metadata_match(su)) + for uid in (os.getuid(), os.geteuid()): + try: + login = pwd.getpwuid(uid).pw_name + if login: + out.add(username_for_metadata_match(login)) + except KeyError: + pass + if os.geteuid() == 0 and not su: + out.add("root") + return {x for x in out if x} + + +def resolve_keeper(args: argparse.Namespace) -> str: + if args.keep: + return validate_username_syntax(args.keep) + if os.geteuid() == 0: + su = os.environ.get("SUDO_USER", "").strip() + if su: + return validate_username_syntax(username_for_metadata_match(su)) + eprint( + "Erro: sessão root sem SUDO_USER. Indique explicitamente a conta a preservar:\n" + " --keep alice\n" + " ou execute a partir da conta desejada, ex.: sudo -u alice python3 …/doom.py", + ) + raise SystemExit(EXIT_VALIDATION) + return validate_username_syntax( + username_for_metadata_match(pwd.getpwuid(os.getuid()).pw_name), + ) + + +def load_runv_usernames(metadata_path: Path) -> list[str]: + if not metadata_path.is_file(): + return [] + raw = metadata_path.read_text(encoding="utf-8").strip() + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + eprint(f"Erro: JSON inválido em {metadata_path}: {e}") + raise SystemExit(EXIT_SYSTEM) from e + if not isinstance(data, list): + eprint(f"Erro: {metadata_path} deve ser uma lista JSON.") + raise SystemExit(EXIT_SYSTEM) + out: list[str] = [] + for item in data: + if isinstance(item, dict) and item.get("username"): + u = str(item["username"]).strip() + if u: + out.append(u) + return out + + +def confirm_doom(keeper: str, protected: set[str], victims: list[str]) -> bool: + print() + print(" ═══════════════════════════════════════════════════════════") + print(" DOOM — remoção em massa de contas runv") + print(" ═══════════════════════════════════════════════════════════") + print(f" Conta runv alvo (referência): {keeper!r}") + extra = sorted(protected - {keeper}) + if extra: + print(f" Nunca apagar (quem invocou / efectivo): {', '.join(repr(x) for x in extra)}") + print(f" Contas a apagar: {len(victims)}") + if victims: + preview = ", ".join(sorted(victims)[:20]) + if len(victims) > 20: + preview += ", …" + print(f" {preview}") + print() + typed = input(" Digite DOOM em maiúsculas para confirmar: ").strip() + return typed == "DOOM" + + +def run_del_user( + username: str, + *, + metadata_path: Path, + lock_path: Path, + purge_all_files: bool, + verbose: bool, + dry_run: bool, +) -> None: + if not _DEL_USER_PY.is_file(): + eprint(f"Erro: não encontrei del-user.py em {_DEL_USER_PY}") + raise SystemExit(EXIT_SYSTEM) + + cmd: list[str] = [ + sys.executable, + str(_DEL_USER_PY), + "--username", + username, + "--yes", + "--metadata-file", + str(metadata_path), + "--lock-file", + str(lock_path), + ] + if purge_all_files: + cmd.append("--purge-all-files") + if verbose: + cmd.append("--verbose") + if dry_run: + cmd.append("--dry-run") + + r = subprocess.run(cmd, timeout=600) + if r.returncode != 0: + eprint(f"Erro: del-user.py falhou para {username!r} (código {r.returncode}).") + raise SystemExit(EXIT_SYSTEM) + + +def main() -> int: + p = argparse.ArgumentParser( + description="Remove todas as contas em users.json excepto a conta indicada (runv.club).", + ) + p.add_argument( + "--keep", + metavar="USER", + help="conta Unix a preservar (obrigatório se root sem SUDO_USER)", + ) + p.add_argument( + "--metadata-file", + type=Path, + default=DEFAULT_METADATA_PATH, + help=f"caminho users.json (default: {DEFAULT_METADATA_PATH})", + ) + p.add_argument( + "--lock-file", + type=Path, + default=Path("/var/lib/runv/users.lock"), + help="ficheiro de lock (default: /var/lib/runv/users.lock)", + ) + p.add_argument( + "--purge-all-files", + action="store_true", + help="repassa --purge-all-files ao del-user (além de --remove-home)", + ) + p.add_argument("--dry-run", action="store_true", help="só simula (del-user em dry-run)") + p.add_argument("--verbose", "-v", action="store_true") + p.add_argument( + "--yes", + "-y", + action="store_true", + help="não pedir confirmação DOOM (perigoso)", + ) + p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club") + args = p.parse_args() + + keeper = resolve_keeper(args) + keeper = validate_username_syntax(keeper) + + runners = collect_runners_who_must_survive() + protected = runners | {keeper} + + all_names = load_runv_usernames(args.metadata_file) + victims = sorted({u for u in all_names if u not in protected}) + + if not victims: + print( + f"doom.py — nada a fazer (entradas em users.json já só dentro do conjunto protegido; " + f"referência {keeper!r}).", + ) + return EXIT_OK + + if args.dry_run: + print("doom.py — dry-run\n") + print(f" protegidos (nunca apagar): {', '.join(sorted(protected))}") + for u in victims: + print(f" removia: {u!r}") + for u in victims: + run_del_user( + u, + metadata_path=args.metadata_file, + lock_path=args.lock_file, + purge_all_files=args.purge_all_files, + verbose=args.verbose, + dry_run=True, + ) + return EXIT_OK + + if not args.yes: + if not confirm_doom(keeper, protected, victims): + eprint("Cancelado.") + return EXIT_VALIDATION + + validate_privileges() + + overlap = protected & set(victims) + if overlap: + eprint(f"Erro: utilizador(es) protegido(s) na lista de vítimas: {sorted(overlap)!r}") + return EXIT_SYSTEM + + print( + f"\ndoom.py — a remover {len(victims)} conta(s); " + f"protegidos: {', '.join(sorted(protected))}\n", + ) + + for u in victims: + print(f"--- {u!r} ---") + run_del_user( + u, + metadata_path=args.metadata_file, + lock_path=args.lock_file, + purge_all_files=args.purge_all_files, + verbose=args.verbose, + dry_run=False, + ) + + print("\n--- Resumo ---") + print(f" Protegidos (não removidos): {', '.join(sorted(protected))}") + print(f" Removidos: {len(victims)} utilizador(es) runv.") + print(f" Verifique {args.metadata_file} e repquota se necessário.") + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/skel.md b/scripts/skel.md @@ -0,0 +1,145 @@ +# skel.py — preparar `/etc/skel` (runv.club) + +**Versão 0.01** · runv.club + +Script para **administradores** prepararem o diretório `/etc/skel` no **Debian** (ex.: Debian 13), de modo que `adduser` copie automaticamente uma home inicial com `public_html`, página de boas-vindas e README para novos usuários. + +- **Não** cria usuários. +- **Não** altera Apache, SSH, chaves ou pacotes. +- Só cria/atualiza ficheiros sob `/etc/skel` (com segurança e idempotência). + +**Ambiente:** execute no servidor Linux como **root** (ou `sudo`). No Windows, use apenas para revisão do código; a execução real é no Debian. + +## Objetivo + +- Garantir `/etc/skel/public_html/` e `/etc/skel/public_html/index.html`. +- Garantir `/etc/skel/README.md` (texto de ajuda para quem entra na shell). +- Aplicar permissões: diretório `755`, ficheiros `644`. +- Ser **idempotente**: voltar a correr não apaga nada; sem `--force`, ficheiros existentes são **preservados**. + +## Requisitos + +- Python 3 (stdlib apenas; usa `pathlib`). +- Permissões de root para escrita em `/etc/skel`. + +## Como executar + +Nos exemplos com **`admin/skel.py`**, execute a partir do diretório **`scripts/`** do repositório. + +Torne o script executável (opcional): + +```bash +sudo chmod +x admin/skel.py +``` + +(a partir do diretório `scripts/` do repositório; ou use o caminho absoluto até `scripts/admin/skel.py`.) + +### Simular (sem root, sem alterar disco) + +```bash +python3 admin/skel.py --dry-run +python3 admin/skel.py --dry-run --verbose +``` + +### Aplicar de verdade + +```bash +sudo python3 admin/skel.py +sudo python3 admin/skel.py --verbose +``` + +### Regenerar templates (sobrescrever) + +Se `index.html` ou `README.md` já existirem em `/etc/skel` e quiser substituir pelo conteúdo embutido no script: + +```bash +sudo python3 admin/skel.py --force +``` + +## Opções + +| Opção | Efeito | +|--------|--------| +| `--dry-run` | Mostra o que seria criado/atualizado; **não** exige root; não escreve ficheiros. | +| `--verbose` | Mais detalhe (ex.: `chmod` explícito). | +| `--force` | Sobrescreve `index.html` e `README.md` se já existirem. Sem `--force`, são preservados. | +| `--version` | Mostra versão do script. | + +## Exemplos de saída + +### Dry-run (trecho) + +``` +Modo dry-run — nenhuma alteração em disco. + + [dry-run] criaria diretório: /etc/skel/public_html + [dry-run] criaria arquivo: /etc/skel/public_html/index.html + [dry-run] criaria arquivo: /etc/skel/README.md + +Resumo: nada foi gravado. Execute sem --dry-run como root para aplicar. +``` + +### Execução real (trecho) + +``` +skel.py — preparando /etc/skel para runv.club + + [dir] criado: /etc/skel/public_html + [file] criado: /etc/skel/public_html/index.html + [file] criado: /etc/skel/README.md + +Aplicando permissões... + +--- Resumo --- + ... +``` + +Segunda execução **sem** `--force`: deve aparecer `preservado` para os ficheiros já existentes. + +## Limitações + +- Não altera `/etc/skel` fora do que o script define (outros ficheiros que o admin adicionar manualmente ficam). +- Não remove ficheiros. +- Não cria `public_html` na home de utilizadores **já** existentes — só para **novos** utilizadores criados **depois** de preparar o skel; utilizadores antigos precisam copiar à mão ou de outro procedimento. + +## Como testar no Debian 13 + +1. **Preparar o skel** + + ```bash + sudo python3 ./admin/skel.py --verbose + ``` + +2. **Criar um utilizador de teste** + + ```bash + sudo adduser testuser + ``` + + (ou `adduser --disabled-password` se o seu fluxo não precisar de password interativa.) + +3. **Verificar cópia a partir de `/etc/skel`** + + Como `testuser` (ou inspecionando a home): + + ```bash + sudo ls -la /home/testuser/ + sudo ls -la /home/testuser/public_html/ + sudo cat /home/testuser/public_html/index.html | head + sudo cat /home/testuser/README.md | head + ``` + +4. **Permissões típicas para publicação web** (o README em `/etc/skel` explica; após `adduser`, confirme se a sua política exige `chmod` na home — muitas instalações já ficam com home `755` e `public_html` herdado de `755`). + +5. **Prova no browser** (se DNS e Apache estiverem corretos): `http://runv.club/~testuser/` + +## Ficheiros geridos pelo script + +| Caminho | Conteúdo | +|---------|-----------| +| `/etc/skel/public_html/index.html` | Página inicial estática em português (CSS embutido, visual simples). | +| `/etc/skel/README.md` | Instruções para o utilizador na shell. | + +## Créditos + +runv.club. diff --git a/scripts/starthere.md b/scripts/starthere.md @@ -0,0 +1,94 @@ +# starthere.py + +Bootstrap **conservador** para preparar um servidor **Debian**: pacotes úteis ao runv.club e **quotas de utilizador** (`usrquota`) no **mesmo filesystem ext4 onde vivem as homes** (detetado automaticamente — tipicamente `/` se `/home` está na raiz, ou `/home` se é um volume dedicado). A lógica de descoberta é a de [`runv_mount.py`](admin/runv_mount.py), alinhada a [`create_runv_user.py`](create_runv_user.md). + +Versão do script: **0.01** (use `python3 admin/starthere.py --version` a partir do diretório `scripts/` do repositório). + +### Comportamento de `--dry-run` + +O script **consulta sempre o `findmnt` real** (só leitura) para mostrar o estado do mount detetado. Como o `fstab` não é gravado em dry-run, o kernel **não** passa a mostrar `usrquota` até uma execução real; por isso, em dry-run o fluxo **assume** quotas ativas só para completar o plano de quotas, incluindo `quotacheck` / `quotaon` simulados. + +## Quando usar + +- Máquina nova ou recém-instalada, antes de criar utilizadores com [`create_runv_user.py`](create_runv_user.md). +- Quando ainda não existem quotas ativas no filesystem das homes e pretende alinhar ao fluxo de `create_runv_user.md` (ext4 + `setquota`). + +## Requisitos + +- **root** (`sudo` ou sessão root). +- **Debian** (ou derivado com `apt-get`). +- O path de sonda (predefinido **`/home`**) tem de residir num filesystem **`ext4`**. Se `/home` estiver noutro tipo (btrfs, xfs, …), o script aborta — configure quotas manualmente ou use layout ext4 para as homes. +- Acesso à rede para `apt-get update` / `install` (exceto em `--dry-run`, que não executa APT mas ainda pode ler `/etc/fstab` nos passos de quota). + +## O que o script faz + +1. **`apt-get update`** (a menos que `--no-install`). +2. **`apt-get install -y`** de um conjunto fixo de pacotes (personalizável com `--packages`; ver lista em `BASE_PACKAGES` no código). +3. **Limpeza segura**: `apt-get autoremove` e `autoclean` (desligável com `--no-cleanup`). +4. **Serviços** (desligável com `--no-services`): + - `systemctl enable --now apache2` + - Se o UFW estiver **inativo**: `ufw allow OpenSSH`, `80/tcp`, `443/tcp`, depois `ufw --force enable` (não altera regras se o UFW já estiver ativo). +5. **Quotas** (desligável com `--no-quota`): + - **Deteta** o mountpoint com `find_mount_triple` sobre `--quota-probe` (predefinido `/home`). + - Garante `usrquota` na linha **ext4** correspondente a esse mountpoint em `/etc/fstab`. + - Backup de `/etc/fstab` em `/root/runv-fstab-backups/fstab.<timestamp>.bak`. + - **`mount -o remount,usrquota <mount>`** e, se preciso, **`mount -o remount <mount>`** (saltável com `--skip-remount`). + - **`quotacheck`** / **`quotaon`** nesse mesmo `<mount>`. + +## O que o script não faz + +- Não remove pacotes em massa nem faz `purge` agressivo. +- Não altera vhosts Apache nem cria utilizadores (só enable/start do serviço `apache2`). +- Não altera configuração SSH além do que o pacote `openssh-server` já traz. +- Não instala stack de correio. +- Não define limites por utilizador — isso fica para `create_runv_user.py` / `setquota` manual. + +## Opções de linha de comandos + +| Opção | Efeito | +|--------|--------| +| `--dry-run` | Mostra comandos e plano; não executa subprocessos reais (saídas simuladas onde aplicável). | +| `--verbose` | Ecoa comandos e saída de stderr/stdout dos programas. | +| `--packages` | Lista explícita de pacotes a instalar (substitui o padrão). | +| `--no-install` | Não corre APT; apenas lógica de quotas (útil se os pacotes já estiverem instalados). | +| `--no-cleanup` | Não corre `autoremove` / `autoclean`. | +| `--no-quota` | Só instala/limpa; não mexe em `fstab` nem quotas. | +| `--quota-probe PATH` | Caminho para descobrir o FS de quotas (predefinido `/home`; deve bater com onde o `create_runv_user` cria homes). | +| `--skip-remount` | Não tenta `remount` após editar `fstab`. | +| `--allow-live-scan` | Usa apenas **`quotacheck -cuM`** (não tenta antes `-cu`). | +| `--no-services` | Não ativa Apache nem configura/ativa UFW. | +| `--version` | Mostra versão e sai. | + +Códigos de saída: **0** sucesso; **2** erro operacional (`BootstrapError`); **130** interrupção (Ctrl+C). + +## Pacotes base (padrão) + +Incluem, entre outros: `apache2`, `openssh-server`, `sudo`, `ufw`, `quota`, ferramentas de rede e ficheiros (`curl`, `wget`, `git`, `rsync`), consola (`tmux`, `htop`, `vim`, …), `jq`, `acl`, `build-essential`, `python3-venv`, `python3-pip`, `ripgrep`, `shellcheck`. A lista completa está em `BASE_PACKAGES` em [`starthere.py`](admin/starthere.py). + +## Quotas: comportamento esperado e problemas comuns + +- Depois de editar `fstab`, o **remount** pode falhar em alguns ambientes (cloud-init, segurança do kernel). O script sugere **reiniciar a VM** e voltar a executar o script. +- **`quotacheck` com filesystem montado**: o script tenta **`-cu`**, depois **`-cuM`**, e se ainda falhar (p.ex. quotas já ativas após remount — mensagem «use -f»), **`-cuM -f`** e **`-cu -f`**. **`--allow-live-scan`** começa por **`-cuM`** e, se preciso, **`-cuM -f`**. +- **`quotaon -vu`**: se o kernel já tiver quotas de utilizador activas neste mount (comum após `remount,usrquota` + `quotacheck`), o `quotaon` pode devolver **Device or resource busy**. O script trata isso como **sucesso** e imprime um aviso — confirme com **`quota -vs`** ou **`sudo repquota -s <mount>`** (`repquota` costuma estar em `/usr/sbin/`). +- Confirme com `mount | grep ' on <mount> '` (o `<mount>` é o indicado no resumo do script, ex. `/` ou `/home`) e `quota -vs`. +- **Reinício:** em muitas contas normais o comando `reboot` não está no `PATH`; use `sudo reboot` ou `/sbin/reboot`. + +### Avisos «external quota files» e `tune2fs -O quota` + +O `quotacheck` e o `quotaon` podem imprimir avisos dizendo que o kernel prefere a **feature interna `quota` do ext4** e que os ficheiros clássicos (`aquota.user` / «external quota files») estão **deprecated**. + +- **Isto não invalida o que o script fez:** com `usrquota` no mount e `quota -vs` a mostrar o filesystem, as quotas estão ativas; `setquota` (como em `create_runv_user.py`) funciona neste modo. +- O script usa o caminho suportado em **/** montado: `usrquota` + ficheiros de quota geridos pelas ferramentas `quota` — é o esperado quando a feature `quota` do ext4 **não** foi ligada no superbloco. +- **Migrar** para quotas «só no ext4» (sem aquele aviso) implica, em geral, **desmontar** o volume (para `/` isso significa **modo rescue/live** ou VM parada), correr algo como `tune2fs -O quota <dispositivo>`, voltar a montar e rever opções de mount/documentação do seu Debian — fora do âmbito automático deste bootstrap. +- O pacote **`e2fsprogs`** (inclui `tune2fs`, `dumpe2fs`) está na lista base para inspeção manual; após um run bem-sucedido, o script pode imprimir uma **nota** em stderr se detectar que a feature interna ainda não está ativa. + +## Relação com outros scripts + +- Após este bootstrap, use **[create_runv_user.md](create_runv_user.md)** para criar contas com quota e `public_html`. +- **[skel.md](skel.md)** e **[del-user.md](del-user.md)** cobrem esqueleto de ficheiros e remoção de utilizadores. + +## Segurança e operações + +- Alterações em **`/etc/fstab`** são precedidas de backup em **`/root/runv-fstab-backups/`**. +- O script foi pensado para **ext4 no volume das homes**; não extrapola para outros filesystems. +- Revise sempre `--dry-run` / `--verbose` num ambiente de teste antes de produção. diff --git a/site/README.md b/site/README.md @@ -0,0 +1,97 @@ +# Site público (landing runv.club) + +Conteúdo estático inspirado em [tilde.town](https://tilde.town) e [tilde.club](https://tilde.club): landing com constelação de links por membro (`members.json`), rotas **`/news/`** e **`/wiki/`** (placeholders por agora), e **`/junte-se/`** — guia de chave SSH (Linux, macOS, Windows) e acesso a **`entre@runv.club`**. + +## O que significa “membro” na página + +- **Membro listado** = conta presente em `/var/lib/runv/users.json` (criada por `create_runv_user.py`). +- **Não** é “sessão SSH ativa neste momento” nem “logged in”; isso exigiria outra fonte de dados (ex. `lastlog`). + +## Privacidade + +- `build_directory.py` **filtra** o JSON interno: só escreve `username`, `since` (data de criação), `path` (`/~user/`) e, opcionalmente, `homepage_mtime` se você usar `--homes-root`. +- **Nunca** copia email, fingerprint de chave nem quotas para `members.json`. + +## Stack + +- **HTML/CSS/JS** estáticos em `public/`. +- **Rodapé:** em todas as páginas HTML em `public/` deve constar o **contato** da administração — `admin@runv.club` (bloco `<footer class="site-footer">` como em `index.html`). +- **Geração de dados**: Python 3 (stdlib) — adequado a **cron** no servidor; sem CGI. + +## Gerar `public/data/members.json` + +**No Git**, `public/data/members.json` fica **`[]`**: a landing não deve mostrar utilizadores fictícios. Quem aparece na constelação vem **só** de `build_directory.py` a ler **`/var/lib/runv/users.json`** (produção, via cron) ou, em desenvolvimento, uma cópia de teste com **`--users-json site/example-users.json`** — sem commit do JSON gerado como se fosse produção. Se **`users.json` ainda não existir** no servidor, o `build_directory.py` assume **zero membros** (aviso em stderr) em vez de falhar. + +Manual detalhado do script: **[`build_directory.md`](build_directory.md)**. + +No servidor (como root), após provisionar contas: + +```bash +sudo python3 /caminho/ao/repo/site/build_directory.py \ + --users-json /var/lib/runv/users.json \ + --homes-root /home \ + -o /caminho/deploy/public/data/members.json +``` + +Sem acesso a `/home` (ex.: build na **sua** máquina só para pré-visualizar): + +```bash +python3 site/build_directory.py \ + --users-json site/example-users.json \ + -o site/public/data/members.json +``` + +Dry-run: + +```bash +python3 site/build_directory.py --users-json site/example-users.json --dry-run +``` + +## Configurar Apache (`genlanding.py`) + +Para **gerar o VirtualHost**, **ativar** `mod_userdir` / `mod_rewrite`, copiar **`public/`** para o `DocumentRoot` e (opcional) rodar **Certbot**, use o script **[`genlanding.py`](genlanding.py)**. Manual completo: **[`genlanding.md`](genlanding.md)**. + +Exemplos: + +```bash +# Produção (runv.club, /var/www/runv.club/html) +sudo python3 site/genlanding.py + +# Pré-visualização +python3 site/genlanding.py --dry-run + +# VM / teste local (runv.local; por padrão desativa 000-default para IP servir a landing) +sudo python3 site/genlanding.py --dev +# Manter página Debian em paralelo: --dev --keep-default-site + +# TLS após HTTP correto (não combinar com --dev) +sudo python3 site/genlanding.py --certbot +``` + +## Deploy no Apache (manual) + +Alternativa ao genlanding: copiar o conteúdo de **`public/`** para o `DocumentRoot` do VirtualHost do domínio (ex. `/var/www/runv.club/html/`), ou configurar `DocumentRoot` para apontar diretamente para esta pasta. + +**Certifique-se** de que `mod_userdir` continua a servir `~/public_html` para cada **usuário**; a landing é só a **raiz** do site. + +### Cron (exemplo) + +```cron +*/15 * * * * root python3 /opt/runv-server/site/build_directory.py --users-json /var/lib/runv/users.json --homes-root /home -o /var/www/runv/html/data/members.json +``` + +(Ajuste os caminhos.) + +## Arquivos + +| Caminho | Função | +|---------|--------| +| `genlanding.py` | Configura Apache (vhost, cópia de `public/`, opcional Certbot); ver `genlanding.md` | +| `build_directory.py` | Gera `members.json` público; ver **`build_directory.md`** | +| `build_directory.md` | Como usar `build_directory.py` (flags, cron, exemplos) | +| `public/index.html` | Landing | +| `public/junte-se/index.html` | Pedir entrada: gerar chave SSH e `ssh entre@runv.club` | +| `public/assets/style.css` | Estilos | +| `public/assets/app.js` | Constelação, lista, filtro, shuffle | +| `public/data/members.json` | Dados públicos (regenerado; exemplo no repo) | +| `example-users.json` | Amostra para testes locais | diff --git a/site/build_directory.md b/site/build_directory.md @@ -0,0 +1,126 @@ +# build_directory.py — gerar `members.json` para a landing + +O script [`build_directory.py`](build_directory.py) lê o ficheiro interno **`users.json`** (criado pelo [`create_runv_user.py`](../scripts/create_runv_user.md)) e gera um JSON **público** consumido pelo JavaScript da landing (`public/assets/app.js`): posiciona os **pontos** (links para `/~utilizador/`) com base em `username`, `since` e `path`. + +- **Python 3**, só biblioteca padrão (sem PyPI). +- **Não** é um servidor web: corre na linha de comando ou via **cron**. + +Visão geral do `site/`: [README.md](README.md). + +## O que entra e o que sai + +### Entrada (`--users-json`) + +Caminho para o JSON do provisionador (por omissão no servidor: `/var/lib/runv/users.json`). O ficheiro deve ser uma **lista** de objectos; cada entrada com `username` (string) é considerada. + +Se o caminho **ainda não existir** (bootstrap antes do primeiro `create_runv_user.py`), o script **não falha**: emite um aviso em stderr, assume **lista vazia** e gera `members.json` com `[]`. Podes também criar manualmente `/var/lib/runv/users.json` com conteúdo `[]` se preferires. + +O script **ignora** linhas que não sejam dicionários ou sem `username` válido. Se o ficheiro **existir** mas o JSON for inválido ou não for uma lista, o script termina com erro. + +### Saída (`-o` / `--output`) + +Um único ficheiro JSON (por omissão: **`site/public/data/members.json`**, relativo à pasta onde está o script). + +Cada elemento do array público tem: + +| Campo | Significado | +|--------|-------------| +| `username` | Nome Unix do membro | +| `since` | Valor de `created_at` no `users.json`, se existir e for string (senão `""`) | +| `path` | URL do site pessoal, ex. `"/~alice/"` | +| `homepage_mtime` | *(Opcional)* Só se usares `--homes-root`: ISO UTC da última modificação de `public_html/index.html` desse utilizador | + +### Privacidade + +**Nunca** são copiados para o ficheiro público: email, fingerprint SSH, quotas, nem outros campos internos do `users.json`. + +## Opções da linha de comando + +| Opção | Curto | Por omissão | Descrição | +|--------|------|-------------|-----------| +| `--users-json` | — | `/var/lib/runv/users.json` | Ficheiro fonte (lista JSON) | +| `--output` | `-o` | `site/public/data/members.json`* | Onde gravar o JSON público | +| `--homes-root` | — | *(não definido)* | Se definires (ex. `/home`), tenta acrescentar `homepage_mtime` por utilizador | +| `--dry-run` | — | — | Imprime o JSON no **stdout**; não grava ficheiro | + +\*O caminho por omissão é relativo ao directório do script: `<pasta_do_build_directory.py>/public/data/members.json`. + +## Como executar (a partir da raiz do repositório) + +### 1. Servidor em produção + +Com acesso a `users.json` e, de preferência, a `/home` para `homepage_mtime`: + +```bash +cd /caminho/ao/runv-server +sudo python3 site/build_directory.py \ + --users-json /var/lib/runv/users.json \ + --homes-root /home \ + -o /var/www/runv.club/html/data/members.json +``` + +Ajusta `-o` ao **DocumentRoot** real (o mesmo que usaste com [`genlanding.py`](genlanding.md), ex. `/var/www/runv.club/html/data/members.json`). + +### 2. Máquina local (sem `/var/lib/runv`) + +Usa o exemplo do repo ou uma cópia sanitizada do `users.json`: + +```bash +cd /caminho/ao/runv-server +python3 site/build_directory.py \ + --users-json site/example-users.json \ + -o site/public/data/members.json +``` + +Assim podes editar a landing e recarregar o browser sem tocar no servidor. + +### 3. Pré-visualizar no terminal (dry-run) + +```bash +python3 site/build_directory.py \ + --users-json site/example-users.json \ + --dry-run +``` + +Útil para validar o JSON sem sobrescrever ficheiros. + +### 4. Sem `--homes-root` + +Se não quiseres (ou não puderes) ler as homes: + +```bash +sudo python3 site/build_directory.py \ + --users-json /var/lib/runv/users.json \ + -o /var/www/runv.club/html/data/members.json +``` + +A lista aparece na landing; não haverá `homepage_mtime` (o JS deve tolerar campo em falta). + +## Erros comuns + +| Mensagem / situação | Causa provável | +|---------------------|----------------| +| `Ficheiro inexistente` | `--users-json` aponta para um path errado ou ficheiro ainda não criado | +| `Formato inválido: esperada lista JSON` | O ficheiro não é um array JSON no topo | +| Permissão negada ao gravar `-o` | Corre com `sudo` ou escolhe um `-o` onde o teu utilizador possa escrever | +| `homepage_mtime` nunca aparece | Falta `--homes-root` ou não existe `~/public_html/index.html` legível para esse user | + +## Cron (exemplo) + +Regenerar a cada 15 minutos no servidor (caminhos de exemplo): + +```cron +*/15 * * * * root python3 /opt/runv-server/site/build_directory.py --users-json /var/lib/runv/users.json --homes-root /home -o /var/www/runv.club/html/data/members.json +``` + +Garante que o path do `python3`, do script e do `-o` coincidem com a tua instalação. + +## Relação com outros ficheiros + +| Ferramenta | Papel | +|------------|--------| +| [`create_runv_user.py`](../scripts/create_runv_user.md) | Mantém `/var/lib/runv/users.json` | +| [`genlanding.py`](genlanding.md) | Copia `public/` para o Apache; o **cron** do `build_directory.py` deve escrever `members.json` **dentro** desse DocumentRoot | +| `public/assets/app.js` | Faz `fetch` a `data/members.json` (caminho relativo à página) | + +Depois de alterar `members.json` no servidor, não é obrigatório recarregar o Apache — é ficheiro estático servido como qualquer outro. diff --git a/site/build_directory.py b/site/build_directory.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Gera dados públicos para a landing runv.club a partir de /var/lib/runv/users.json. + +Expõe apenas: username, since (created_at ISO), path (~user/), e opcionalmente +homepage_mtime se --homes-root existir e public_html/index.html for legível. + +Nunca escreve email, fingerprint de chave nem campos de quota detalhados. + +Executar no servidor (cron) como root, ou localmente com --users-json apontando +para uma cópia de teste. Se users.json ainda não existir, assume lista vazia (aviso em stderr). + +Python 3, só biblioteca padrão. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Gera members.json público para site/") + here = Path(__file__).resolve().parent + default_out = here / "public" / "data" / "members.json" + p.add_argument( + "--users-json", + type=Path, + default=Path("/var/lib/runv/users.json"), + help="Caminho para users.json do provisionador", + ) + p.add_argument( + "--output", + "-o", + type=Path, + default=default_out, + help="Ficheiro JSON de saída (pasta criada se necessário)", + ) + p.add_argument( + "--homes-root", + type=Path, + default=None, + help="Se definido (ex. /home), tenta ler mtime de <root>/<user>/public_html/index.html", + ) + p.add_argument( + "--dry-run", + action="store_true", + help="Imprime JSON para stdout em vez de gravar ficheiro", + ) + return p.parse_args() + + +def homepage_mtime_iso(homes_root: Path, username: str) -> str | None: + idx = homes_root / username / "public_html" / "index.html" + try: + st = idx.stat() + ts = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) + return ts.isoformat() + except OSError: + return None + + +def load_users(path: Path) -> list[dict]: + if not path.exists(): + print( + f"Aviso: {path} ainda não existe; a assumir lista vazia (0 membros).", + file=sys.stderr, + ) + return [] + if not path.is_file(): + raise SystemExit(f"Não é um ficheiro: {path}") + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return [] + data = json.loads(raw) + if not isinstance(data, list): + raise SystemExit(f"Formato inválido: esperado lista JSON em {path}") + return data + + +def main() -> None: + args = parse_args() + users = load_users(args.users_json) + members: list[dict] = [] + for row in users: + if not isinstance(row, dict): + continue + username = row.get("username") + if not isinstance(username, str) or not username: + continue + created = row.get("created_at") + since = created if isinstance(created, str) else "" + entry: dict = { + "username": username, + "since": since, + "path": f"/~{username}/", + } + if args.homes_root is not None: + mt = homepage_mtime_iso(args.homes_root, username) + if mt: + entry["homepage_mtime"] = mt + members.append(entry) + + members.sort(key=lambda x: x["username"].lower()) + + out_json = json.dumps(members, ensure_ascii=False, indent=2) + "\n" + if args.dry_run: + sys.stdout.write(out_json) + return + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(out_json, encoding="utf-8") + print(f"Escritos {len(members)} membros em {args.output}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/site/example-users.json b/site/example-users.json @@ -0,0 +1,38 @@ +[ + { + "username": "alice", + "email": "admin@example.com", + "public_key_fingerprint": "REDACTED", + "created_at": "2026-02-15T10:00:00+00:00", + "created_by": "root", + "home_directory": "/home/alice", + "status": "active", + "quota_enabled": true, + "quota_soft_mb": 450, + "quota_hard_mb": 500, + "quota_inode_soft": 10000, + "quota_inode_hard": 12000, + "quota_filesystem": "ext4", + "quota_mountpoint": "/", + "quota_applied_at": "2026-02-15T10:00:05+00:00", + "quota_status": "applied" + }, + { + "username": "bob", + "email": "admin@example.com", + "public_key_fingerprint": "REDACTED", + "created_at": "2026-03-01T08:30:00+00:00", + "created_by": "root", + "home_directory": "/home/bob", + "status": "active", + "quota_enabled": true, + "quota_soft_mb": 450, + "quota_hard_mb": 500, + "quota_inode_soft": 10000, + "quota_inode_hard": 12000, + "quota_filesystem": "ext4", + "quota_mountpoint": "/", + "quota_applied_at": "2026-03-01T08:30:02+00:00", + "quota_status": "applied" + } +] diff --git a/site/genlanding.md b/site/genlanding.md @@ -0,0 +1,105 @@ +# genlanding.py — Apache para a landing runv + +Script em [`genlanding.py`](genlanding.py) (Python 3, stdlib) que configura o **Apache** em Debian para: + +- servir a landing estática (`site/public/`) num **VirtualHost** dedicado; +- activar **`mod_userdir`** e **`mod_rewrite`** (redirect **www → apex** em HTTP); +- em **produção** e em **`--dev`**: por omissão desactiva `000-default.conf` (salvo `--keep-default-site`) para pedidos por IP servirem a landing; opcional **Certbot** em produção (`--certbot`). + +Não substitui o manual de [`scripts/docs/2 - server setup.md`](../scripts/docs/2%20-%20server%20setup.md) para aprender permissões e diagnóstico; automatiza o caminho habitual após DNS e pacotes base. + +## Pré-requisitos + +- **Debian** com `apache2` instalado (recomendado: [`scripts/admin/starthere.py`](../scripts/admin/starthere.py) antes). +- **Produção:** DNS de `runv.club` e `www.runv.club` a apontar para o servidor; porta **80** acessível se fores usar **Certbot**. +- Executar como **root** (`sudo`), excepto `--dry-run` (permite pré-visualizar noutra máquina). + +## Uso rápido + +```bash +cd /caminho/ao/runv-server +sudo python3 site/genlanding.py +``` + +Produção (valores por omissão: `ServerName` **runv.club**, `DocumentRoot` **`/var/www/runv.club/html`**, ficheiro **`/etc/apache2/sites-available/runv.club.conf`**). + +Pré-visualizar sem alterar nada: + +```bash +python3 site/genlanding.py --dry-run +``` + +## Flags principais + +| Flag | Descrição | +|------|-----------| +| `--dev` | Modo **teste local**: `runv.local`, `DocumentRoot` `/var/www/runv-dev/html`, ficheiro `runv-dev.conf`; por omissão **desactiva** `000-default` (igual à produção). | +| `--domain NAME` | Substitui o `ServerName` (e `www.NAME` como alias). | +| `--document-root PATH` | Substitui o `DocumentRoot`. | +| `--source PATH` | Origem da landing (default: `site/public` relativo ao script). | +| `--keep-default-site` | Mantém `000-default.conf` activo (**produção** e **`--dev`**). Com `000-default` activo, pedidos por **IP** não casam com `ServerName` e continuam a mostrar a página Debian; ver secção abaixo. | +| `--certbot` | Depois de configurar HTTP, executa `certbot --apache -d <domínio> -d www.<domínio>`. **Incompatível com `--dev`.** | +| `--dry-run` | Mostra o VirtualHost e comandos; não exige root. | + +## Pedidos por IP vs `ServerName` + +Com **vários** `VirtualHost *:80`, o Apache escolhe o vhost pelo cabeçalho **`Host`**. Se pedires `http://192.168.50.85/`, o `Host` é o IP (ou não coincide com `runv.local`) → o servidor usa o vhost **por defeito** na porta 80, que no Debian costuma ser **`000-default`** (`/var/www/html`, página “It works!”). + +- **Por omissão** (`--dev` ou produção **sem** `--keep-default-site`): o script desactiva `000-default`; o vhost runv fica como único (ou primeiro) em `:80` e **pedidos por IP** passam a servir a **landing** no `DocumentRoot` configurado. +- Com **`--keep-default-site`**: mantém-se `000-default`; para ver a landing usa **`http://runv.local/`** (com `/etc/hosts`) ou força o host no cliente: + + ```bash + curl -sI -H 'Host: runv.local' http://192.168.50.85/ + ``` + +Se `curl http://runv.local/` não devolver nada na VM, confirma que **`runv.local`** está em **`/etc/hosts`** a apontar para o IP correcto (ex. `127.0.0.1` ou o IP da interface). + +## Modo `--dev` (VM ou laptop) + +1. Correr: `sudo python3 site/genlanding.py --dev` (por omissão desactiva `000-default`; usa `--keep-default-site` se quiseres manter a página Debian em paralelo). +2. Opcional: no **cliente** ou na VM, editar `/etc/hosts` para nome bonito: + + ```text + 127.0.0.1 runv.local www.runv.local + ``` + + (Se o Apache estiver noutra máquina, usa o IP dessa máquina em vez de `127.0.0.1`.) + +3. Abrir `http://runv.local/` ou `http://IP_DA_VM/` no browser (sem `--keep-default-site`). O redirect **www → apex** usa **HTTP** (não uses Certbot em `--dev`). + +## Ordem sugerida (produção) + +1. `starthere.py` — pacotes, Apache a correr, quotas, etc. +2. `genlanding.py` — VirtualHost + cópia da landing. +3. Opcional: `genlanding.py --certbot` **numa segunda execução** (ou a primeira já com `--certbot` se tudo estiver pronto), **depois** de confirmar HTTP no domínio. +4. **Cron** para [`build_directory.py`](build_directory.py) gerar `members.json` no `DocumentRoot` (ver [README.md](README.md)). + +## Relação com `build_directory.py` + +- `genlanding.py` **copia** o conteúdo actual de `public/` (incluindo `data/members.json` se existir). +- A lista de membros actualizada vem do **cron** que corre `build_directory.py` com `-o` apontando para + `.../html/data/members.json` (o mesmo `DocumentRoot` que usaste no genlanding). + +### Lista pública (só utilizadores reais) + +- **`public/data/members.json`** no repositório deve ser **`[]`** (placeholder). **Não** versionar nomes fictícios como membros da comunidade; a única fonte de verdade para quem aparece no site é **`/var/lib/runv/users.json`**, filtrada por `build_directory.py`. +- **`site/example-users.json`** existe só para desenvolvimento / testes locais com `build_directory.py --users-json`, não para ship em produção como se fossem contas reais. +- **Atenção:** cada execução de `genlanding.py` **substitui** o `DocumentRoot` pela cópia de `public/`; isso repõe `members.json` para o que está no repo (tipicamente `[]`). Depois de um deploy com `genlanding.py`, volta a correr **`build_directory.py`** (ou espera o cron) para repor a lista gerada a partir de `users.json`. + +## O que o script não faz + +- Não cria utilizadores Unix nem mexe em `users.json`. +- Não configura **firewall** nem **DNS**. +- Não valida certificados além do que o **Certbot** fizer se invocares `--certbot`. + +## Ficheiros tocados + +| Caminho | Acção | +|---------|--------| +| `/etc/apache2/sites-available/runv.club.conf` ou `runv-dev.conf` | Criado / sobrescrito | +| `DocumentRoot` (ex. `/var/www/runv.club/html`) | Conteúdo substituído pela cópia de `public/` | +| `a2enmod userdir`, `rewrite` | Activados | +| `a2dissite 000-default` | Sem `--keep-default-site` (produção ou `--dev`); falha silenciosa se já desactivado | +| `a2ensite` | Activa o site runv | + +Versão do script: ver `python3 site/genlanding.py --version`. diff --git a/site/genlanding.py b/site/genlanding.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +Configura o Apache (Debian) para servir a landing runv.club: VirtualHost, +mod_userdir + mod_rewrite, cópia de site/public para DocumentRoot, redirect +www → apex em HTTP. Produção ou modo --dev para testes locais. + +Executar como root (excepto --dry-run). Apenas biblioteca padrão Python 3. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import grp +import os +import pwd +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Final + +VERSION: Final[str] = "0.01" +EXIT_OK: Final[int] = 0 +EXIT_USAGE: Final[int] = 1 +EXIT_ERROR: Final[int] = 2 + +SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_SOURCE: Final[Path] = SCRIPT_DIR / "public" + +PROD_DOMAIN: Final[str] = "runv.club" +PROD_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv.club/html") +PROD_SITE_CONF: Final[str] = "runv.club.conf" + +DEV_DOMAIN: Final[str] = "runv.local" +DEV_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv-dev/html") +DEV_SITE_CONF: Final[str] = "runv-dev.conf" + +APACHE_SITES_AVAILABLE: Final[Path] = Path("/etc/apache2/sites-available") +APACHE_CTL: Final[str] = "/usr/sbin/apache2ctl" +DEFAULT_SITE: Final[str] = "000-default.conf" + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def require_root(*, dry_run: bool) -> None: + if dry_run: + return + if os.geteuid() != 0: + eprint("Erro: execute como root (sudo), excepto com --dry-run.") + raise SystemExit(EXIT_USAGE) + + +def apache_installed() -> bool: + return Path(APACHE_CTL).is_file() + + +def log_tag_from_domain(domain: str) -> str: + """Nome seguro para ficheiros de log Apache.""" + return re.sub(r"[^\w.-]+", "-", domain).strip("-") or "runv" + + +def render_vhost( + *, + server_name: str, + document_root: Path, + log_tag: str, +) -> str: + www_alias = f"www.{server_name}" + return f"""# Gerado por genlanding.py v{VERSION} — runv.club +# Não editar à mão sem saber o que faz; volte a correr o script ou ajuste e recarregue o Apache. + +<VirtualHost *:80> + ServerName {server_name} + ServerAlias {www_alias} + DocumentRoot {document_root} + + # Redirect www → apex (HTTP; após Certbot o bloco :80 pode ser actualizado pelo certbot) + RewriteEngine On + RewriteCond %{{HTTP_HOST}} ^www\\.(.+)$ [NC] + RewriteRule ^ http://%1%{{REQUEST_URI}} [R=301,L] + + <Directory {document_root}> + Options FollowSymLinks + AllowOverride None + Require all granted + </Directory> + + ErrorLog ${{APACHE_LOG_DIR}}/{log_tag}-error.log + CustomLog ${{APACHE_LOG_DIR}}/{log_tag}-access.log combined +</VirtualHost> +""" + + +def run_cmd( + cmd: list[str], + *, + dry_run: bool, + verbose: bool = True, +) -> None: + if verbose: + print(f" $ {' '.join(cmd)}") + if dry_run: + return + r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"Comando falhou ({r.returncode}): {' '.join(cmd)}\n{err}") + + +def run_cmd_allow_fail( + cmd: list[str], + *, + dry_run: bool, + ok_hint: str = "", +) -> None: + print(f" $ {' '.join(cmd)}") + if dry_run: + return + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if r.returncode == 0: + print(f" [ok] {' '.join(cmd)}") + else: + msg = (r.stderr or r.stdout or "").strip() or ok_hint + print(f" [info] {' '.join(cmd)} — {msg or 'ignorado (já inactivo?)'}") + + +def copy_landing(source: Path, dest: Path, *, dry_run: bool) -> None: + if not source.is_dir(): + raise FileNotFoundError(f"Pasta origem inexistente: {source}") + if dry_run: + print(f" [dry-run] copiaria {source} -> {dest}") + return + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(source, dest) + + +def chown_www_data(path: Path, *, dry_run: bool) -> None: + if dry_run: + print(f" [dry-run] chown -R www-data:www-data {path}") + return + try: + u = pwd.getpwnam("www-data") + g = grp.getgrnam("www-data") + except KeyError as e: + raise RuntimeError("Utilizador ou grupo 'www-data' não encontrado.") from e + run_cmd( + ["chown", "-R", f"{u.pw_uid}:{g.gr_gid}", str(path)], + dry_run=False, + verbose=True, + ) + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Configura Apache para a landing runv (VirtualHost, userdir, cópia de public/).", + ) + p.add_argument( + "--source", + type=Path, + default=DEFAULT_SOURCE, + help=f"pasta com a landing (default: {DEFAULT_SOURCE})", + ) + p.add_argument( + "--document-root", + type=Path, + default=None, + help="DocumentRoot do VirtualHost (default: prod ou dev conforme --dev)", + ) + p.add_argument( + "--domain", + type=str, + default=None, + help="ServerName (default: runv.club ou runv.local com --dev)", + ) + p.add_argument( + "--dev", + action="store_true", + help="modo teste local: runv.local, runv-dev.conf, não desactiva 000-default", + ) + p.add_argument("--dry-run", action="store_true", help="mostra acções sem alterar o sistema") + p.add_argument( + "--certbot", + action="store_true", + help="executa certbot --apache após configurar HTTP (incompatível com --dev)", + ) + p.add_argument( + "--keep-default-site", + action="store_true", + help="não desactiva 000-default.conf (produção e --dev: mantém página Debian; pedidos por IP não casam com ServerName)", + ) + p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club") + return p.parse_args(argv) + + +def resolve_profile(args: argparse.Namespace) -> tuple[str, Path, str, bool]: + """ + Retorna (domain, document_root, site_conf_filename, disable_default_site). + """ + if args.dev: + domain = (args.domain or DEV_DOMAIN).strip().lower() + doc = args.document_root or DEV_DOCUMENT_ROOT + conf = DEV_SITE_CONF + else: + domain = (args.domain or PROD_DOMAIN).strip().lower() + doc = args.document_root or PROD_DOCUMENT_ROOT + conf = PROD_SITE_CONF + # Mesma regra em prod e --dev: sem --keep-default-site, desactiva 000-default para que + # pedidos por IP (Host sem match) caiam no vhost runv em vez da página Debian. + disable_default = not args.keep_default_site + return domain, doc.resolve(), conf, disable_default + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + if args.dev and args.certbot: + eprint("Erro: --certbot não pode ser usado com --dev (Certbot não serve para domínios locais).") + return EXIT_USAGE + + require_root(dry_run=args.dry_run) + + domain, document_root, site_conf_name, disable_default = resolve_profile(args) + source = args.source.resolve() + conf_path = APACHE_SITES_AVAILABLE / site_conf_name + log_tag = log_tag_from_domain(domain) + + print(f"== genlanding.py v{VERSION} — runv.club ==") + print(f" modo: {'dev' if args.dev else 'produção'}") + print(f" ServerName: {domain}") + print(f" DocumentRoot: {document_root}") + print(f" ficheiro site: {conf_path}") + print(f" origem: {source}") + print() + + 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.") + return EXIT_ERROR + + vhost_body = render_vhost( + server_name=domain, + document_root=document_root, + log_tag=log_tag, + ) + + try: + if args.dry_run: + print("--- VirtualHost (pré-visualização) ---") + print(vhost_body) + + run_cmd(["a2enmod", "userdir"], dry_run=args.dry_run) + run_cmd(["a2enmod", "rewrite"], dry_run=args.dry_run) + + copy_landing(source, document_root, dry_run=args.dry_run) + if not args.dry_run: + chown_www_data(document_root, dry_run=False) + + if args.dry_run: + print(f" [dry-run] escreveria {conf_path}") + else: + conf_path.write_text(vhost_body, encoding="utf-8") + os.chmod(conf_path, 0o644) + print(f" [ok] VirtualHost em {conf_path}") + + if disable_default: + run_cmd_allow_fail( + ["a2dissite", DEFAULT_SITE], + dry_run=args.dry_run, + ok_hint="site por defeito já estava desactivado", + ) + else: + print(" [info] site por defeito 000-default mantido activo.") + + run_cmd(["a2ensite", site_conf_name], dry_run=args.dry_run) + + run_cmd([APACHE_CTL, "configtest"], dry_run=args.dry_run) + + if not args.dry_run: + subprocess.run( + ["systemctl", "reload", "apache2"], + check=True, + timeout=60, + ) + else: + print(" [dry-run] systemctl reload apache2") + print(" [ok] Apache recarregado.") + + if args.certbot: + www = f"www.{domain}" + print() + certbot_bin = shutil.which("certbot") + if not certbot_bin: + eprint("Erro: certbot não encontrado no PATH. Instale: sudo apt install -y certbot python3-certbot-apache") + return EXIT_ERROR + print(" A executar Certbot (interactivo se necessário)...") + if args.dry_run: + print(f" [dry-run] {certbot_bin} --apache -d {domain} -d {www}") + else: + r = subprocess.run( + [certbot_bin, "--apache", "-d", domain, "-d", www], + check=False, + ) + if r.returncode != 0: + eprint("Aviso: certbot terminou com código != 0; verifique TLS manualmente.") + return EXIT_ERROR + print(" [ok] Certbot concluído.") + + except (FileNotFoundError, OSError, RuntimeError) as e: + eprint(f"Erro: {e}") + return EXIT_ERROR + + print() + print("Próximos passos:") + print(f" - Testar: curl -sI http://{domain}/ | head -5") + if args.dev: + print(" - Em /etc/hosts (cliente ou VM): 127.0.0.1 runv.local www.runv.local") + print( + " - Membros na constelação: só contas reais (provisionadas → /var/lib/runv/users.json). " + "Gerar lista pública com site/build_directory.py no DocumentRoot (cron; ver site/README.md). " + "O public/data/members.json no repo fica [] até esse passo." + ) + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/public/assets/app.js b/site/public/assets/app.js @@ -0,0 +1,175 @@ +/** + * Landing runv.club — carrega members.json (só dados públicos) e coloca + * pontos clicáveis (links) fora da coluna de texto; brilho ligado à data since. + * Array vazio: sem estrelas até build_directory.py gerar o JSON a partir de users.json. + */ + +function hashUsername(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function parseSince(iso) { + if (!iso) return 0; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +function starBrightness(sinceMs) { + const now = Date.now(); + const age = Math.max(0, now - sinceMs); + const halfYear = 180 * 24 * 3600 * 1000; + const t = Math.exp(-age / halfYear); + return 0.25 + 0.75 * t; +} + +function seededPoint(w, h, seed) { + const x = (Math.sin(seed * 0.001) * 43758.5453) % 1; + const y = (Math.cos(seed * 0.002) * 23421.6789) % 1; + const nx = ((x < 0 ? -x : x) * 0.85 + 0.075) * w; + const ny = ((y < 0 ? -y : y) * 0.85 + 0.075) * h; + return { x: nx, y: ny }; +} + +/** Expande o rect em px (viewport) para manter margem em relação ao texto. */ +function inflateRect(r, pad) { + return { + left: r.left - pad, + top: r.top - pad, + right: r.right + pad, + bottom: r.bottom + pad, + }; +} + +function pointInRect(x, y, rect) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +/** + * Posição para um ponto: fora da coluna `.wrap` (texto), com fallback para + * faixas laterais ou cantos quando o ecrã é estreito. + */ +function findStarPosition(w, h, seed, exclude) { + const edge = 14; + for (let attempt = 0; attempt < 140; attempt++) { + const s = seed + attempt * 9973; + const { x, y } = seededPoint(w, h, s); + const px = Math.max(edge, Math.min(w - edge, x)); + const py = Math.max(edge, Math.min(h - edge, y)); + if (!pointInRect(px, py, exclude)) return { x: px, y: py }; + } + + const spaceLeft = Math.max(0, exclude.left - edge); + const spaceRight = Math.max(0, w - exclude.right - edge); + const spaceAbove = Math.max(0, exclude.top - edge); + const spaceBelow = Math.max(0, h - exclude.bottom - edge); + const order = [ + [spaceLeft, "left"], + [spaceRight, "right"], + [spaceAbove, "above"], + [spaceBelow, "below"], + ].sort((a, b) => b[0] - a[0]); + + const yJitter = edge + ((seed >>> 5) % Math.max(1, h - 2 * edge)); + const xJitter = edge + ((seed >>> 9) % Math.max(1, w - 2 * edge)); + + for (const [, side] of order) { + if (side === "left" && spaceLeft > 6) + return { x: edge + spaceLeft * 0.45, y: yJitter }; + if (side === "right" && spaceRight > 6) + return { x: exclude.right + spaceRight * 0.55, y: yJitter }; + if (side === "above" && spaceAbove > 6) + return { x: xJitter, y: edge + spaceAbove * 0.45 }; + if (side === "below" && spaceBelow > 6) + return { x: xJitter, y: exclude.bottom + spaceBelow * 0.55 }; + } + + const cornerX = seed % 2 === 0 ? edge : w - edge; + const cornerY = (seed >>> 3) % 2 === 0 ? edge : h - edge; + return { x: cornerX, y: cornerY }; +} + +function validMembers(members) { + return members.filter( + (m) => + m && + typeof m.username === "string" && + m.username.length > 0 && + typeof m.path === "string" && + m.path.length > 0 + ); +} + +function renderStarLinks(container, wrapEl, members) { + if (!container) return; + + container.replaceChildren(); + + const w = window.innerWidth; + const h = window.innerHeight; + if (w < 32 || h < 32) return; + + const pad = 36; + const exclude = wrapEl + ? inflateRect(wrapEl.getBoundingClientRect(), pad) + : { left: 0, top: 0, right: w, bottom: h }; + + for (const m of validMembers(members)) { + const seed = hashUsername(m.username); + const { x, y } = findStarPosition(w, h, seed, exclude); + const bright = starBrightness(parseSince(m.since)); + + const a = document.createElement("a"); + a.className = "star-member"; + a.href = m.path; + a.setAttribute("aria-label", `Site de ~${m.username}`); + a.textContent = `~${m.username}`; + a.style.left = `${x}px`; + a.style.top = `${y}px`; + a.style.opacity = String(0.55 + bright * 0.43); + const scale = 0.78 + bright * 0.42; + a.style.setProperty("--star-scale", String(scale)); + + container.appendChild(a); + } +} + +async function main() { + const starRoot = document.getElementById("starfield"); + const wrapEl = document.querySelector(".wrap"); + + let members = []; + + try { + const res = await fetch("data/members.json", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + members = await res.json(); + if (!Array.isArray(members)) throw new Error("members.json inválido"); + } catch { + members = []; + } + + let starRaf = 0; + const scheduleStars = () => { + if (!starRoot) return; + cancelAnimationFrame(starRaf); + starRaf = requestAnimationFrame(() => { + renderStarLinks(starRoot, wrapEl, members); + }); + }; + + scheduleStars(); + + window.addEventListener("resize", scheduleStars, { passive: true }); + window.addEventListener("scroll", scheduleStars, { passive: true, capture: true }); + if (typeof ResizeObserver !== "undefined" && wrapEl) { + const ro = new ResizeObserver(scheduleStars); + ro.observe(wrapEl); + } +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/site/public/assets/style.css b/site/public/assets/style.css @@ -0,0 +1,570 @@ +:root { + --bg: #0c0b0f; + --fg: #e8e4dc; + --muted: #8a8494; + --accent: #7fd4a0; + --accent2: #c49cf5; + --grid: rgba(232, 228, 220, 0.06); + --stroke: rgba(232, 228, 220, 0.1); + --radius: 10px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + overflow-x: hidden; + max-width: 100%; +} + +body { + margin: 0; + min-height: 100vh; + max-width: 100%; + overflow-x: hidden; + font-family: "Atkinson Hyperlegible", Georgia, serif; + font-size: 1.05rem; + line-height: 1.6; + color: var(--fg); + background: var(--bg); + background-image: + linear-gradient(var(--grid) 1px, transparent 1px), + linear-gradient(90deg, var(--grid) 1px, transparent 1px); + background-size: 24px 24px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.starfield-root { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; +} + +.star-member { + --star-scale: 1; + position: fixed; + width: 10px; + height: 10px; + margin: 0; + padding: 0; + border: none; + border-radius: 50%; + pointer-events: auto; + cursor: pointer; + transform: translate(-50%, -50%) scale(var(--star-scale)); + text-indent: -9999px; + overflow: hidden; + white-space: nowrap; + background: var(--accent); + opacity: 0.72; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.star-member:hover, +.star-member:focus-visible { + opacity: 1; + outline: none; + transform: translate(-50%, -50%) scale(calc(var(--star-scale) * 1.1)); +} + +.star-member:focus-visible { + outline: 2px solid var(--accent2); + outline-offset: 2px; +} + +.wrap { + position: relative; + z-index: 1; + width: 100%; + max-width: min(46rem, 100%); + margin: 0 auto; + padding: 1.35rem clamp(1rem, 4vw, 1.35rem) 4.5rem; + box-sizing: border-box; +} + +.hero { + margin-bottom: 0.5rem; +} + +.eyebrow { + margin: 0 0 0.35rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.hero-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; + margin: 0 0 1rem; + font-family: "Syne", sans-serif; + font-weight: 600; + font-size: 0.95rem; +} + +.hero-nav a { + color: var(--accent2); + text-decoration: none; +} + +.hero-nav a:hover { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; +} + +.hero-nav-sep { + color: var(--muted); + user-select: none; +} + +.hero-nav-current { + color: var(--fg); +} + +.top-nav { + margin: 0 0 1.5rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.85rem; +} + +.top-nav a { + color: var(--accent); + text-decoration: none; +} + +.top-nav a:hover { + color: var(--accent2); + text-decoration: underline; + text-underline-offset: 2px; +} + +.subpage-title { + margin: 0 0 0.5rem; +} + +.subpage-intro { + margin: 0 0 1.25rem; + color: var(--muted); + font-size: 1rem; + max-width: 38rem; +} + +.placeholder-block { + margin: 0; + padding: 1.25rem 1.15rem; + border: 1px dashed var(--stroke); + border-radius: var(--radius); + color: var(--muted); + font-size: 0.98rem; +} + +.section.subpage-main:first-of-type { + margin-top: 1.25rem; +} + +.hero-title { + font-family: "Syne", sans-serif; + font-weight: 800; + font-size: clamp(2.4rem, 7vw, 3.35rem); + letter-spacing: -0.03em; + line-height: 1.05; + margin: 0 0 1rem; + color: var(--accent); +} + +.hero-subtitle { + margin: 0 0 1.75rem; + font-family: "Syne", sans-serif; + font-weight: 600; + font-size: clamp(1.05rem, 2.4vw, 1.25rem); + line-height: 1.45; + color: var(--accent2); + max-width: 38rem; +} + +.hero-lead { + margin: 0 0 1.1rem; + font-size: 1.06rem; + line-height: 1.65; + color: var(--fg); + max-width: 40rem; +} + +.hero-lead:last-of-type { + margin-bottom: 1.75rem; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.6rem; + margin: 0 0 2.5rem; + padding: 0; + list-style: none; +} + +.chips li { + margin: 0; + padding: 0.4rem 0.75rem; + font-size: 0.78rem; + line-height: 1.35; + font-family: "IBM Plex Mono", monospace; + color: var(--muted); + border: 1px solid rgba(196, 156, 245, 0.28); + border-radius: 999px; + background: rgba(196, 156, 245, 0.06); +} + +.section { + margin-top: 2.75rem; +} + +.section:first-of-type { + margin-top: 0; +} + +.prose-block { + max-width: 100%; + padding: 1.75rem clamp(1rem, 3vw, 1.35rem); + background: rgba(0, 0, 0, 0.28); + border: 1px solid var(--stroke); + border-radius: var(--radius); + box-sizing: border-box; + overflow-x: hidden; +} + +.prose-block code { + word-break: break-word; + overflow-wrap: anywhere; +} + +.prose-block h2 { + margin-top: 0; +} + +.prose-block p:last-child { + margin-bottom: 0; +} + +h1 { + font-family: "Syne", sans-serif; + font-weight: 800; + font-size: clamp(1.75rem, 4vw, 2.35rem); + letter-spacing: -0.02em; + margin: 0 0 0.5rem; + color: var(--accent); +} + +h2 { + font-family: "Syne", sans-serif; + font-weight: 700; + font-size: 1.2rem; + margin: 0 0 0.85rem; + color: var(--accent2); + letter-spacing: -0.02em; +} + +.lede { + color: var(--muted); + font-size: 1.02rem; + max-width: 40rem; +} + +.checklist { + margin: 0.5rem 0 1rem; + padding: 0 0 0 1.15rem; + color: var(--fg); +} + +.checklist li { + margin: 0 0 0.45rem; + padding-left: 0.15rem; +} + +.checklist li::marker { + color: var(--accent); +} + +.pillars { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.75rem 0 1rem; + padding: 0; + list-style: none; +} + +.pillars li { + margin: 0; + padding: 0.35rem 0.7rem; + font-size: 0.88rem; + color: var(--fg); + background: rgba(127, 212, 160, 0.1); + border: 1px solid rgba(127, 212, 160, 0.25); + border-radius: 6px; +} + +.pull-soft { + margin: 1rem 0 0.35rem; + color: var(--muted); + font-style: italic; +} + +.pull-accent { + margin: 0; + font-size: 1.08rem; + color: var(--accent); +} + +.prose-block a, +.section-invite a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.prose-block a:hover, +.section-invite a:hover { + color: var(--accent2); +} + +.section-invite { + margin-top: 2.75rem; + padding: 1.75rem 0 0.5rem; + border-top: 1px solid var(--stroke); +} + +.section-invite p { + margin: 0 0 1rem; + font-size: 1.08rem; + line-height: 1.62; + max-width: 38rem; +} + +.invite-line { + font-family: "Syne", sans-serif; + font-weight: 700; + font-size: clamp(1rem, 2.5vw, 1.15rem); + color: var(--accent2); + letter-spacing: -0.01em; +} + +.cta-panel { + margin: 2.5rem 0 0; + padding: 2rem 1.5rem; + text-align: center; + border-radius: var(--radius); + border: 1px solid rgba(127, 212, 160, 0.3); + background: rgba(0, 0, 0, 0.32); +} + +.cta-brand { + margin: 0; + font-family: "Syne", sans-serif; + font-weight: 800; + font-size: clamp(1.85rem, 5vw, 2.5rem); + letter-spacing: -0.03em; + color: var(--accent); +} + +.cta-tagline { + margin: 0.65rem 0 0; + font-family: "Syne", sans-serif; + font-weight: 600; + font-size: clamp(0.95rem, 2.2vw, 1.1rem); + color: var(--fg); + letter-spacing: 0.04em; +} + +.cta-join { + margin: 1.1rem 0 0; + font-size: 0.98rem; +} + +.cta-join a { + color: var(--accent2); + text-decoration: none; + font-weight: 600; +} + +.cta-join a:hover, +.cta-join a:focus-visible { + text-decoration: underline; +} + +.ascii-block { + display: flex; + justify-content: flex-start; + width: 100%; + max-width: 100%; + margin: 0 0 1.5rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.ascii-block::-webkit-scrollbar { + display: none; + height: 0; +} + +.ascii-inner { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + max-width: 100%; +} + +.ascii { + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: clamp(0.58rem, 1.65vw, 0.68rem); + line-height: 1.15; + letter-spacing: 0; + color: var(--accent); + white-space: pre; + text-align: left; + margin: 0; + padding: 1rem; + width: max-content; + max-width: 100%; + box-sizing: border-box; + overflow-x: auto; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(127, 212, 160, 0.2); + border-radius: var(--radius); + -webkit-font-smoothing: antialiased; +} + +.ascii-tagline { + margin: 0.45rem 0 0; + padding: 0; + max-width: 40rem; + box-sizing: border-box; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: clamp(0.52rem, 1.45vw, 0.62rem); + line-height: 1.35; + letter-spacing: 0.02em; + color: var(--accent); + text-align: left; +} + +.code-block { + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 0.82rem; + line-height: 1.45; + margin: 0.75rem 0 1.25rem; + padding: 0.85rem 1rem; + max-width: 100%; + overflow-x: hidden; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + color: var(--fg); + background: rgba(0, 0, 0, 0.45); + border: 1px solid var(--stroke); + border-radius: var(--radius); +} + +.code-block code { + font-family: inherit; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +/* Identidade SSH em destaque (ex.: entre@runv.club) */ +.ssh-identity { + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-weight: 600; + padding: 0.12em 0.5em; + border-radius: 6px; + background: linear-gradient( + 135deg, + rgba(127, 212, 160, 0.2) 0%, + rgba(196, 156, 245, 0.18) 100% + ); + color: var(--accent); + border: 1px solid rgba(127, 212, 160, 0.45); + box-shadow: 0 0 20px rgba(127, 212, 160, 0.12); + white-space: nowrap; +} + +.ssh-identity-lg { + display: inline-block; + margin-top: 0.35rem; + max-width: 100%; + font-size: clamp(1rem, 4.5vw, 1.25rem); + letter-spacing: 0.02em; + white-space: normal; + word-break: break-all; + text-align: center; +} + +.entre-callout { + margin: 1.5rem 0 2rem; + padding: 1.1rem 1rem; + text-align: center; + border-radius: var(--radius); + border: 1px solid rgba(196, 156, 245, 0.35); + background: rgba(0, 0, 0, 0.35); +} + +.entre-callout-label { + display: block; + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + font-family: "IBM Plex Mono", ui-monospace, monospace; + margin-bottom: 0.35rem; +} + +.site-footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--stroke); + font-size: 0.9rem; + color: var(--muted); + text-align: center; +} + +.site-footer p { + margin: 0; +} + +.site-footer a { + color: var(--accent); + text-decoration: none; +} + +.site-footer a:hover, +.site-footer a:focus-visible { + text-decoration: underline; +} + diff --git a/site/public/data/members.json b/site/public/data/members.json @@ -0,0 +1 @@ +[] diff --git a/site/public/index.html b/site/public/index.html @@ -0,0 +1,164 @@ +<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>runv.club — comunidade brasileira estilo tilde</title> + <meta name="description" content="Comunidade brasileira em estilo tilde: Unix/Linux, página pessoal, terminal, estudo e convívio — menos algoritmo, mais presença."> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="assets/style.css"> +</head> +<body> + <div id="starfield" class="starfield-root"></div> + + <div class="wrap"> + <header class="hero"> + <div class="ascii-block"> + <div class="ascii-inner"> + <pre class="ascii" role="img" aria-label="Letras RUNV em arte ASCII">██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗ +██╔══██╗██║ ██║████╗ ██║██║ ██║ +██████╔╝██║ ██║██╔██╗ ██║██║ ██║ +██╔══██╗╚██╗ ██╔╝██║╚██╗██║╚██╗ ██╔╝ +██║ ██║ ╚████╔╝ ██║ ╚████║ ╚████╔╝ +╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═══╝ ╚═══╝</pre> + <p class="ascii-tagline">.club — um computador para compartilhar</p> + </div> + </div> + + <p class="eyebrow">Comunidade brasileira · estilo tilde · pubnix</p> + <nav class="hero-nav" aria-label="Outras páginas"> + <a href="/news/">Notícias</a> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <a href="/wiki/">Wiki</a> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <a href="/junte-se/">Junte-se</a> + </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. + </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. + </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. + </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. + </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>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> + </header> + + <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. + </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. + </p> + </section> + + <section class="section prose-block" aria-labelledby="who-h"> + <h2 id="who-h">Para quem é</h2> + <p>A runv é para quem quer:</p> + <ul class="checklist"> + <li>aprender Unix e Linux na prática;</li> + <li>entender melhor shell, terminal, SSH, arquivos e servidores;</li> + <li>criar sua própria página pessoal;</li> + <li>estudar programação, automação e administração de sistemas;</li> + <li>conhecer gente com interesses parecidos;</li> + <li>participar de uma comunidade tranquila, amigável e colaborativa;</li> + <li>experimentar uma internet mais artesanal, criativa e humana.</li> + </ul> + <p class="pull-soft">Você não precisa ser especialista. Não precisa saber tudo. Não precisa chegar pronto.</p> + <p class="pull-accent"><strong>Todos são bem-vindos.</strong></p> + </section> + + <section class="section prose-block" aria-labelledby="spirit-h"> + <h2 id="spirit-h">O espírito da comunidade</h2> + <p> + A runv.club foi pensada para lembrar os antigos ambientes Unix públicos e comunitários — os chamados pubnixes — em que pessoas se encontravam para aprender, explorar, conversar, publicar e criar juntas. + </p> + <p>É uma proposta que valoriza:</p> + <ul class="pillars"> + <li>curiosidade</li> + <li>estudo constante</li> + <li>autonomia</li> + <li>gentileza</li> + <li>troca de conhecimento</li> + <li>criatividade</li> + <li>convivência respeitosa</li> + </ul> + <p class="pull-soft">Aqui, aprender e conviver andam juntos.</p> + </section> + + <section class="section prose-block" aria-labelledby="can-h"> + <h2 id="can-h">O que você pode fazer aqui</h2> + <p>Na runv, você pode:</p> + <ul class="checklist"> + <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>estudar programação e automação;</li> + <li>trocar ideias com outras pessoas;</li> + <li>acompanhar projetos e experiências da comunidade;</li> + <li>construir seu espaço online de forma simples e autoral.</li> + </ul> + </section> + + <section class="section prose-block" aria-labelledby="idea-h"> + <h2 id="idea-h">Mantida pelo Portal IDEA</h2> + <p> + A runv.club é totalmente mantida pelo <strong>Portal IDEA</strong>, instituição dedicada à ampliação do acesso ao conhecimento e à formação de pessoas por meio da educação online. + </p> + <p> + Essa parceria dá base e propósito ao projeto: criar uma comunidade que una tecnologia, aprendizado, autonomia digital e troca real entre pessoas. + </p> + </section> + + <section class="section prose-block" aria-labelledby="origin-h"> + <h2 id="origin-h">Sobre a origem</h2> + <p> + A comunidade foi desenvolvida por <strong>Pablo Murad</strong> (<a href="/~pmurad/">~pmurad</a>) como uma forma de resgatar a memória dos antigos sistemas Unix públicos e trazer essa experiência para o contexto brasileiro de hoje. + </p> + <p> + A ideia da runv é simples e poderosa: oferecer um espaço onde a tecnologia volte a ser também encontro, descoberta, estudo e expressão pessoal. + </p> + </section> + + <section class="section section-invite" aria-labelledby="invite-h"> + <h2 id="invite-h" class="visually-hidden">Convite</h2> + <p> + 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. + </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-join"><a href="/junte-se/">Como pedir entrada — chave SSH e <span class="ssh-identity">entre@runv.club</span></a></p> + </div> + + <footer class="site-footer"> + <p>Administração: <a href="mailto:admin@runv.club">admin@runv.club</a></p> + </footer> + </div> + + <script src="assets/app.js" defer></script> +</body> +</html> diff --git a/site/public/junte-se/index.html b/site/public/junte-se/index.html @@ -0,0 +1,108 @@ +<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <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 e Windows e pedir entrada na runv.club com 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"> + <link rel="stylesheet" href="../assets/style.css"> +</head> +<body> + <div class="wrap"> + <nav class="top-nav"><a href="/">← runv.club</a></nav> + + <header> + <p class="eyebrow">runv.club</p> + <nav class="hero-nav" aria-label="Outras páginas"> + <a href="/news/">Notícias</a> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <a href="/wiki/">Wiki</a> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <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">Gere uma chave SSH no seu computador e conecte-se a <span class="ssh-identity">entre@runv.club</span> para fazer seu pedido de entrada na comunidade.</p> + </header> + + <div class="entre-callout" role="status"> + <span class="entre-callout-label">SSH — pedido de entrada</span> + <span class="ssh-identity ssh-identity-lg">entre@runv.club</span> + </div> + + <main class="section prose-block subpage-main"> + <h2>O que vai acontecer</h2> + <p> + A conta Unix <strong>entre</strong> não oferece um shell normal: executa um programa que pede o + <strong>nome de usuário</strong> desejado, <strong>e-mail</strong> e sua <strong>chave pública SSH</strong> + (uma linha). O pedido fica numa <strong>fila</strong> no servidor; a equipe revisa e, se for aceito, + cria a conta com as ferramentas internas do runv. <strong>Não é cadastro automático.</strong> + </p> + <p> + É preciso gerar um par de chaves no seu PC (a privada fica com você; você só envia a <em>pública</em> no fluxo). + Abaixo: passos para <strong>Linux</strong>, <strong>macOS</strong> e <strong>Windows</strong>. + </p> + + <h2>Linux (Terminal)</h2> + <ol class="checklist"> + <li>Abra um terminal.</li> + <li>Gere uma chave Ed25519 (recomendado). Substitua o comentário por algo seu (ex.: e-mail ou nome do PC):</li> + </ol> + <pre class="code-block" tabindex="0"><code>ssh-keygen -t ed25519 -C "seu-email-ou-identificador" -f ~/.ssh/id_ed25519_runv</code></pre> + <p>Quando pedir passphrase, você pode definir uma (mais seguro) ou Enter vazio (mais simples — menos seguro se alguém copiar o arquivo).</p> + <p>Mostre a <strong>chave pública</strong> para copiar no passo final (é uma linha que começa com <code>ssh-ed25519</code>):</p> + <pre class="code-block" tabindex="0"><code>cat ~/.ssh/id_ed25519_runv.pub</code></pre> + <p>Para se conectar depois com essa chave (quando você já tiver conta própria), o cliente SSH usa por padrão <code>~/.ssh/id_ed25519</code> ou o que você definir em <code>~/.ssh/config</code>. Para o primeiro contato com <strong>entre</strong>, basta o comando da seção “Ligar a <span class="ssh-identity">entre@runv.club</span>”.</p> + + <h2>macOS (Terminal)</h2> + <ol class="checklist"> + <li>Abra o <strong>Terminal</strong> (Spotlight: “Terminal”).</li> + <li>Os passos são os mesmos que no Linux:</li> + </ol> + <pre class="code-block" tabindex="0"><code>ssh-keygen -t ed25519 -C "seu-email-ou-identificador" -f ~/.ssh/id_ed25519_runv</code></pre> + <pre class="code-block" tabindex="0"><code>cat ~/.ssh/id_ed25519_runv.pub</code></pre> + <p>No macOS recente, o OpenSSH já vem instalado. Se você usar o agente de chaves, pode rodar <code>ssh-add ~/.ssh/id_ed25519_runv</code> após gerar.</p> + + <h2>Windows (PowerShell com OpenSSH)</h2> + <p> + No Windows 10 e 11, o cliente OpenSSH costuma estar disponível. Abra o <strong>PowerShell</strong> + ou o <strong>Terminal do Windows</strong> e verifique: + </p> + <pre class="code-block" tabindex="0"><code>ssh -V</code></pre> + <p>Se o comando não for encontrado, em <strong>Configurações</strong> → <strong>Aplicativos</strong> → <strong>Recursos opcionais</strong> instale o <strong>Cliente OpenSSH</strong> (ou use o guia oficial da Microsoft).</p> + <p>Gere a chave (caminho típico da pasta <code>.ssh</code> no seu perfil):</p> + <pre class="code-block" tabindex="0"><code>ssh-keygen -t ed25519 -C "seu-email-ou-identificador" -f $env:USERPROFILE\.ssh\id_ed25519_runv</code></pre> + <p>Mostre a chave pública:</p> + <pre class="code-block" tabindex="0"><code>Get-Content $env:USERPROFILE\.ssh\id_ed25519_runv.pub</code></pre> + <p> + <strong>Obs.:</strong> Nunca compartilhe o arquivo <em>sem</em> extensão <code>.pub</code> — esse é o privado. + Só a linha do <code>.pub</code> entra no pedido ao <strong>entre</strong>. + </p> + + <h2>Ligar a <span class="ssh-identity">entre@runv.club</span></h2> + <p>Com a chave já criada, no mesmo terminal (Linux/macOS) ou PowerShell (Windows):</p> + <pre class="code-block" tabindex="0"><code>ssh -i ~/.ssh/id_ed25519_runv entre@runv.club</code></pre> + <p>No Windows PowerShell, equivalente:</p> + <pre class="code-block" tabindex="0"><code>ssh -i $env:USERPROFILE\.ssh\id_ed25519_runv entre@runv.club</code></pre> + <p> + Se sua chave tiver outro nome ou estiver no caminho padrão (<code>id_ed25519</code> / <code>id_rsa</code>), + você pode omitir <code>-i</code> ou ajustar o caminho. Na <strong>primeira conexão</strong>, o SSH pergunta se você confia + na fingerprint do servidor — confirme se estiver falando com o runv certo. + </p> + <p>Siga as instruções na tela até o fim. Se algo falhar, entre em contato com <a href="mailto:admin@runv.club">admin@runv.club</a>.</p> + + <h2>Tipos de chave aceitas</h2> + <p> + O fluxo do servidor segue a política de provisionamento: em geral <strong>Ed25519</strong> (recomendado), + chaves <strong>ECDSA</strong> NIST e, em alguns casos, <strong>RSA</strong>. Preferência forte por Ed25519. + </p> + </main> + + <footer class="site-footer"> + <p>Administração: <a href="mailto:admin@runv.club">admin@runv.club</a></p> + </footer> + </div> +</body> +</html> diff --git a/site/public/news/index.html b/site/public/news/index.html @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Notícias — runv.club</title> + <meta name="description" content="Notícias e atualizações da comunidade 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"> + <link rel="stylesheet" href="../assets/style.css"> +</head> +<body> + <div class="wrap"> + <nav class="top-nav"><a href="/">← runv.club</a></nav> + + <header> + <p class="eyebrow">runv.club</p> + <nav class="hero-nav" aria-label="Outras páginas"> + <span class="hero-nav-current" aria-current="page">Notícias</span> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <a href="/wiki/">Wiki</a> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <a href="/junte-se/">Junte-se</a> + </nav> + <h1 class="hero-title subpage-title">Notícias</h1> + <p class="subpage-intro">Comunicados oficiais, mudanças no servidor e novidades da comunidade.</p> + </header> + + <main class="section prose-block subpage-main"> + <p class="placeholder-block"> + Ainda não há entradas publicadas. Esta página será preenchida quando houver notícias — por exemplo via build estático, feed ou outro fluxo que definirem mais tarde. + </p> + </main> + + <footer class="site-footer"> + <p>Administração: <a href="mailto:admin@runv.club">admin@runv.club</a></p> + </footer> + </div> +</body> +</html> diff --git a/site/public/wiki/index.html b/site/public/wiki/index.html @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Wiki — runv.club</title> + <meta name="description" content="Documentação e guias da comunidade 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"> + <link rel="stylesheet" href="../assets/style.css"> +</head> +<body> + <div class="wrap"> + <nav class="top-nav"><a href="/">← runv.club</a></nav> + + <header> + <p class="eyebrow">runv.club</p> + <nav class="hero-nav" aria-label="Outras páginas"> + <a href="/news/">Notícias</a> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <span class="hero-nav-current" aria-current="page">Wiki</span> + <span class="hero-nav-sep" aria-hidden="true">·</span> + <a href="/junte-se/">Junte-se</a> + </nav> + <h1 class="hero-title subpage-title">Wiki</h1> + <p class="subpage-intro">Guias, como fazer, glossário e referência para quem usa o servidor.</p> + </header> + + <main class="section prose-block subpage-main"> + <p class="placeholder-block"> + Conteúdo em construção. Aqui poderão entrar páginas em Markdown/HTML, links para documentação do repositório ou um motor de wiki — o que combinarem mais tarde. + </p> + </main> + + <footer class="site-footer"> + <p>Administração: <a href="mailto:admin@runv.club">admin@runv.club</a></p> + </footer> + </div> +</body> +</html> diff --git a/terminal/README.md b/terminal/README.md @@ -0,0 +1,65 @@ +# terminal — pedido de entrada SSH (`entre@runv.club`) + +Módulo **runv.club** para quem se liga por SSH ao utilizador Unix **`entre`**: em vez de shell normal, corre uma experiência **textual guiada** que recolhe username desejado, email e chave pública SSH, grava um JSON na **fila local** e (opcionalmente) notifica o administrador por **sendmail**. + +**Não cria contas Linux.** O provisionamento continua a ser manual (ou via [`scripts/admin/create_runv_user.py`](../scripts/admin/create_runv_user.md)). + +## Ficheiros principais + +| Ficheiro | Função | +|----------|--------| +| `entre_app.py` | Programa principal (ForceCommand SSH). | +| `entre_core.py` | Validação, fila JSON, log, email. | +| `setup_entre.py` | Instalação no servidor (root): utilizador `entre`, shell `/bin/sh`, `--auth-mode` (`shared-password` \| `key-only` \| `empty-password` estilo tilde.town), PAM opcional, drop-in SSH, `sshd -t` + `sshd -T -C`, reload. | +| `del_entre.py` | Remoção (root): retira `runv-entre.conf`, recarrega `sshd`, `userdel` do utilizador `entre`; opções `--purge-install`, `--purge-queue`, `--keep-home`. | +| `config.example.toml` | Modelo de configuração → copiar para `config.toml`. | +| `templates/*.txt` | Textos da experiência e do email ao admin. | +| `docs/USO.md` | **Instalação + uso** (admin, visitante, testes, checklist). | +| `docs/INSTALL.md` | Guia de instalação detalhado (Debian 13). | +| `docs/ADMIN.md` | Operação e aprovação de pedidos. | +| `docs/ARCHITECTURE.md` | Desenho e segurança. | + +## Instalação e uso (resumo) + +Guia unificado: **[`docs/USO.md`](docs/USO.md)**. + +Em linhas: + +1. Como root: `python3 setup_entre.py` (ou `scripts/install.sh`) — por omissão `--auth-mode shared-password`, shell `/bin/sh`, validação `sshd -T -C …`. +2. **Onboarding sem senha (estilo tilde.town):** `sudo python3 setup_entre.py --auth-mode empty-password` — **PAM** por omissão; SSH por omissão **keyboard-interactive** (melhor no **OpenSSH do Windows**). Teste: `ssh entre@runv.club`. Se a sessão fechar, veja PAM e logs em [INSTALL.md](docs/INSTALL.md). Para o modo README tilde (**password** + senha vazia), use **`--empty-password-tilde-password-auth`** (Linux/Git Bash). +3. Editar `/opt/runv/terminal/config.toml` (`admin_email`, etc.). +4. Modo default: `sudo passwd entre`. Modo `key-only`: `authorized_keys`. +5. Visitante: `ssh entre@runv.club` e seguir o fluxo até à despedida. + +Opcional: `--skip-sshd` para aplicar o bloco `Match User entre` à mão (`INSTALL.md`). + +## Teste local (sem SSH) + +```bash +chmod +x scripts/test_local.sh +./scripts/test_local.sh +``` + +Usa `terminal/data/queue` e `config.example.toml`. Exige **`ssh-keygen`** no PATH (validação da chave). + +## Variáveis de ambiente (opcional) + +| Variável | Efeito | +|----------|--------| +| `RUNV_ENTRE_ROOT` | Raiz do módulo (default: pasta do `entre_core.py`). | +| `RUNV_ENTRE_CONFIG` | Caminho absoluto do `config.toml`. | +| `RUNV_ENTRE_QUEUE_DIR` | Sobrepõe `queue_dir` do TOML. | +| `RUNV_ENTRE_LOG_FILE` | Sobrepõe `log_file` do TOML. | +| `RUNV_ENTRE_TEMPLATES_DIR` | Sobrepõe `templates_dir`. | + +## Checklist manual de teste + +- [ ] `python3 -m py_compile entre_app.py entre_core.py setup_entre.py del_entre.py` +- [ ] `./scripts/test_local.sh` — percorrer fluxo até gravar JSON em `data/queue/` +- [ ] Confirmar que **não** sobrescreve se repetir o mesmo `request_id` (colisão improvável; o código regera UUID) +- [ ] Com `admin_email` preenchido e `mailutils`/`sendmail`: pedido gera tentativa de email (ver log) +- [ ] No servidor: após `setup_entre.py`, `sshd -t` OK e ficheiro `runv-entre.conf` (ou equivalente manual com `--skip-sshd`) +- [ ] `ssh entre@servidor` — fluxo completo e ficheiro em `/var/lib/runv/entre-queue/` +- [ ] Após aprovação: correr `create_runv_user.py` com dados do JSON (ver `docs/ADMIN.md`) + +Versão da app: ver `python3 entre_app.py --version`. diff --git a/terminal/config.example.toml b/terminal/config.example.toml @@ -0,0 +1,12 @@ +# Copie para config.toml ao instalar (ex.: /opt/runv/terminal/config.toml). +# Valores por omissão da fila e log alinham com setup_entre.py. + +queue_dir = "/var/lib/runv/entre-queue" +log_file = "/var/log/runv/entre.log" +templates_dir = "/opt/runv/terminal/templates" + +# Email do administrador (opcional). Se vazio, só fila + log local. +admin_email = "" +# Remetente do aviso ao admin (From). Por omissão runv.club; se vazio no TOML, o código usa o mesmo. +mail_from = "entre@runv.club" +sendmail_path = "/usr/sbin/sendmail" diff --git a/terminal/data/.gitkeep b/terminal/data/.gitkeep diff --git a/terminal/docs/ADMIN.md b/terminal/docs/ADMIN.md @@ -0,0 +1,98 @@ +# Operação — fila de pedidos `entre` (runv.club) + +Fluxo geral de instalação e utilização: **[USO.md](USO.md)**. + +## Onde ficam os pedidos + +- Directório: **`/var/lib/runv/entre-queue/`** +- Um ficheiro **`{request_id}.json`** por pedido (UUID v4). +- Permissões: directório `0700`, dono **`entre`**; ficheiros `0640` na criação. + +## Conteúdo típico do JSON + +| Campo | Descrição | +|-------|-----------| +| `request_id` | Identificador único. | +| `username` | Nome Unix desejado pelo candidato. | +| `email` | Contacto. | +| `public_key` | Linha OpenSSH normalizada. | +| `public_key_fingerprint` | SHA256 (formato OpenSSH). | +| `submitted_at` | ISO 8601 UTC. | +| `remote_addr` | Endereço remoto, se `SSH_CONNECTION`/`SSH_CLIENT` existir. | +| `tty` | `SSH_TTY`, se existir. | +| `source` | `entre-ssh`. | +| `status` | Inicialmente `pending`. | +| `app_version` | Versão do `entre_app`. | + +## Ler e filtrar + +```bash +sudo ls -1 /var/lib/runv/entre-queue/ +sudo jq -r '"\(.submitted_at) \(.username) \(.email) \(.status)"' /var/lib/runv/entre-queue/*.json +``` + +## Revisão manual + +1. Abrir o JSON e confirmar que username, email e chave são plausíveis. +2. Procurar duplicados (mesmo email ou mesma fingerprint com pedidos `pending`). +3. Decidir: aprovar, rejeitar ou pedir mais informação por email **fora** deste sistema. + +## Aprovar e criar a conta real + +Use o provisionador interno **[`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py)** (no servidor, como root): + +```bash +sudo python3 /caminho/create_runv_user.py \ + --username "NOME_DO_JSON" \ + --email "EMAIL_DO_JSON" \ + --public-key 'LINHA_EXACTA_DO_JSON' +``` + +Ou modo interactivo sem flags e colar os dados. O script valida de novo (regex, chave, utilizador ainda inexistente, etc.). + +**Importante:** os dados do JSON são **proposta**; a última palavra é sempre o operador e o `create_runv_user.py`. + +## Marcar pedidos no JSON + +Não há base de dados: o operador pode: + +- Acrescentar campos manualmente, por exemplo: + - `"reviewed_at": "2026-03-20T12:00:00+00:00"` + - `"status": "approved"` | `"rejected"` | `"archived"` + - `"reviewer": "admin"` +- Ou mover ficheiros para subpastas (`approved/`, `rejected/`) se criar essa convenção localmente. + +Sugestão mínima: manter o ficheiro no sítio e só alterar `status` para auditoria simples. + +## Notificação ao administrador + +1. **Obrigatória:** novo ficheiro na fila. +2. **Log:** `/var/log/runv/entre.log` (ou o caminho em `config.toml`). +3. **Email:** se `admin_email` estiver definido e `sendmail` funcionar, o `entre_app.py` envia um resumo. + +### Reenviar notificação + +Não há botão. Opções: + +- Copiar o JSON e enviar email manualmente. +- Script local que relê o JSON e chama `sendmail` com o mesmo formato que `templates/admin_mail.txt`. + +### Depuração de email + +- Ver log: `grep -i sendmail /var/log/runv/entre.log` ou mensagens `notificação por email`. +- Testar MTA: `echo test | mail -s test root` (conforme configuração do sistema). +- Confirmar caminho: `ls -l /usr/sbin/sendmail`. + +## Pedidos inválidos ou spam + +- Marcar `status` como `rejected` ou arquivar. +- Não apagar de imediato se quiseres trilho de auditoria; podes mover para `archive/` depois de um tempo. +- **Rate limiting** avançado está fora de âmbito deste módulo; pode ser feito à frente (fail2ban, firewall, etc.). + +## Logs e privacidade + +Os JSONs contêm dados pessoais e chave pública. Restringe acesso ao directório da fila e rotações de log conforme a política da runv. + +## Ligação com o site / documentação pública + +Se existir página “Junte-se a nós” no site estático, deve apontar para **`ssh entre@runv.club`** e explicar geração de chaves — mantém coerência com este fluxo. diff --git a/terminal/docs/ARCHITECTURE.md b/terminal/docs/ARCHITECTURE.md @@ -0,0 +1,65 @@ +# Arquitectura — módulo `terminal` (entre SSH) + +Instalação e uso operacional: **[USO.md](USO.md)**. + +## Fluxo ponta a ponta + +```mermaid +sequenceDiagram + participant C as Cliente_SSH + participant S as sshd + participant A as entre_app.py + participant Q as entre_queue + participant L as entre.log + participant M as sendmail + + C->>S: autentica como entre + S->>A: ForceCommand + A->>C: splash, intro/avisos em vários ecrãs + A->>C: formulário (1 campo por ecrã) + C->>A: username, email, pubkey (linha a linha) + A->>A: validação (entre_core) + A->>Q: JSON O_EXCL + A->>L: eventos + opt admin_email configurado + A->>M: email resumo + end + A->>C: despedida +``` + +## Componentes + +| Peça | Papel | +|------|--------| +| `entre_app.py` | Orquestra etapas, I/O terminal, confirmação. | +| `entre_core.py` | Config TOML, validação (alinhada ao `create_runv_user.py`), escrita atómica do JSON, logging, `sendmail`. | +| `setup_entre.py` | Bootstrap: utilizador `entre`, árvore em `/opt/runv/terminal`, permissões, snippet SSH impresso. | +| `templates/*.txt` | Conteúdo editável sem alterar código. | +| `systemd/*.path` + `*.service` | Gatilho opcional em alterações da fila. | + +## Decisões de segurança + +- **Sem `shell=True`:** `subprocess.run([...], ...)` apenas com listas literais. +- **Sem criação de utilizadores** no `entre_app.py` / `entre_core.py`. +- **Sem alteração de Apache ou sshd** pelo código de aplicação. +- **Fila:** criação com `O_CREAT|O_EXCL` para não sobrescrever ficheiros existentes. +- **Entrada:** limites de tamanho; chave numa linha; rejeição de marcadores de chave privada. +- **SSH:** `DisableForwarding` e afins recomendados no `Match User entre` para limitar túneis e agent forwarding. +- **Utilizador `entre`:** shell `nologin` reduz superfície se alguma configuração falhar (ainda assim, o essencial é o `ForceCommand` correcto). + +## Por que a conta não é criada na hora + +- **Revisão humana:** pubnix/tilde costuma evitar contas automáticas abertas a abuso. +- **Coerência com o projeto:** o provisionamento oficial e quotas/metadata estão centralizados em `create_runv_user.py`. +- **Auditoria:** JSONs imutáveis na entrada (novo `request_id` por tentativa gravada) facilitam rastrear o que foi pedido. + +## Pontos de extensão futura + +- Campo opcional “mensagem ao admin” no JSON. +- Script que promove `pending` → `approved` e chama `create_runv_user.py`. +- Notificação via webhook ou Matrix no `runv-entre-notify.service`. +- Base de dados: substituir fila por tabela **mudaria** este módulo; hoje é deliberadamente ficheiro-only. + +## Alinhamento com `create_runv_user.py` + +Regex de username/email, tipos de chave e normalização da linha pública seguem a mesma filosofia que [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py). O código **não** importa esse ficheiro em runtime (evita dependência de path do repositório em `/opt/runv/terminal`); comentários no código referem a necessidade de manter políticas sincronizadas. diff --git a/terminal/docs/INSTALL.md b/terminal/docs/INSTALL.md @@ -0,0 +1,230 @@ +# Instalação — fluxo SSH `entre` (runv.club) + +Guia para **Debian 13** (ou derivado próximo). Por defeito, `setup_entre.py` instala **`/etc/ssh/sshd_config.d/runv-entre.conf`**, corre **`sshd -t`** e **`systemctl reload ssh`** (com backup do drop-in anterior se existir). Use **`--skip-sshd`** se preferir aplicar o bloco à mão. + +Para um único documento com **instalação + uso** (visitante e admin), ver também **[USO.md](USO.md)**. + +## 1. Dependências + +```bash +sudo apt update +sudo apt install -y python3 openssh-server openssh-client mailutils +``` + +- **python3** — interpretador (stdlib: `tomllib`, `email`, etc.). +- **openssh-client** — binário `ssh-keygen` usado para validar fingerprint da chave pública. +- **openssh-server** — serviço SSH. +- **mailutils** (ou outro MTA com **sendmail** em `/usr/sbin/sendmail`) — **opcional**, só para email ao admin. + +**Recomendado para o servidor runv.club:** configurar envio **sem Postfix/Exim** com o módulo do repositório **[`email/`](../email/README.md)** (`msmtp` + `msmtp-mta` + `bsd-mailx`). Depois disso, `/usr/sbin/sendmail` encaminha para o seu SMTP externo e o `entre` continua a usar `sendmail_path = "/usr/sbin/sendmail"` no `config.toml`. + +## 2. Obter o código + +A partir do repositório `runv-server`, a pasta relevante é `terminal/`. + +## 3. Executar o setup (root) + +```bash +cd /caminho/do/repositório/terminal +sudo python3 setup_entre.py +``` + +Ou: + +```bash +sudo sh scripts/install.sh +``` + +O script: + +- cria o utilizador **`entre`** (se não existir), com home por omissão `/home/entre` e shell **`/bin/sh`** (o OpenSSH precisa de shell funcional para o contexto do **ForceCommand**; `nologin` impede o fluxo); +- alinha o shell com **`chsh`** se `entre` já existir com outro shell; +- garante **`~entre/.ssh`** e **`authorized_keys`** (vazio; útil sobretudo em `--auth-mode key-only`); +- cria **`/var/lib/runv/entre-queue`** (dono `entre`, modo `0700`); +- garante **`/var/log/runv/`** e o ficheiro **`entre.log`** (dono `entre`, leitura/escrita para append); +- copia o módulo para **`/opt/runv/terminal`** e, se não existir `config.toml`, gera-o a partir de `config.example.toml`; +- **OpenSSH (por defeito):** escreve o drop-in conforme **`--auth-mode`** (omissão: **`shared-password`**); **`sshd -t`**, validação **`sshd -T -C …`**, **`systemctl reload ssh`** (em falha, reverte o drop-in). + +Opções úteis: + +- `--auth-mode shared-password` | `key-only` | `empty-password` — método para `entre`. +- **`empty-password` (onboarding estilo [tilde.town](https://tilde.town) / `join@tilde.town`):** cria grupo **`entre-open`**, mete `entre` no grupo, **`passwd -d`**, valida **NP**. **Por omissão** o drop-in usa **`AuthenticationMethods keyboard-interactive`** + **`KbdInteractiveAuthentication yes`** (PAM **`pam_succeed_if`** sem prompts) — melhor com **OpenSSH do Windows**, que em geral não envia palavra-passe vazia no método **`password`**. **`--empty-password-tilde-password-auth`** volta ao esquema README tilde (**`password`** + **`PermitEmptyPasswords yes`**). **Por omissão** altera **`/etc/pam.d/sshd`**: backup e linha **`pam_succeed_if … user ingroup …`** antes de **`@include common-auth`**. Sem isto, no Debian o **PAM** pode recusar o fluxo → **«Connection closed»**. **Não** é ausência total de autenticação: é política explícita só para `entre`. +- `--empty-password-group` — nome do grupo suplementar (default: `entre-open`). +- **`--empty-password-tilde-password-auth`** — só com **`empty-password`**: drop-in estilo README tilde (**`password`** + **`PermitEmptyPasswords yes`**); omissão = keyboard-interactive (recomendado para Windows). +- **`--skip-pam-empty-password-rule`** — não mexer no PAM (só para quem configura à mão; em geral **não** use em `empty-password` em Debian). +- `--sshd-test-connection` — argumento `-C` para `sshd -T` (deve bater com o `Match`, ex.: `user=entre,host=runv.club,addr=127.0.0.1`). +- `--dry-run` — apenas mensagens, sem alterações. +- `--force-config` — repõe `config.toml` a partir do example. +- `--skip-copy` — só directórios/utilizador (sem copiar ficheiros). +- `--skip-sshd` — não toca no SSH; imprime o bloco `Match User entre` para cópia manual. +- `--no-reload` — grava o drop-in e corre `sshd -t` + validação `-T`, mas não recarrega o serviço (útil para rever antes). + +## 4. Configuração (`config.toml`) + +Edite **`/opt/runv/terminal/config.toml`**: + +- **`admin_email`** — endereço para notificações (pode ficar vazio: só fila + log). +- **`mail_from`** — remetente do email (cabeçalho `From`); por omissão **`entre@runv.club`**. Se a chave existir mas estiver vazia, o programa usa o mesmo endereço. +- **`sendmail_path`** — normalmente `/usr/sbin/sendmail`. + +## 5. Autenticação SSH para o utilizador `entre` + +O OpenSSH **exige** sempre **alguma** credencial; **não** existe “`ssh` e entrou” sem palavra-passe nem chave no protocolo. + +**Modo recomendado (`--auth-mode shared-password`):** palavra-passe Unix **partilhada**, definida **só pelo root** (`sudo passwd entre`, etc.), com **`AuthenticationMethods password`**, **`PubkeyAuthentication no`** e **`KbdInteractiveAuthentication no`** no `Match User entre`, para acesso sem chave pré-registada. + +**`key-only`:** só chave pública em **`authorized_keys`**; sem palavra-passe. + +**`empty-password`:** `passwd -d entre`, grupo **`entre-open`**, regra PAM **`pam_succeed_if user ingroup entre-open`** (recomendado no Debian). **Por omissão** o SSH usa **`keyboard-interactive`** (PAM resolve sem prompts; compatível com Windows). Com **`--empty-password-tilde-password-auth`**, **`AuthenticationMethods password`** + **`PermitEmptyPasswords yes`** (como muitos README tilde). **Menos seguro** que palavra-passe ou chave; usar só para onboarding do utilizador especial `entre`, não para contas normais. + +O fluxo **`entre_app`** (historinha + formulário) **não** altera a senha Unix; só recolhe o pedido de conta. + +O visitante **não** obtém shell interactivo normal: o **`ForceCommand`** substitui o comando remoto (o shell em passwd é apenas o contexto mínimo exigido pelo OpenSSH). + +## 6. OpenSSH (`runv-entre.conf`) + +O setup coloca o ficheiro **`/etc/ssh/sshd_config.d/runv-entre.conf`** com o mesmo conteúdo lógico que abaixo (o caminho de `python3` vem de `which python3` no servidor). Confirme que **`/etc/ssh/sshd_config`** inclui algo como `Include /etc/ssh/sshd_config.d/*.conf` (comum no Debian). + +Exemplo equivalente: + +``` +Match User entre + AuthenticationMethods password + PasswordAuthentication yes + KbdInteractiveAuthentication no + PubkeyAuthentication no + PermitEmptyPasswords no + ForceCommand /usr/bin/python3 /opt/runv/terminal/entre_app.py + PermitTTY yes + PermitUserRC no + X11Forwarding no + AllowAgentForwarding no + AllowTcpForwarding no + PermitTunnel no + DisableForwarding yes +``` + +Exemplo **`--auth-mode empty-password`** (omissão; keyboard-interactive + PAM — recomendado para Windows): + +``` +Match User entre + AuthenticationMethods keyboard-interactive + PasswordAuthentication no + KbdInteractiveAuthentication yes + PubkeyAuthentication no + PermitEmptyPasswords no + ForceCommand /usr/bin/python3 /opt/runv/terminal/entre_app.py + PermitTTY yes + PermitUserRC no + X11Forwarding no + AllowAgentForwarding no + AllowTcpForwarding no + PermitTunnel no + DisableForwarding yes +``` + +Com **`--empty-password-tilde-password-auth`** (README tilde; `PermitEmptyPasswords yes`): + +``` +Match User entre + AuthenticationMethods password + PasswordAuthentication yes + KbdInteractiveAuthentication no + PubkeyAuthentication no + PermitEmptyPasswords yes + ForceCommand /usr/bin/python3 /opt/runv/terminal/entre_app.py + PermitTTY yes + PermitUserRC no + X11Forwarding no + AllowAgentForwarding no + AllowTcpForwarding no + PermitTunnel no + DisableForwarding yes +``` + +Em **`empty-password`**, o script faz backup de **`/etc/pam.d/sshd`** e insere antes de `@include common-auth` (ou primeira linha `auth`), salvo **`--skip-pam-empty-password-rule`**: + +``` +auth [success=done default=ignore] pam_succeed_if.so user ingroup entre-open +``` + +(Ajuste `entre-open` com **`--empty-password-group`** se mudar o nome do grupo.) + +Se usou **`--skip-sshd`**, crie o ficheiro à mão e depois: + +```bash +sudo sshd -t +sudo systemctl reload ssh +``` + +Confirme que o caminho de **`python3`** e de **`entre_app.py`** coincidem com o servidor (`which python3`). + +## 7. Teste local do programa (sem SSH) + +Na máquina de desenvolvimento (com `ssh-keygen` disponível): + +```bash +cd terminal +chmod +x scripts/test_local.sh +./scripts/test_local.sh +``` + +Os pedidos ficam em `terminal/data/queue/`. + +## 8. Teste via SSH + +A partir de um cliente: + +```bash +ssh entre@runv.club +``` + +(Substitua o host.) Percorra o fluxo até ao fim e verifique: + +```bash +sudo ls -la /var/lib/runv/entre-queue/ +sudo jq . /var/lib/runv/entre-queue/<request_id>.json +``` + +## 9. Teste de notificação por email + +1. Preencha **`admin_email`** no `config.toml`. +2. Garanta que **`sendmail`** aceita mail local ou relay (configuração do MTA fora do âmbito deste módulo). +3. Opcional: inspeccionar o formato com: + +```bash +sh scripts/test_mail.sh +``` + +4. Para um teste real, pode redireccionar para sendmail conforme a política do servidor. + +Se o email falhar, o pedido **mantém-se** na fila e o log regista o aviso. + +## 10. systemd.path (opcional) + +Para reagir a alterações na fila (log extra, hook próprio): + +```bash +sudo cp systemd/runv-entre-notify.path /etc/systemd/system/ +sudo cp systemd/runv-entre-notify.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now runv-entre-notify.path +``` + +Edite **`runv-entre-notify.service`** se quiser outro `ExecStart` (sem depender do Python do módulo para notificações simples). + +## 11. Segurança e reversão do drop-in + +A instalação automática faz **backup** do ficheiro anterior (`runv-entre.conf.bak.<timestamp>`), valida com **`sshd -t`** e só então recarrega o serviço. Se o teste falhar, o script **reverte** (ou remove o ficheiro numa primeira instalação). Para ambientes onde qualquer alteração ao SSH exige revisão prévia, use **`--no-reload`** ou **`--skip-sshd`**. + +## Problemas frequentes + +| Sintoma | Verificação | +|---------|-------------| +| `entre_app.py` não arranca | Permissões em `/opt/runv/terminal`, dono `entre`, `python3` no caminho. | +| Erro ao gravar fila | Dono e modo de `/var/lib/runv/entre-queue`. | +| Log vazio / permissão | Dono de `/var/log/runv/entre.log`. | +| Chave rejeitada | `ssh-keygen` instalado; chave numa linha; tipo permitido. | +| Sessão SSH fecha logo | Autenticação de `entre` falhou antes do ForceCommand. | + +Documentação de operação: **[ADMIN.md](ADMIN.md)**. Desenho: **[ARCHITECTURE.md](ARCHITECTURE.md)**. diff --git a/terminal/docs/USO.md b/terminal/docs/USO.md @@ -0,0 +1,117 @@ +# Instalação e uso — módulo `terminal` (entre) + +Este documento resume **como instalar**, **como usar** (visitante e administrador) e **onde olhar** quando algo falha. Detalhes técnicos extra: [INSTALL.md](INSTALL.md), [ADMIN.md](ADMIN.md), [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## 1. O que é + +- Utilizador Unix **`entre`** no servidor; quem corre `ssh entre@runv.club` **não** recebe shell normal. +- O OpenSSH executa **`entre_app.py`** (`ForceCommand`), que mostra textos em [templates/](../templates/) (introdução, avisos, formulário), valida dados e grava um **JSON** em `/var/lib/runv/entre-queue/`. +- **Não cria conta Linux** automaticamente; a aprovação é manual e o provisionamento usa [`create_runv_user.py`](../../scripts/admin/create_runv_user.py). + +--- + +## 2. Instalação no servidor (admin) + +1. **Dependências** (Debian 13): `python3`, `openssh-server`, `openssh-client` (`ssh-keygen`), opcional `mailutils` para email. +2. **Copiar e preparar o módulo** (como root), a partir da pasta `terminal/` do repositório: + + ```bash + cd /caminho/runv-server/terminal + sudo python3 setup_entre.py + ``` + + Ou: `sudo sh scripts/install.sh` + +3. **Configurar** `/opt/runv/terminal/config.toml` (a partir de `config.example.toml`): + - `admin_email` — para receber notificação por `sendmail` (pode ficar vazio). + - `queue_dir`, `log_file`, `templates_dir` — normalmente não precisa mudar. + +4. **OpenSSH:** por defeito o `setup_entre.py` instala **`/etc/ssh/sshd_config.d/runv-entre.conf`**, corre **`sshd -t`** e **`systemctl reload ssh`**. Com **`--skip-sshd`**, aplica o bloco à mão (ver [INSTALL.md](INSTALL.md) ou [examples/sshd_match_entre.conf.sample](../examples/sshd_match_entre.conf.sample)). + +5. **Autenticação:** omissão **`--auth-mode shared-password`**. **`empty-password`**: espírito **`join@tilde.town`** — grupo **`entre-open`**, **`passwd -d`**, **PAM** em `/etc/pam.d/sshd` por omissão; o drop-in SSH usa **`keyboard-interactive`** por omissão (Windows); **`--empty-password-tilde-password-auth`** = **`password`** + **`PermitEmptyPasswords`**. Não é “SSH sem credencial”. Shell **`/bin/sh`**. Ver [INSTALL.md](INSTALL.md). + +--- + +## 3. Uso pelo visitante (candidato) + +1. Ligar (o site indica a **palavra-passe partilhada** do utilizador `entre`, se existir): + + ```bash + ssh entre@runv.club + ``` + +2. **Opcional:** em **`key-only`**, ou se o admin tiver posto a tua chave em `authorized_keys` (não aplica ao modo `shared-password` por defeito). + +3. No início aparece o **logo RUNV em ASCII** (verde, se o terminal suportar cores) e a frase *Aperte qualquer tecla para continuar...*; em todo o fluxo, a cadeia **`runv.club`** é destacada a verde quando o terminal suporta cores (`style_runv_club` em `entre_app.py`); a **história** e o **aviso da chave** vão em **vários ecrãs** (Enter para seguir). A **coleta** é **um campo por ecrã** (utilizador, email, chave pública), com entrada de teclado normal linha a linha. +4. No **aviso da chave**: confirmar que vai colar só a **pública**, nunca a privada (gera um par com `ssh-keygen` no teu PC se ainda não tiveres). +5. **Informar três dados:** + - nome de utilizador **desejado** (regras: minúsculas, letras/dígitos/`_`/`-`, não pode ser nome reservado nem utilizador já existente no servidor); + - **email** de contacto; + - **chave pública** (uma linha inteira). +6. **Rever o resumo** (inclui fingerprint SHA256 da chave pública que colaste): + - confirmar envio, **editar** de novo ou **cancelar**. +7. Se confirmar: o pedido fica na fila; aparece a **despedida** com a referência `{request_id}`. +8. **Aguardar email** da administração; não repetir o mesmo pedido muitas vezes. + +O **splash ASCII** (igual ao da landing em `site/public/index.html`) e o texto *Aperte qualquer tecla...* estão em [`entre_app.py`](../entre_app.py) (`RUNV_ASCII_ART`, `show_opening_splash`). Em `intro.txt` e `warning_public_key.txt`, uma linha só com `%%PAGE%%` **parte o texto em vários ecrãs** (`show_paged_template`). Os restantes textos: `confirm.txt`, `goodbye.txt`. + +--- + +## 4. Uso pelo administrador (após pedidos) + +1. **Listar pedidos:** `/var/lib/runv/entre-queue/*.json` +2. **Ler e decidir** (duplicados, email inválido, etc.) — ver [ADMIN.md](ADMIN.md). +3. **Criar conta** com o provisionador, usando os campos do JSON aprovado. +4. **Opcional:** `systemd` `runv-entre-notify.path` para reagir a novos ficheiros na fila. + +--- + +## 5. Teste sem SSH (desenvolvimento) + +```bash +cd terminal +chmod +x scripts/test_local.sh +./scripts/test_local.sh +``` + +Grava em `terminal/data/queue/` e usa `config.example.toml`. Exige `ssh-keygen` no PATH. + +Variáveis úteis: `RUNV_ENTRE_CONFIG`, `RUNV_ENTRE_QUEUE_DIR`, `RUNV_ENTRE_LOG_FILE` (ver [README.md](../README.md)). + +--- + +## 6. Onde está o quê + +| Item | Caminho típico | +|------|----------------| +| Aplicação instalada | `/opt/runv/terminal/` | +| Configuração | `/opt/runv/terminal/config.toml` | +| Fila de pedidos | `/var/lib/runv/entre-queue/` | +| Log | `/var/log/runv/entre.log` | +| Textos da sessão | `/opt/runv/terminal/templates/` | +| Drop-in SSH `entre` | `/etc/ssh/sshd_config.d/runv-entre.conf` | + +--- + +## 7. Problemas comuns + +| Situação | O que verificar | +|----------|-----------------| +| SSH recusa antes de aparecer o texto | Palavra-passe de `entre` definida (`sudo passwd entre`); ou chave em `authorized_keys`; firewall; drop-in com `PasswordAuthentication yes` para `entre`. | +| Erro ao gravar pedido | Dono e permissões de `/var/lib/runv/entre-queue` (dono `entre`, `0700`). | +| Email não chega | `admin_email` preenchido; `sendmail` e MTA; mensagens no log. | +| Chave inválida | Uma linha só; tipo permitido; `ssh-keygen` instalado no servidor. | + +--- + +## 8. Checklist rápido pós-instalação + +- [ ] `sudo python3 setup_entre.py` concluído sem erros +- [ ] `config.toml` com `admin_email` se quiseres mail +- [ ] Drop-in `runv-entre.conf` presente (ou `--skip-sshd` aplicado à mão); `sshd -t` OK após o setup +- [ ] `ssh entre@host` mostra a narrativa e completa até JSON na fila +- [ ] Log com linha `pedido gravado` + +Para texto legal e segurança em profundidade, ver [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/terminal/entre_app.py b/terminal/entre_app.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +""" +Experiência SSH guiada para pedidos de entrada na runv.club (utilizador «entre»). + +Executado via ForceCommand no OpenSSH. Não cria contas Linux; apenas fila + log ++ notificação opcional. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +# Arte ASCII da landing (site/public/index.html) — manter alinhado ao <pre class="ascii">. +RUNV_ASCII_ART: str = """██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗ +██╔══██╗██║ ██║████╗ ██║██║ ██║ +██████╔╝██║ ██║██╔██╗ ██║██║ ██║ +██╔══██╗╚██╗ ██╔╝██║╚██╗██║╚██╗ ██╔╝ +██║ ██║ ╚████╔╝ ██║ ╚████║ ╚████╔╝ +╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═══╝ ╚═══╝""" + +ASCII_TAGLINE: str = ".club — um computador para compartilhar" + +# Em intro.txt: linha só com este marcador separa ecrãs da narrativa. +INTRO_PAGE_BREAK: str = "%%PAGE%%" + +from entre_core import ( + APP_VERSION, + DEFAULT_MAIL_FROM, + ValidationError, + build_request_payload, + find_config_path, + find_install_root, + load_config, + log_session, + new_request_id, + render_template, + resolve_paths, + save_request_json, + sendmail_notify, + setup_file_logger, + ssh_remote_context, + validate_email, + validate_public_key_line, + validate_username, +) + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def pause(stdin, stdout) -> None: + stdout.write("\n[Enter] continuar · [q] sair\n") + stdout.flush() + line = stdin.readline() + if not line: + raise SystemExit(0) + if line.strip().lower() in ("q", "quit", "sair"): + print("\nAté logo.\n") + raise SystemExit(0) + + +def read_line(prompt: str, stdin, stdout) -> str: + stdout.write(prompt) + stdout.flush() + line = stdin.readline() + if not line: + raise SystemExit(0) + return line.rstrip("\r\n") + + +def clear_screen(stdout) -> None: + stdout.write("\033[2J\033[H") + stdout.flush() + + +def _use_ansi_color(stdout) -> bool: + if not getattr(stdout, "isatty", lambda: False)(): + return False + term = (os.environ.get("TERM") or "").strip().lower() + if term in ("", "dumb"): + return False + if os.environ.get("NO_COLOR", "").strip(): + return False + return True + + +RUNV_CLUB_MARK: str = "runv.club" + + +def style_runv_club(text: str, stdout) -> str: + """Destaca runv.club a verde no terminal (todas as ocorrências).""" + if not _use_ansi_color(stdout) or RUNV_CLUB_MARK not in text: + return text + g, r = "\033[92m", "\033[0m" + return text.replace(RUNV_CLUB_MARK, f"{g}{RUNV_CLUB_MARK}{r}") + + +def wait_any_key(stdin, stdout) -> None: + """Lê uma tecla em modo cru (POSIX); senão, uma linha (Enter).""" + if sys.platform != "win32" and stdin.isatty(): + try: + import termios + import tty + + fd = stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + if ch == "\x03": + raise KeyboardInterrupt + if ch == "\x04" or ch == "": + raise SystemExit(0) + return + except (ImportError, OSError, termios.error): + pass + stdout.write(" (tecla Enter para continuar)\n") + stdout.flush() + line = stdin.readline() + if not line: + raise SystemExit(0) + + +def show_opening_splash(stdin, stdout) -> None: + clear_screen(stdout) + green = "\033[92m" if _use_ansi_color(stdout) else "" + reset = "\033[0m" if green else "" + stdout.write("\n") + for line in RUNV_ASCII_ART.splitlines(): + stdout.write(f" {green}{line}{reset}\n") + stdout.write(f"\n {green}{ASCII_TAGLINE}{reset}\n\n") + stdout.write(f" {green}Aperte qualquer tecla para continuar...{reset}\n") + stdout.flush() + wait_any_key(stdin, stdout) + + +def show_paged_template(stdin, stdout, template_path: Path) -> None: + raw = template_path.read_text(encoding="utf-8") + pages = [p.strip("\n") for p in raw.split(INTRO_PAGE_BREAK)] + pages = [p for p in pages if p.strip()] + total = len(pages) + for i, page in enumerate(pages, start=1): + clear_screen(stdout) + if total > 1: + stdout.write(f" ({i}/{total})\n\n") + page = style_runv_club(page, stdout) + stdout.write(page) + if not page.endswith("\n"): + stdout.write("\n") + stdout.flush() + pause(stdin, stdout) + + +def collect_loop(stdin, stdout, templates: Path) -> tuple[str, str, str, str]: + username = email = pubkey = "" + fp = "" + while True: + clear_screen(stdout) + stdout.write(style_runv_club("— runv.club · dados — (1/3)\n\n", stdout)) + stdout.write("Nome de utilizador desejado (minúsculas, letras, dígitos, _ ou -).\n") + stdout.write("Deixe em branco só se ainda não tiver escolhido.\n\n") + u = read_line(f" Utilizador [{username or '(vazio)'}]: ", stdin, stdout).strip() + if u: + username = u + + clear_screen(stdout) + stdout.write(style_runv_club("— runv.club · dados — (2/3)\n\n", stdout)) + stdout.write("Email para a administração entrar em contacto consigo.\n\n") + e = read_line(f" Email [{email or '(vazio)'}]: ", stdin, stdout).strip() + if e: + email = e + + clear_screen(stdout) + stdout.write(style_runv_club("— runv.club · dados — (3/3)\n\n", stdout)) + stdout.write("Cole a sua chave pública SSH (uma linha) e prima Enter.\n") + stdout.write("Só a pública — nunca a chave privada.\n\n") + stdout.write(" Chave pública:\n ") + stdout.flush() + pk = stdin.readline() + if not pk: + raise SystemExit(0) + pk = pk.rstrip("\r\n") + if pk.strip(): + pubkey = pk.strip() + + errors: list[str] = [] + try: + vu = validate_username(username) + except ValidationError as ex: + errors.append(str(ex)) + vu = "" + try: + ve = validate_email(email) + except ValidationError as ex: + errors.append(str(ex)) + ve = "" + try: + if not pubkey: + raise ValidationError("a chave pública é obrigatória.") + nkey, fp = validate_public_key_line(pubkey) + except ValidationError as ex: + errors.append(str(ex)) + nkey, fp = "", "" + + if errors: + clear_screen(stdout) + stdout.write("— Corrija os dados —\n\n") + for err in errors: + stdout.write(f" • {err}\n") + stdout.write("\n[Enter] para voltar ao início do formulário\n") + stdout.flush() + stdin.readline() + continue + return vu, ve, nkey, fp + + +def confirm_loop( + stdin, + stdout, + *, + username: str, + email: str, + fingerprint: str, + templates: Path, +) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + body = render_template( + templates / "confirm.txt", + { + "username": username, + "email": email, + "fingerprint": fingerprint, + "submitted_preview": now, + }, + ) + while True: + clear_screen(stdout) + stdout.write(style_runv_club(body, stdout)) + stdout.write("\n [c] confirmar envio\n") + stdout.write(" [e] editar dados\n") + stdout.write(" [x] cancelar e sair\n\n") + stdout.write("Opção: ") + stdout.flush() + line = stdin.readline() + if not line: + raise SystemExit(0) + c = line.strip().lower() + if c in ("c", "confirmar", "s", "sim", "y", "yes"): + return "confirm" + if c in ("e", "editar"): + return "edit" + if c in ("x", "cancelar", "n", "nao", "não"): + return "cancel" + stdout.write("Opção inválida.\n") + stdout.write("[Enter]") + stdin.readline() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Fluxo SSH entre@runv.club (runv.club)") + parser.add_argument("--version", action="version", version=f"%(prog)s {APP_VERSION}") + args = parser.parse_args() + del args + + stdin, stdout = sys.stdin, sys.stdout + + install_root = find_install_root() + config_path = find_config_path(install_root) + try: + cfg = load_config(config_path) + except (OSError, ValueError) as e: + eprint(f"Erro de configuração: {e}") + return 2 + + paths = resolve_paths(cfg, install_root) + logger = setup_file_logger(paths.log_file) + + ctx = ssh_remote_context() + log_session( + logger, + f"sessão iniciada remote_addr={ctx.get('remote_addr')!r} tty={ctx.get('tty')!r}", + ) + + templates = paths.templates_dir + if not templates.is_dir(): + eprint(f"Templates em falta: {templates}") + log_session(logger, f"ERRO templates em falta: {templates}", level=40) + return 2 + + try: + # --- Abertura: arte ASCII da landing (verde) + qualquer tecla + show_opening_splash(stdin, stdout) + + # --- Etapa 1: narrativa (%%PAGE%% em intro.txt) + show_paged_template(stdin, stdout, templates / "intro.txt") + + # --- Etapa 2: aviso chave (pode ter %%PAGE%% como intro.txt) + show_paged_template(stdin, stdout, templates / "warning_public_key.txt") + + # --- Etapa 3–4: coleta e confirmação (com edição repetível) + username, email, pubkey, fingerprint = collect_loop(stdin, stdout, templates) + while True: + action = confirm_loop( + stdin, + stdout, + username=username, + email=email, + fingerprint=fingerprint, + templates=templates, + ) + if action == "cancel": + log_session(logger, "utilizador cancelou antes de gravar") + stdout.write("\nPedido cancelado. Até logo.\n\n") + return 0 + if action == "edit": + username, email, pubkey, fingerprint = collect_loop(stdin, stdout, templates) + continue + break + + request_id = "" + path_saved = None + for attempt in range(8): + request_id = new_request_id() + payload = build_request_payload( + request_id=request_id, + username=username, + email=email, + public_key=pubkey, + fingerprint=fingerprint, + remote_addr=ctx.get("remote_addr"), + tty=ctx.get("tty"), + ) + try: + path_saved = save_request_json( + queue_dir=paths.queue_dir, + request_id=request_id, + payload=payload, + logger=logger, + ) + break + except FileExistsError: + log_session(logger, f"colisão request_id, a gerar outro (tentativa {attempt + 1})") + continue + except OSError as e: + log_session(logger, f"ERRO ao gravar pedido: {e}", level=40) + eprint("Não foi possível gravar o pedido. Contacte a administração.") + return 2 + else: + log_session(logger, "ERRO: não foi possível obter request_id único", level=40) + eprint("Erro interno: tente novamente.") + return 2 + + submitted_at = payload["submitted_at"] + _ = path_saved + + # Aviso em consola ao admin (template curto) + try: + notice = render_template( + templates / "admin_console_notice.txt", + { + "request_id": request_id, + "username": username, + "email": email, + "fingerprint": fingerprint, + "submitted_at": submitted_at, + }, + ) + log_session(logger, "admin_console_notice:\n" + notice.strip()) + except OSError: + pass + + admin_email = str(cfg.get("admin_email", "")).strip() + mail_raw = str(cfg.get("mail_from", DEFAULT_MAIL_FROM)).strip() + mail_from = mail_raw or DEFAULT_MAIL_FROM + sendmail_path = str(cfg.get("sendmail_path", "/usr/sbin/sendmail")).strip() + if admin_email: + try: + subject = f"[runv] Novo pedido: {username}" + body = render_template( + templates / "admin_mail.txt", + { + "request_id": request_id, + "username": username, + "email": email, + "public_key": pubkey, + "fingerprint": fingerprint, + "submitted_at": submitted_at, + "remote_addr": ctx.get("remote_addr") or "", + "tty": ctx.get("tty") or "", + }, + ) + sendmail_notify( + admin_email=admin_email, + mail_from=mail_from, + subject=subject, + body=body, + sendmail_path=sendmail_path, + logger=logger, + ) + except OSError as e: + log_session(logger, f"template admin_mail falhou: {e}", level=40) + + # --- Etapa 7: despedida + clear_screen(stdout) + goodbye = render_template( + templates / "goodbye.txt", + {"request_id": request_id}, + ) + stdout.write(style_runv_club(goodbye, stdout)) + stdout.flush() + log_session(logger, f"sessão concluída request_id={request_id}") + except ValidationError as e: + log_session(logger, f"validação: {e}", level=40) + stdout.write(style_runv_club(f"\n{e}\n\n", stdout)) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/terminal/entre_core.py b/terminal/entre_core.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +""" +Lógica partilhada do fluxo SSH «entre» (runv.club): validação, fila, log, email. + +Mantido alinhado com as regras de ``scripts/admin/create_runv_user.py`` (username, +email, tipos de chave). Sem dependências PyPI. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import pwd +import re +import subprocess +import tempfile +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from email.message import EmailMessage +from pathlib import Path +from typing import Any, Final + +import tomllib + +# --- Alinhado a create_runv_user.py (não importar em runtime) ---------------- + +USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") +EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$" +) + +RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( + { + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "_apt", + "nobody", + "admin", + "postmaster", + "entre", + "join", + "welcome", + } +) + +ALLOWED_KEY_TYPES: Final[tuple[str, ...]] = ( + "ssh-ed25519", + "sk-ssh-ed25519@openssh.com", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-rsa", +) + +FINGERPRINT_SHA256_RE: Final[re.Pattern[str]] = re.compile(r"\b(SHA256:[+A-Za-z0-9/_=-]+)\b") + +PRIVATE_KEY_MARKERS: Final[tuple[str, ...]] = ( + "-----BEGIN OPENSSH PRIVATE KEY-----", + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", + "-----BEGIN DSA PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN ENCRYPTED PRIVATE KEY-----", + "PuTTY-User-Key-File", +) + +MAX_USERNAME_LEN: Final[int] = 32 +MAX_EMAIL_LEN: Final[int] = 254 +MAX_PUBKEY_LEN: Final[int] = 16_384 + +APP_VERSION: Final[str] = "0.01" +SOURCE_TAG: Final[str] = "entre-ssh" +# Remetente por omissão das notificações sendmail do fluxo «entre» (cabeçalho From). +DEFAULT_MAIL_FROM: Final[str] = "entre@runv.club" + + +class ValidationError(ValueError): + """Entrada inválida (mensagem para o utilizador).""" + + +def load_config(path: Path) -> dict[str, Any]: + if not path.is_file(): + raise FileNotFoundError(f"config não encontrado: {path}") + data = tomllib.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("config TOML inválido: raiz deve ser tabela") + return data + + +def validate_username(username: str) -> str: + if not username or not username.strip(): + raise ValidationError("o nome de utilizador desejado é obrigatório.") + u = username.strip() + if len(u) > MAX_USERNAME_LEN: + raise ValidationError("nome de utilizador demasiado longo.") + if not USERNAME_PATTERN.fullmatch(u): + raise ValidationError( + "use apenas letras minúsculas, dígitos, _ e -; comece com letra; " + "entre 2 e 32 caracteres." + ) + if u in RESERVED_USERNAMES: + raise ValidationError("esse nome está reservado ou não é permitido.") + try: + pwd.getpwnam(u) + except KeyError: + pass + else: + raise ValidationError("esse nome já existe neste servidor.") + return u + + +def validate_email(email: str) -> str: + if not email or not email.strip(): + raise ValidationError("o email é obrigatório.") + e = email.strip() + if len(e) > MAX_EMAIL_LEN: + raise ValidationError("email demasiado longo.") + if not EMAIL_PATTERN.fullmatch(e): + raise ValidationError("formato de email inválido.") + return e + + +def _reject_private_key_blob(raw: str) -> None: + s = raw.strip() + low = s.lower() + for marker in PRIVATE_KEY_MARKERS: + if marker.lower() in low: + raise ValidationError( + "isto parece uma chave **privada**. Nunca a cole aqui. " + "Cole apenas a linha da chave **pública** (.pub)." + ) + + +def normalize_public_key(raw: str) -> str: + if raw is None or raw == "": + raise ValidationError("a chave pública é obrigatória.") + if len(raw) > MAX_PUBKEY_LEN: + raise ValidationError("linha da chave demasiado longa.") + _reject_private_key_blob(raw) + if "\n" in raw or "\r" in raw: + raise ValidationError("cole uma única linha, sem quebras.") + line = raw.strip() + if not line: + raise ValidationError("chave pública vazia.") + parts = line.split() + if len(parts) < 2: + raise ValidationError("formato inválido: esperado tipo, dados base64 e comentário opcional.") + key_type = parts[0] + if key_type not in ALLOWED_KEY_TYPES: + raise ValidationError( + f"tipo de chave não aceite ({key_type!r}). " + f"Exemplos: ssh-ed25519, ecdsa-sha2-nistp256, ssh-rsa." + ) + blob = parts[1] + if not re.fullmatch(r"[A-Za-z0-9+/]+=*", blob): + raise ValidationError("dados da chave (base64) inválidos.") + normalized = key_type + " " + blob + if len(parts) > 2: + normalized += " " + " ".join(parts[2:]) + return normalized + + +def compute_public_key_fingerprint(public_key_line: str, tmp_dir: Path | None = None) -> str: + line = normalize_public_key(public_key_line) + fd, tmppath = tempfile.mkstemp(prefix="runv-entre-key-", suffix=".pub", dir=tmp_dir) + path = Path(tmppath) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(line + "\n") + proc = subprocess.run( + ["ssh-keygen", "-l", "-E", "sha256", "-f", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + raise ValidationError(f"a chave foi rejeitada pelo ssh-keygen: {err}") + out = (proc.stdout or "").strip().splitlines() + if not out: + raise RuntimeError("ssh-keygen não devolveu saída") + m = FINGERPRINT_SHA256_RE.search(out[0]) + if not m: + raise RuntimeError(f"não foi possível ler o fingerprint: {out[0]!r}") + return m.group(1) + finally: + path.unlink(missing_ok=True) + + +def validate_public_key_line(raw: str) -> tuple[str, str]: + normalized = normalize_public_key(raw) + fp = compute_public_key_fingerprint(normalized) + return normalized, fp + + +def ssh_remote_context() -> dict[str, str | None]: + return { + "remote_addr": os.environ.get("SSH_CONNECTION", "").split()[0] + if os.environ.get("SSH_CONNECTION") + else ( + os.environ.get("SSH_CLIENT", "").split()[0] + if os.environ.get("SSH_CLIENT") + else None + ), + "ssh_connection": os.environ.get("SSH_CONNECTION"), + "ssh_client": os.environ.get("SSH_CLIENT"), + "tty": os.environ.get("SSH_TTY"), + } + + +@dataclass +class EntrePaths: + install_root: Path + templates_dir: Path + queue_dir: Path + log_file: Path + config_path: Path + + +def resolve_paths(cfg: dict[str, Any], install_root: Path) -> EntrePaths: + q = os.environ.get("RUNV_ENTRE_QUEUE_DIR", "").strip() + queue = Path(q) if q else Path(cfg.get("queue_dir", "/var/lib/runv/entre-queue")) + lf_e = os.environ.get("RUNV_ENTRE_LOG_FILE", "").strip() + logf = Path(lf_e) if lf_e else Path(cfg.get("log_file", "/var/log/runv/entre.log")) + td_e = os.environ.get("RUNV_ENTRE_TEMPLATES_DIR", "").strip() + td = Path(td_e) if td_e else Path(cfg.get("templates_dir", str(install_root / "templates"))) + return EntrePaths( + install_root=install_root, + templates_dir=td, + queue_dir=queue, + log_file=logf, + config_path=install_root / "config.toml", + ) + + +def setup_file_logger(log_path: Path) -> logging.Logger: + log = logging.getLogger("runv.entre") + log.setLevel(logging.INFO) + log.handlers.clear() + fmt = logging.Formatter("%(asctime)sZ %(levelname)s %(message)s") + fmt.converter = time.gmtime + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_path, encoding="utf-8") + fh.setFormatter(fmt) + log.addHandler(fh) + except OSError: + sh = logging.StreamHandler() + fmt_err = logging.Formatter("%(asctime)sZ %(levelname)s %(message)s") + fmt_err.converter = time.gmtime + sh.setFormatter(fmt_err) + log.addHandler(sh) + return log + + +def log_session(logger: logging.Logger, msg: str, *, level: int = logging.INFO) -> None: + logger.log(level, msg) + + +def sendmail_notify( + *, + admin_email: str, + mail_from: str, + subject: str, + body: str, + sendmail_path: str, + logger: logging.Logger, +) -> None: + if not admin_email.strip(): + logger.info("notificação por email: admin_email vazio, ignorado.") + return + if not Path(sendmail_path).is_file(): + logger.warning( + "notificação por email: sendmail não encontrado em %s — pedido continua gravado.", + sendmail_path, + ) + return + from_addr = mail_from.strip() or DEFAULT_MAIL_FROM + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = admin_email + msg.set_content(body) + try: + proc = subprocess.run( + [sendmail_path, "-t", "-i"], + input=msg.as_bytes(), + capture_output=True, + timeout=60, + ) + if proc.returncode != 0: + err = (proc.stderr or b"").decode("utf-8", errors="replace").strip() + logger.warning("sendmail falhou (código %s): %s", proc.returncode, err) + else: + logger.info("notificação por email enviada para %s", admin_email) + except OSError as e: + logger.warning("notificação por email: erro ao executar sendmail: %s", e) + except subprocess.TimeoutExpired: + logger.warning("notificação por email: timeout ao executar sendmail.") + + +def save_request_json( + *, + queue_dir: Path, + request_id: str, + payload: dict[str, Any], + logger: logging.Logger, +) -> Path: + queue_dir.mkdir(parents=True, exist_ok=True) + path = queue_dir / f"{request_id}.json" + fd = os.open( + str(path), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o640, + ) + try: + data = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + os.write(fd, data.encode("utf-8")) + finally: + os.close(fd) + logger.info("pedido gravado: %s", path) + return path + + +def build_request_payload( + *, + request_id: str, + username: str, + email: str, + public_key: str, + fingerprint: str, + remote_addr: str | None, + tty: str | None, +) -> dict[str, Any]: + return { + "request_id": request_id, + "username": username, + "email": email, + "public_key": public_key, + "public_key_fingerprint": fingerprint, + "submitted_at": datetime.now(timezone.utc).isoformat(), + "remote_addr": remote_addr, + "tty": tty, + "source": SOURCE_TAG, + "status": "pending", + "app_version": APP_VERSION, + } + + +def new_request_id() -> str: + return str(uuid.uuid4()) + + +def render_template(path: Path, mapping: dict[str, str]) -> str: + text = path.read_text(encoding="utf-8") + for k, v in mapping.items(): + text = text.replace("{" + k + "}", v) + return text + + +def find_install_root() -> Path: + env = os.environ.get("RUNV_ENTRE_ROOT", "").strip() + if env: + return Path(env).resolve() + return Path(__file__).resolve().parent + + +def find_config_path(install_root: Path) -> Path: + env = os.environ.get("RUNV_ENTRE_CONFIG", "").strip() + if env: + return Path(env).resolve() + p = install_root / "config.toml" + if p.is_file(): + return p + example = install_root / "config.example.toml" + if example.is_file(): + return example + return p diff --git a/terminal/examples/sshd_match_entre.conf.sample b/terminal/examples/sshd_match_entre.conf.sample @@ -0,0 +1,19 @@ +# Exemplo — modo shared-password (omissão do setup_entre.py) +# Validar: sudo sshd -t && sudo systemctl reload ssh +# +# Shell do utilizador «entre» em passwd: /bin/sh (não use nologin com ForceCommand). + +Match User entre + AuthenticationMethods password + PasswordAuthentication yes + KbdInteractiveAuthentication no + PubkeyAuthentication no + PermitEmptyPasswords no + ForceCommand /usr/bin/python3 /opt/runv/terminal/entre_app.py + PermitTTY yes + PermitUserRC no + X11Forwarding no + AllowAgentForwarding no + AllowTcpForwarding no + PermitTunnel no + DisableForwarding yes diff --git a/terminal/examples/sshd_match_entre_empty.conf.sample b/terminal/examples/sshd_match_entre_empty.conf.sample @@ -0,0 +1,32 @@ +# Exemplo — --auth-mode empty-password (omissão: keyboard-interactive + PAM) +# Compatível com OpenSSH do Windows. Validar: sudo sshd -t && sudo systemctl reload ssh +# +# Shell em passwd: /bin/sh. Grupo suplementar típico: entre-open (+ PAM pam_succeed_if). + +Match User entre + AuthenticationMethods keyboard-interactive + PasswordAuthentication no + KbdInteractiveAuthentication yes + PubkeyAuthentication no + PermitEmptyPasswords no + ForceCommand /usr/bin/python3 /opt/runv/terminal/entre_app.py + PermitTTY yes + PermitUserRC no + X11Forwarding no + AllowAgentForwarding no + AllowTcpForwarding no + PermitTunnel no + DisableForwarding yes + +# Variante README tilde (password + senha vazia): correr setup com +# --empty-password-tilde-password-auth +# ou substituir o bloco Match por: +# +# Match User entre +# AuthenticationMethods password +# PasswordAuthentication yes +# KbdInteractiveAuthentication no +# PubkeyAuthentication no +# PermitEmptyPasswords yes +# ForceCommand /usr/bin/python3 /opt/runv/terminal/entre_app.py +# ... diff --git a/terminal/scripts/install.sh b/terminal/scripts/install.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Instalação rápida: delega em setup_entre.py (root). +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +exec python3 "$ROOT/setup_entre.py" "$@" diff --git a/terminal/scripts/test_local.sh b/terminal/scripts/test_local.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Teste local sem SSH: fila e log dentro de terminal/data/. +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p "$ROOT/data/queue" +export RUNV_ENTRE_ROOT="$ROOT" +export RUNV_ENTRE_CONFIG="$ROOT/config.example.toml" +export RUNV_ENTRE_QUEUE_DIR="$ROOT/data/queue" +export RUNV_ENTRE_LOG_FILE="$ROOT/data/entre-test.log" +exec python3 "$ROOT/entre_app.py" "$@" diff --git a/terminal/scripts/test_mail.sh b/terminal/scripts/test_mail.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Mostra no stdout o corpo que seria enviado via sendmail (sem executar sendmail). +# Uso: Ajuste variáveis e execute. Para enviar de verdade: +# ... | /usr/sbin/sendmail -t -i +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REQUEST_ID="00000000-0000-0000-0000-000000000001" +export REQUEST_ID +printf '%s\n' "From: entre@runv.club +To: admin@example.com +Subject: [runv] teste de notificação + +Pedido de teste request_id=${REQUEST_ID} +" diff --git a/terminal/setup_entre.py b/terminal/setup_entre.py @@ -0,0 +1,897 @@ +#!/usr/bin/env python3 +""" +Prepara infraestrutura do utilizador «entre» e instala o módulo terminal em +/opt/runv/terminal. + +Onboarding estilo tilde.town (join@tilde.town): + O padrão documentado por tilde.town usa utilizador especial + Match User + SSH com + PasswordAuthentication, PermitEmptyPasswords yes, PubkeyAuthentication no, e muitas vezes + uma linha em /etc/pam.d/sshd com pam_succeed_if (ex.: user ingroup join) para que a + autenticação PAM não exija palavra-passe para esse grupo. Não é «sem autenticação» no + protocolo: é aceitar palavra-passe vazia / sucesso PAM antecipado só para essa conta + 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 --auth-mode empty-password (primeira classe): + Replica o espírito tilde.town para «entre»: senha vazia (passwd -d), grupo suplementar + (omissão: entre-open), e por omissão drop-in com AuthenticationMethods keyboard-interactive + + KbdInteractiveAuthentication yes (PAM pam_succeed_if sem prompts) — compatível com + OpenSSH do Windows, que em geral não envia palavra-passe vazia no método password. + Por omissão altera /etc/pam.d/sshd (pam_succeed_if user ingroup …) com backup — no Debian, + sem isto o PAM recusa o fluxo e a sessão pode fechar. Use --skip-pam-empty-password-rule + só se configurar PAM à mão. + Para o esquema README tilde (password + PermitEmptyPasswords yes), use + --empty-password-tilde-password-auth (Linux/Git Bash). + +Porque /bin/sh e não nologin: + O OpenSSH usa o shell de passwd no contexto do login; nologin impede o fluxo até ao + ForceCommand. Use /bin/sh; o visitante não fica com shell interactivo normal. + +Por defeito (sem --skip-sshd): + - cria «entre» com /bin/sh; chsh se já existir com outro shell; + - em empty-password: grupo onboarding, membro, passwd -d, validação NP, regra PAM (por omissão); + - escreve runv-entre.conf; sshd -t; sshd -T -C …; reload ssh. + +Use --skip-sshd / --no-reload / --dry-run conforme necessário. + +Executar como root no servidor Debian. + +Versão 0.10 — runv.club +""" + +from __future__ import annotations + +import argparse +import grp +import os +import pwd +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Final + +VERSION: Final[str] = "0.10" +ENTRE_USER: Final[str] = "entre" +INSTALL_ROOT: Final[Path] = Path("/opt/runv/terminal") +QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue") +LOG_DIR: Final[Path] = Path("/var/log/runv") +SSHD_DROPIN: Final[Path] = Path("/etc/ssh/sshd_config.d/runv-entre.conf") +PAM_SSHD: Final[Path] = Path("/etc/pam.d/sshd") +MODULE_SRC: Final[Path] = Path(__file__).resolve().parent + +AUTH_SHARED: Final[str] = "shared-password" +AUTH_KEY: Final[str] = "key-only" +AUTH_EMPTY: Final[str] = "empty-password" + +# Grupo suplementar para PAM pam_succeed_if (tilde.town usa «join»; aqui «entre-open»). +ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT: Final[str] = "entre-open" + +INSECURE_EMPTY_BANNER: Final[str] = """ +****************************************************************************** +* AVISO: modo empty-password — onboarding estilo tilde.town / join@tilde.town * +* Não é «SSH sem autenticação»: é palavra-passe vazia + políticas só para «entre». * +* Qualquer cliente que alcance o porto SSH pode entrar nesta conta. * +* Não use para contas normais nem exponha sem firewall / política consciente. * +****************************************************************************** +""" + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def require_root() -> None: + if os.geteuid() != 0: + eprint("Execute como root (sudo).") + raise SystemExit(1) + + +def run(cmd: list[str], *, timeout: int = 120) -> None: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") + + +def run_capture(cmd: list[str], *, timeout: int = 120) -> str: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") + return (r.stdout or "").strip() + + +def user_exists(name: str) -> bool: + try: + pwd.getpwnam(name) + except KeyError: + return False + return True + + +def group_exists(name: str) -> bool: + try: + grp.getgrnam(name) + except KeyError: + return False + return True + + +def user_in_group(username: str, group_name: str) -> bool: + try: + g = grp.getgrnam(group_name) + except KeyError: + return False + if username in g.gr_mem: + return True + try: + pw = pwd.getpwnam(username) + except KeyError: + return False + return pw.pw_gid == g.gr_gid + + +def ensure_onboarding_group( + group_name: str, + *, + dry_run: bool, +) -> None: + if dry_run: + print(f"[dry-run] groupadd -f {group_name!r} (se não existir)") + return + if not group_exists(group_name): + run(["groupadd", group_name]) + print(f"Criado grupo {group_name!r}.") + else: + print(f"Grupo {group_name!r} já existe.") + + +def ensure_user_in_onboarding_group(group_name: str, *, dry_run: bool) -> None: + if dry_run: + print(f"[dry-run] usermod -aG {group_name} {ENTRE_USER}") + return + if user_in_group(ENTRE_USER, group_name): + print(f"{ENTRE_USER!r} já está no grupo {group_name!r}.") + return + run(["usermod", "-aG", group_name, ENTRE_USER]) + print(f"Adicionado {ENTRE_USER!r} ao grupo {group_name!r}.") + + +def pam_line_for_onboarding_group(group_name: str) -> str: + return ( + "auth [success=done default=ignore] pam_succeed_if.so " + f"user ingroup {group_name}" + ) + + +def install_pam_empty_password_rule( + group_name: str, + *, + dry_run: bool, +) -> None: + """ + Insere regra tilde.town-style antes da autenticação PAM padrão (ex.: @include common-auth). + Backup: /etc/pam.d/sshd.bak.<timestamp> + """ + line = pam_line_for_onboarding_group(group_name) + marker = f"runv.club setup_entre.py — onboarding {group_name}" + block = ( + f"# {marker}\n" + f"{line}\n" + ) + + if dry_run: + print(f"[dry-run] backup + inserir em {PAM_SSHD}:\n{line}") + return + + if not PAM_SSHD.is_file(): + raise RuntimeError(f"{PAM_SSHD} não existe; não é possível instalar regra PAM.") + + current = PAM_SSHD.read_text(encoding="utf-8", errors="replace") + if line in current: + print(f"Regra PAM já presente em {PAM_SSHD} (saltar).") + return + + backup = PAM_SSHD.with_name(f"{PAM_SSHD.name}.bak.{int(time.time())}") + shutil.copy2(PAM_SSHD, backup) + print(f"Backup PAM: {backup}") + + lines = current.splitlines(keepends=True) + insert_at = 0 + for i, raw in enumerate(lines): + s = raw.strip() + if not s or s.startswith("#"): + continue + if s.startswith("@include") or re.match(r"^auth\s", s): + insert_at = i + break + insert_at = i + 1 + + new_body = "".join(lines[:insert_at]) + block + "".join(lines[insert_at:]) + PAM_SSHD.write_text(new_body, encoding="utf-8") + print(f"Inserida regra PAM em {PAM_SSHD} (antes da auth padrão).") + + +def ensure_user_entre(*, home: Path, shell: str) -> None: + if user_exists(ENTRE_USER): + print(f"Utilizador {ENTRE_USER!r} já existe.") + return + run( + [ + "useradd", + "--create-home", + "--home-dir", + str(home), + "--shell", + shell, + "--user-group", + ENTRE_USER, + ] + ) + print(f"Criado utilizador {ENTRE_USER!r} (shell {shell!r}).") + + +def ensure_entre_shell(shell: str, *, dry_run: bool) -> None: + """Garante shell em passwd (ex.: migração de contas antigas com nologin).""" + if dry_run: + return + pw = pwd.getpwnam(ENTRE_USER) + if pw.pw_shell == shell: + return + run(["chsh", "-s", shell, ENTRE_USER]) + print(f"Shell de {ENTRE_USER!r} actualizado de {pw.pw_shell!r} para {shell!r}.") + + +def ensure_entre_dot_ssh(home: Path, uid: int, gid: int, *, dry_run: bool) -> None: + """Garante ~/.ssh/authorized_keys com modos correctos (ficheiro pode ficar vazio).""" + if dry_run: + print(f"[dry-run] garantiria {home}/.ssh e authorized_keys") + return + home.mkdir(parents=True, exist_ok=True) + try: + os.chown(home, uid, gid) + except OSError: + pass + ssh = home / ".ssh" + ssh.mkdir(mode=0o700, exist_ok=True) + os.chmod(ssh, 0o700) + os.chown(ssh, uid, gid) + auth = ssh / "authorized_keys" + if not auth.exists(): + auth.write_text("", encoding="utf-8") + os.chmod(auth, 0o600) + os.chown(auth, uid, gid) + print(f"Garantido {ssh} e {auth} (dono {ENTRE_USER}).") + + +def clear_entre_password(*, dry_run: bool) -> None: + """Palavra-passe vazia (modo empty-password).""" + if dry_run: + print("[dry-run] passwd -d entre (palavra-passe vazia)") + return + run(["passwd", "-d", ENTRE_USER]) + print(f"Palavra-passe de {ENTRE_USER!r} removida (passwd -d).") + + +def assert_entre_password_empty(*, dry_run: bool) -> None: + """Estado NP em passwd -S (sem palavra-passe utilizável).""" + if dry_run: + print("[dry-run] validaria passwd -S entre (esperado NP)") + return + out = run_capture(["passwd", "-S", ENTRE_USER], timeout=30) + parts = out.split() + if len(parts) < 2: + raise RuntimeError(f"passwd -S inesperado: {out!r}") + status = parts[1] + if status != "NP": + raise RuntimeError( + f"Esperava estado NP (sem palavra-passe) após passwd -d; obtido {status!r} " + f"em «{out}». Verifique bloqueios (usermod -U) ou política de palavras-passe." + ) + print(f"passwd -S: {ENTRE_USER!r} está NP (sem palavra-passe utilizável).") + + +def build_sshd_dropin_content( + python_path: str, + app_path: Path, + auth_mode: str, + *, + empty_ssh_auth: str | None = None, +) -> str: + cmd = f"{python_path} {app_path}" + header = ( + f"# Instalado por runv.club setup_entre.py — auth_mode={auth_mode}\n" + f"# Validar: sshd -t\n" + ) + if auth_mode == AUTH_EMPTY: + header += "# Onboarding tilde.town-style: PAM pam_succeed_if + conta especial entre.\n" + + lines = [ + header.rstrip(), + f"Match User {ENTRE_USER}", + ] + + if auth_mode == AUTH_SHARED: + lines.extend( + [ + " AuthenticationMethods password", + " PasswordAuthentication yes", + " KbdInteractiveAuthentication no", + " PubkeyAuthentication no", + " PermitEmptyPasswords no", + ] + ) + elif auth_mode == AUTH_KEY: + lines.extend( + [ + " AuthenticationMethods publickey", + " PasswordAuthentication no", + " KbdInteractiveAuthentication no", + " PubkeyAuthentication yes", + " PermitEmptyPasswords no", + ] + ) + elif auth_mode == AUTH_EMPTY: + # Omissão: keyboard-interactive + PAM (compatível com OpenSSH Windows; sem senha vazia no wire). + # tilde-password: como README tilde (password + PermitEmptyPasswords); Linux/Git Bash. + if empty_ssh_auth == "password": + lines.extend( + [ + " AuthenticationMethods password", + " PasswordAuthentication yes", + " KbdInteractiveAuthentication no", + " PubkeyAuthentication no", + " PermitEmptyPasswords yes", + ] + ) + else: + lines.extend( + [ + " AuthenticationMethods keyboard-interactive", + " PasswordAuthentication no", + " KbdInteractiveAuthentication yes", + " PubkeyAuthentication no", + " PermitEmptyPasswords no", + ] + ) + else: + raise ValueError(f"auth_mode desconhecido: {auth_mode!r}") + + lines.extend( + [ + f" ForceCommand {cmd}", + " PermitTTY yes", + " PermitUserRC no", + " X11Forwarding no", + " AllowAgentForwarding no", + " AllowTcpForwarding no", + " PermitTunnel no", + " DisableForwarding yes", + "", + ] + ) + return "\n".join(lines) + + +def parse_sshd_t(output: str) -> dict[str, str]: + cfg: dict[str, str] = {} + for raw in output.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) + if len(parts) == 1: + cfg[parts[0].lower()] = "" + else: + cfg[parts[0].lower()] = parts[1].strip() + return cfg + + +def _norm_ws(s: str) -> str: + return " ".join(s.split()) + + +def validate_effective_sshd( + *, + conn: str, + force_command: str, + auth_mode: str, + empty_ssh_auth: str | None = None, +) -> None: + """Confirma opções efectivas para Match User entre via sshd -T -C.""" + try: + out = run_capture(["sshd", "-T", "-C", conn], timeout=60) + except RuntimeError as e: + raise RuntimeError( + "Validação sshd -T -C falhou (sshd inacessível ou -C inválido?). " + f"Detalhe: {e}" + ) from e + + cfg = parse_sshd_t(out) + errs: list[str] = [] + + fc_eff = _norm_ws(cfg.get("forcecommand", "")) + fc_exp = _norm_ws(force_command) + if not fc_eff or (fc_eff != fc_exp and fc_exp not in fc_eff and fc_eff not in fc_exp): + errs.append(f"forcecommand: esperado «{fc_exp}», efectivo «{fc_eff}»") + + if cfg.get("permittty", "").lower() != "yes": + errs.append(f"permittty: esperado yes, efectivo «{cfg.get('permittty', '')}»") + + if cfg.get("disableforwarding", "").lower() != "yes": + errs.append( + f"disableforwarding: esperado yes, efectivo «{cfg.get('disableforwarding', '')}»" + ) + + if "permituserrc" in cfg and cfg.get("permituserrc", "").lower() != "no": + errs.append(f"permituserrc: esperado no, efectivo «{cfg.get('permituserrc', '')}»") + + am = cfg.get("authenticationmethods", "").lower().replace(",", " ") + pw = cfg.get("passwordauthentication", "").lower() + pk = cfg.get("pubkeyauthentication", "").lower() + kbd = cfg.get("kbdinteractiveauthentication", "").lower() + empty = cfg.get("permitemptypasswords", "").lower() + + if auth_mode == AUTH_SHARED: + if "password" not in am.split(): + errs.append(f"authenticationmethods: esperado incluir password, efectivo «{am}»") + if pw != "yes": + errs.append(f"passwordauthentication: esperado yes, efectivo «{pw}»") + if pk != "no": + errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»") + if kbd != "no": + errs.append(f"kbdinteractiveauthentication: esperado no, efectivo «{kbd}»") + if empty != "no": + errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»") + elif auth_mode == AUTH_KEY: + if "publickey" not in am.split(): + errs.append(f"authenticationmethods: esperado incluir publickey, efectivo «{am}»") + if pw != "no": + errs.append(f"passwordauthentication: esperado no, efectivo «{pw}»") + if pk != "yes": + errs.append(f"pubkeyauthentication: esperado yes, efectivo «{pk}»") + if empty != "no": + errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»") + elif auth_mode == AUTH_EMPTY: + if empty_ssh_auth == "password": + if "password" not in am.split(): + errs.append(f"authenticationmethods: esperado incluir password, efectivo «{am}»") + if pw != "yes": + errs.append(f"passwordauthentication: esperado yes, efectivo «{pw}»") + if pk != "no": + errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»") + if kbd != "no": + errs.append(f"kbdinteractiveauthentication: esperado no, efectivo «{kbd}»") + if empty != "yes": + errs.append(f"permitemptypasswords: esperado yes, efectivo «{empty}»") + else: + if "keyboard-interactive" not in am.split(): + errs.append( + f"authenticationmethods: esperado incluir keyboard-interactive, efectivo «{am}»" + ) + if pw != "no": + errs.append(f"passwordauthentication: esperado no, efectivo «{pw}»") + if kbd != "yes": + errs.append( + f"kbdinteractiveauthentication: esperado yes, efectivo «{kbd}»" + ) + if pk != "no": + errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»") + if empty != "no": + errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»") + + if errs: + raise RuntimeError( + "Validação pós-configuração (sshd -T -C) falhou:\n - " + + "\n - ".join(errs) + ) + + +def sshd_main_config_mentions_dropin() -> bool: + main = Path("/etc/ssh/sshd_config") + if not main.is_file(): + return False + try: + text = main.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return "sshd_config.d" in text and "Include" in text + + +def apply_sshd_configuration( + python_path: str, + app_path: Path, + *, + install_root: Path, + auth_mode: str, + sshd_test_connection: str, + empty_ssh_auth: str | None, + dry_run: bool, + skip_sshd: bool, + no_reload: bool, +) -> None: + force_cmd = f"{python_path} {app_path}" + content = build_sshd_dropin_content( + python_path, app_path, auth_mode, empty_ssh_auth=empty_ssh_auth + ) + + if skip_sshd: + print() + print("== Modo --skip-sshd: configure o SSH manualmente ==") + print("1. Editar", install_root / "config.toml", "— especialmente admin_email.") + print("2. Criar /etc/ssh/sshd_config.d/… com o bloco abaixo.") + print("3. sshd -t && systemctl reload ssh") + print("4. empty-password: regra PAM por omissão (ou --skip-pam-empty-password-rule).") + print("5. Testar conforme --auth-mode.") + print() + print(content) + return + + if dry_run: + print(f"[dry-run] escreveria {SSHD_DROPIN} e correria sshd -t + validação -T") + print("--- conteúdo ---") + print(content) + return + + if not sshd_main_config_mentions_dropin(): + print( + "AVISO: /etc/ssh/sshd_config pode não incluir /etc/ssh/sshd_config.d/*.conf.\n" + " Confirme uma linha «Include … sshd_config.d» ou o drop-in não será lido.", + file=sys.stderr, + ) + + SSHD_DROPIN.parent.mkdir(parents=True, exist_ok=True) + backup: Path | None = None + if SSHD_DROPIN.is_file(): + backup = SSHD_DROPIN.with_name(f"{SSHD_DROPIN.name}.bak.{int(time.time())}") + shutil.copy2(SSHD_DROPIN, backup) + print(f"Backup do drop-in anterior: {backup}") + + SSHD_DROPIN.write_text(content, encoding="utf-8") + SSHD_DROPIN.chmod(0o644) + print(f"Escrito {SSHD_DROPIN}") + + def revert() -> None: + if backup is not None: + shutil.copy2(backup, SSHD_DROPIN) + print(f"Revertido {SSHD_DROPIN} a partir de {backup}.", file=sys.stderr) + else: + try: + SSHD_DROPIN.unlink() + except OSError: + pass + print(f"Removido {SSHD_DROPIN}.", file=sys.stderr) + + try: + run(["sshd", "-t"]) + except RuntimeError as e: + revert() + raise RuntimeError("sshd -t falhou após instalar drop-in; configuração revertida.") from e + + print("sshd -t: OK.") + + try: + validate_effective_sshd( + conn=sshd_test_connection, + force_command=force_cmd, + auth_mode=auth_mode, + empty_ssh_auth=empty_ssh_auth, + ) + except RuntimeError as e: + revert() + raise RuntimeError( + f"{e}\nConfiguração revertida; corrija o Match User ou a string -C de teste." + ) from e + + print(f"Validação efectiva sshd -T -C {sshd_test_connection!r}: OK.") + + if no_reload: + print("Saltado reload (--no-reload). Execute: systemctl reload ssh") + return + + try: + run(["systemctl", "reload", "ssh"], timeout=60) + except RuntimeError: + try: + run(["systemctl", "reload", "sshd"], timeout=60) + except RuntimeError as e2: + raise RuntimeError( + "sshd -t e validação passaram mas falhou systemctl reload ssh/sshd; " + "recarregue o serviço SSH manualmente." + ) from e2 + print("Serviço SSH recarregado (reload).") + + +def copy_module(dest: Path, *, dry_run: bool) -> None: + files = [ + "entre_app.py", + "entre_core.py", + "del_entre.py", + "config.example.toml", + "README.md", + ] + subdirs = ["templates", "docs", "systemd", "scripts", "data", "examples"] + if dry_run: + print(f"[dry-run] copiaria para {dest}") + return + dest.mkdir(parents=True, exist_ok=True) + for name in files: + src = MODULE_SRC / name + if src.is_file(): + shutil.copy2(src, dest / name) + for sd in subdirs: + s = MODULE_SRC / sd + if s.is_dir(): + d = dest / sd + if d.exists(): + shutil.rmtree(d) + shutil.copytree(s, d) + print(f"Módulo copiado para {dest}") + + +def install_config(dest: Path, *, dry_run: bool, force: bool) -> None: + cfg = dest / "config.toml" + example = dest / "config.example.toml" + if dry_run: + print(f"[dry-run] config em {cfg}") + return + if cfg.is_file() and not force: + print(f"Mantido {cfg} existente (use --force-config para sobrescrever do example).") + return + if example.is_file(): + shutil.copy2(example, cfg) + print(f"Instalado {cfg} a partir do example.") + else: + eprint(f"Aviso: {example} não encontrado.") + + +def chmod_tree_templates(root: Path) -> None: + t = root / "templates" + if not t.is_dir(): + return + for p in t.rglob("*"): + if p.is_file(): + p.chmod(0o644) + + +def print_final_instructions( + *, + auth_mode: str, + install_root: Path, + empty_group: str, + pam_installed: bool, + empty_ssh_auth: str | None, +) -> None: + print() + print("== Concluído ==") + print(f"1. Editar {install_root / 'config.toml'} (admin_email, etc.).") + + if auth_mode == AUTH_SHARED: + print("2. Acesso por palavra-passe Unix partilhada (definida só pelo root):") + print(f" sudo passwd {ENTRE_USER}") + print(" ou: echo 'entre:A_SENHA' | sudo chpasswd") + print("3. Testar:") + print(" ssh entre@runv.club") + elif auth_mode == AUTH_KEY: + auth_keys = Path(pwd.getpwnam(ENTRE_USER).pw_dir) / ".ssh" / "authorized_keys" + print("2. Colocar chaves públicas em (uma linha por chave):") + print(f" {auth_keys}") + print("3. Testar:") + print(" ssh entre@runv.club") + elif auth_mode == AUTH_EMPTY: + print(INSECURE_EMPTY_BANNER) + print("2. Onboarding estilo join@tilde.town:") + print(f" - Conta {ENTRE_USER!r} sem palavra-passe utilizável (passwd -d; estado NP).") + print(f" - Grupo suplementar {empty_group!r} (para alinhar com PAM pam_succeed_if).") + if pam_installed: + print(f" - PAM: linha ingroup {empty_group!r} em /etc/pam.d/sshd (com backup .bak.*).") + else: + print(" - PAM: saltado (--skip-pam-empty-password-rule). No Debian o login com") + print(" senha vazia falha sem pam_succeed_if antes de common-auth; volte a correr") + print(" o setup sem --skip-pam ou edite /etc/pam.d/sshd à mão.") + if empty_ssh_auth == "password": + print("3. Testar (Enter em branco no prompt de palavra-passe):") + print(" ssh entre@runv.club") + print(" Nota: OpenSSH do Windows em geral não envia palavra-passe vazia neste modo.") + print(" Use WSL/Git Bash, ou volte a correr o setup sem --empty-password-tilde-password-auth") + print(" (omissão: keyboard-interactive, mais compatível com Windows).") + else: + print("3. Testar (omissão: keyboard-interactive + PAM; pode não pedir palavra-passe):") + print(" ssh entre@runv.club") + print(" Se aparecer prompt, tente Enter em branco; em Windows este modo costuma funcionar.") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Setup utilizador entre + /opt/runv/terminal + OpenSSH (automatizado).", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--force-config", action="store_true", help="sobrescrever config.toml com example") + parser.add_argument("--home", type=Path, default=Path(f"/home/{ENTRE_USER}")) + parser.add_argument( + "--shell", + default="/bin/sh", + help="shell em passwd (ForceCommand precisa de shell funcional; não use nologin)", + ) + 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)", + ) + parser.add_argument( + "--empty-password-group", + default=ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT, + metavar="GRUPO", + help=f"grupo suplementar em empty-password + PAM ingroup (default: {ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT})", + ) + parser.add_argument( + "--empty-password-tilde-password-auth", + action="store_true", + help="empty-password: password + PermitEmptyPasswords (README tilde); omissão usa " + "keyboard-interactive (melhor no OpenSSH do Windows)", + ) + parser.add_argument( + "--skip-pam-empty-password-rule", + action="store_true", + help="não alterar /etc/pam.d/sshd (empty-password: sem PAM, Debian costuma fechar a sessão)", + ) + parser.add_argument( + "--install-pam-empty-password-rule", + action="store_true", + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--sshd-test-connection", + default="user=entre,host=runv.club,addr=127.0.0.1", + help="argumento -C para sshd -T na validação pós-config (user/host/addr do Match)", + ) + parser.add_argument("--install-root", type=Path, default=INSTALL_ROOT) + parser.add_argument("--queue-dir", type=Path, default=QUEUE_DIR) + parser.add_argument("--skip-copy", action="store_true", help="não copiar ficheiros do módulo") + parser.add_argument( + "--skip-sshd", + action="store_true", + help="não escrever drop-in nem recarregar SSH; imprime bloco para cópia manual", + ) + parser.add_argument( + "--no-reload", + action="store_true", + help="após sshd -t e validação -T, não executar systemctl reload", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") + args = parser.parse_args() + + 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.") + return 2 + + empty_ssh_auth: str | None + if args.auth_mode == AUTH_EMPTY: + empty_ssh_auth = ( + "password" if args.empty_password_tilde_password_auth else "keyboard-interactive" + ) + else: + empty_ssh_auth = None + + if args.auth_mode == AUTH_EMPTY: + print(INSECURE_EMPTY_BANNER, file=sys.stderr) + if args.skip_pam_empty_password_rule: + eprint( + "AVISO: --skip-pam-empty-password-rule — em Debian/Ubuntu o stack PAM em " + "sshd recusa palavra-passe vazia sem pam_succeed_if; espere «Connection closed» " + "após o prompt se não configurar PAM à mão." + ) + + require_root() + + ir = args.install_root + qd = args.queue_dir + empty_group = args.empty_password_group.strip() + if not empty_group: + eprint("--empty-password-group não pode ser vazio.") + return 2 + + pam_done = False + apply_pam_empty = ( + args.auth_mode == AUTH_EMPTY + and not args.skip_pam_empty_password_rule + ) + + if not args.skip_copy: + copy_module(ir, dry_run=args.dry_run) + install_config(ir, dry_run=args.dry_run, force=args.force_config) + if not args.dry_run: + chmod_tree_templates(ir) + + if not args.dry_run: + LOG_DIR.mkdir(parents=True, exist_ok=True) + qd.mkdir(parents=True, mode=0o750, exist_ok=True) + ensure_user_entre(home=args.home, shell=args.shell) + ensure_entre_shell(args.shell, dry_run=False) + + pw = pwd.getpwnam(ENTRE_USER) + uid, gid = pw.pw_uid, pw.pw_gid + entre_home = Path(pw.pw_dir) + ensure_entre_dot_ssh(entre_home, uid, gid, dry_run=False) + + if args.auth_mode == AUTH_EMPTY: + ensure_onboarding_group(empty_group, dry_run=False) + ensure_user_in_onboarding_group(empty_group, dry_run=False) + clear_entre_password(dry_run=False) + assert_entre_password_empty(dry_run=False) + if apply_pam_empty: + install_pam_empty_password_rule(empty_group, dry_run=False) + pam_done = True + + os.chown(qd, uid, gid) + qd.chmod(0o700) + + log_path = LOG_DIR / "entre.log" + if not log_path.exists(): + log_path.touch(mode=0o640) + os.chown(log_path, uid, gid) + log_path.chmod(0o640) + + if ir.exists(): + for root, dirs, files in os.walk(ir, followlinks=False): + for name in dirs + files: + p = Path(root) / name + try: + os.chown(p, uid, gid, follow_symlinks=False) + except OSError: + pass + try: + os.chown(ir, uid, gid) + except OSError: + pass + ir.chmod(0o750) + else: + print("[dry-run] utilizador entre, fila, log e .ssh seriam garantidos (sem alterar sistema).") + if args.auth_mode == AUTH_EMPTY: + ensure_onboarding_group(empty_group, dry_run=True) + ensure_user_in_onboarding_group(empty_group, dry_run=True) + clear_entre_password(dry_run=True) + assert_entre_password_empty(dry_run=True) + if apply_pam_empty: + install_pam_empty_password_rule(empty_group, dry_run=True) + + py = shutil.which("python3") or "/usr/bin/python3" + app = ir / "entre_app.py" + + try: + apply_sshd_configuration( + py, + app, + install_root=ir, + auth_mode=args.auth_mode, + sshd_test_connection=args.sshd_test_connection, + empty_ssh_auth=empty_ssh_auth, + dry_run=args.dry_run, + skip_sshd=args.skip_sshd, + no_reload=args.no_reload, + ) + except RuntimeError as e: + eprint(str(e)) + return 1 + + if not args.skip_sshd and not args.dry_run: + print_final_instructions( + auth_mode=args.auth_mode, + install_root=ir, + empty_group=empty_group, + pam_installed=pam_done, + empty_ssh_auth=empty_ssh_auth, + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/terminal/systemd/runv-entre-notify.path b/terminal/systemd/runv-entre-notify.path @@ -0,0 +1,16 @@ +# systemd path unit — dispara o serviço quando a fila é alterada. +# Instalação (exemplo): +# sudo cp terminal/systemd/runv-entre-notify.path /etc/systemd/system/ +# sudo cp terminal/systemd/runv-entre-notify.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now runv-entre-notify.path + +[Unit] +Description=Observar fila de pedidos runv (entre-queue) + +[Path] +PathModified=/var/lib/runv/entre-queue +Unit=runv-entre-notify.service + +[Install] +WantedBy=multi-user.target diff --git a/terminal/systemd/runv-entre-notify.service b/terminal/systemd/runv-entre-notify.service @@ -0,0 +1,15 @@ +# Serviço one-shot opcional — acoplado ao .path acima. +# Personalize ExecStart para enviar email, Matrix, etc. + +[Unit] +Description=Hook pós-novo pedido na fila entre (runv.club) +After=network-online.target + +[Service] +Type=oneshot +User=root +# Exemplo: só registo no syslog (sem shell -c no Python; aqui é unit systemd) +ExecStart=/usr/bin/logger -t runv-entre-notify "fila /var/lib/runv/entre-queue modificada" + +[Install] +WantedBy=multi-user.target diff --git a/terminal/templates/admin_console_notice.txt b/terminal/templates/admin_console_notice.txt @@ -0,0 +1 @@ +NOVO_PEDIDO_ENTRE request_id={request_id} user={username} email={email} fp={fingerprint} at={submitted_at} diff --git a/terminal/templates/admin_mail.txt b/terminal/templates/admin_mail.txt @@ -0,0 +1,16 @@ +Novo pedido de entrada (SSH entre) + +request_id : {request_id} +username : {username} +email : {email} +fingerprint: {fingerprint} +submitted_at: {submitted_at} +remote_addr: {remote_addr} +tty : {tty} + +Chave pública (uma linha): +{public_key} + +--- +Fila: /var/lib/runv/entre-queue/{request_id}.json +Aprovar com create_runv_user.py (ver terminal/docs/ADMIN.md). diff --git a/terminal/templates/confirm.txt b/terminal/templates/confirm.txt @@ -0,0 +1,13 @@ + Confirmar pedido + ──────────────── + + Revisa os dados antes de enviar. + + Nome desejado : {username} + Email : {email} + Fingerprint SHA256: {fingerprint} + (relógio local do servidor ao confirmar): {submitted_preview} + + Depois de confirmar, o pedido fica na fila para análise manual. Não há + criação imediata de conta. + diff --git a/terminal/templates/goodbye.txt b/terminal/templates/goodbye.txt @@ -0,0 +1,20 @@ + + Pedido registrado + ───────────────── + + Seu pedido foi recebido. + + Agora ele seguirá para análise manual pela administração da runv.club. + + Se tudo estiver certo, você receberá uma resposta por email com os próximos + passos. + + Não é preciso reenviar o pedido várias vezes. + + Referência interna do pedido: {request_id} + + Obrigado por chegar até aqui — e por ajudar a manter vivo esse espírito de + estudo, curiosidade e comunidade. + + Até breve. + diff --git a/terminal/templates/intro.txt b/terminal/templates/intro.txt @@ -0,0 +1,62 @@ + + runv.club — pedido de entrada + ───────────────────────────── + + Você atravessou uma porta simples. + + Nada de feed infinito. Nada de algoritmo puxando sua manga. Nada de vitrines + barulhentas disputando sua atenção. + + Do outro lado, existe apenas uma máquina ligada, algumas pessoas curiosas e a + velha ideia de que a internet também pode ser um lugar de estudo, criação e + encontro. + +%%PAGE%% + + A runv.club nasce desse espírito. + + Ela é uma comunidade brasileira inspirada nos antigos ambientes Unix públicos + — os pubnixes, tilde servers e outros cantos da rede onde aprender, publicar + e conversar eram partes da mesma experiência. + + Aqui, o terminal não é uma barreira. É só a porta de entrada. + +%%PAGE%% + + A proposta é simples: oferecer um espaço para brasileiros que queiram explorar + Unix e Linux, criar sua própria página pessoal, estudar, trocar conhecimento, + conhecer gente interessante e redescobrir uma internet mais calma, mais humana + e mais autoral. + + Talvez você já saiba exatamente o que procura. Talvez esteja só começando. + Talvez tenha chegado por curiosidade. + + Tudo bem. + + Você não precisa chegar pronto. + +%%PAGE%% + + Nesta etapa, ainda não vamos criar sua conta imediatamente. Primeiro, vamos + registrar seu interesse e coletar algumas informações básicas para que a + entrada na comunidade aconteça com cuidado. + + Daqui a pouco, vamos pedir apenas três coisas: + + o nome de usuário que você gostaria de usar; + seu email para contato; + sua chave pública SSH. + +%%PAGE%% + + Se tudo estiver certo, seu pedido será registrado e analisado pela + administração. + + Respire fundo. Leia com calma. E, por favor, cole apenas sua chave pública + — nunca a privada. + + Se você veio para aprender, explorar, construir e conviver, já chegou ao + lugar certo. + + Bem-vindo à runv.club. + diff --git a/terminal/templates/warning_public_key.txt b/terminal/templates/warning_public_key.txt @@ -0,0 +1,25 @@ + Aviso importante — chave SSH + ─────────────────────────── + + Você vai colar sua chave **pública**, a que costuma terminar em .pub ou que + começa por um tipo como: + + ssh-ed25519 + ssh-rsa + ecdsa-sha2-nistp256 + ecdsa-sha2-nistp384 + ecdsa-sha2-nistp521 + +%%PAGE%% + + **Nunca** cole aqui a chave **privada** (arquivos sem .pub, blocos que + começam por -----BEGIN … PRIVATE KEY-----, ou texto do PuTTY “Private key”). + Quem tiver a privada controla seu acesso. Trate-a como senha: só em + arquivos locais protegidos. + + Cole **uma única linha**, a mesma que colocaria em authorized_keys. + Sem quebras de linha no meio. + + Se tiver dúvida, pare e gere um par novo no seu computador antes de + continuar. + diff --git a/tools/README.md b/tools/README.md @@ -0,0 +1,63 @@ +# tools — experiência base runv.club (Debian) + +Módulo para **automatizar** no servidor Debian 13 (ou compatível): + +1. **Pacotes globais** via `apt` (lista em `manifests/apt_packages.txt`) — para todos os usuários, **sem** passar pelo `/etc/skel`. +2. **Comandos locais** em `/usr/local/bin`: `runv-help`, `runv-links`, `runv-status`. +3. **MOTD dinâmico** em `/etc/update-motd.d/60-runv` (arte ASCII verde, texto em português). +4. **Arquivos padrão** copiados para `/etc/skel/` (README, `.bash_aliases`, `public_html/index.html`) — **somente modelos de home**, nunca instaladores de sistema. + +## Regras + +- **`/etc/skel`** = apenas arquivos que **novas contas** recebem na home (via `adduser`). **Não** instala programas. +- **Programas** = sempre **`apt`** (globais). +- **Scripts do projeto** = **`/usr/local/bin`**. +- **MOTD** = script executável em **`/etc/update-motd.d/`**. +- Python **stdlib** apenas; **`subprocess` sem `shell=True`**; sem Docker, sem web, sem DB. + +## Execução rápida + +No servidor, a partir da raiz do repositório (ou com caminho absoluto): + +```bash +sudo python3 tools/tools.py +``` + +Simular sem alterar nada: + +```bash +sudo python3 tools/tools.py --dry-run --verbose +``` + +Sem `--force`, o script **atualiza** MOTD, `bin/` e skel quando o ficheiro no repositório **mudou** em relação ao destino. Para sobrescrever **sempre** (mesmo idêntico): + +```bash +sudo python3 tools/tools.py --force +``` + +Reaplicar só scripts/MOTD/skel **sem** rodar `apt`: + +```bash +sudo python3 tools/tools.py --skip-apt +``` + +## Conteúdo + +| Caminho | Função | +|---------|--------| +| `tools.py` | Orquestra apt, cópias e permissões | +| `manifests/apt_packages.txt` | Um pacote Debian por linha | +| `bin/` | Scripts shell instalados em `/usr/local/bin` | +| `motd/60-runv` | Fragmento MOTD (verde, pubnix) | +| `skel/` | Modelos copiados para `/etc/skel/` | +| `docs/` | Instalação, administração, experiência do usuário | + +## Byobu + +O pacote **byobu** é instalado **globalmente** com os demais, mas **não** é ativado automaticamente para todos os usuários. Quem quiser pode habilitar depois com **`byobu-enable`** na própria conta. Integrar isso ao fluxo de **provisionamento** (`create_runv_user` / onboarding) fica para uma etapa futura — não é papel deste módulo forçar Byobu no login. + +## Documentação + +- **[docs/INSTALL.md](docs/INSTALL.md)** — dependências, flags, verificação. +- **[docs/ADMIN.md](docs/ADMIN.md)** — operação e manutenção. +- **[docs/USER_EXPERIENCE.md](docs/USER_EXPERIENCE.md)** — o que o usuário vê e recebe. diff --git a/tools/bin/runv-help b/tools/bin/runv-help @@ -0,0 +1,67 @@ +#!/bin/sh +# runv.club — ajuda rápida para usuários do servidor +# +# Usar printf %b (não %s) para argumentos que contêm sequências ANSI (\033). + +R='\033[0m' +G='\033[0;32m' +C='\033[0;36m' +Y='\033[0;33m' +B='\033[1m' + +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" +printf '%b %brunv.club%b — ajuda rápida\n' "${B}" "${G}" "${R}" +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" +printf '\n' + +printf '%bO runv.club é um pubnix brasileiro:%b espaço shell Unix compartilhado,\n' "${C}" "${R}" +printf '%bpágina pessoal em %b~/public_html/%b e comunidade em torno de Linux e software livre.\n\n' "${C}" "${G}" "${R}" + +printf '%b%bComandos runv.club%b\n' "${Y}" "${B}" "${R}" +printf ' %brunv-help%b Esta mensagem (ajuda e boas práticas).\n' "${G}" "${R}" +printf ' %brunv-links%b Links do projeto, site e parceiros.\n' "${G}" "${R}" +printf ' %brunv-status%b Hostname, uptime, memória, disco, quem está online.\n' "${G}" "${R}" +printf '\n' + +printf '%b%bFerramentas instaladas no servidor%b (exemplos)\n' "${Y}" "${B}" "${R}" +printf ' %blynx%b Navegador web no terminal.\n' "${G}" "${R}" +printf ' %bcurl%b / %bwget%b Transferir ficheiros e páginas pela linha de comando.\n' "${G}" "${R}" "${G}" "${R}" +printf ' %bgit%b Controlo de versão.\n' "${G}" "${R}" +printf ' %bless%b Paginar ficheiros longos (ex.: less README.md).\n' "${G}" "${R}" +printf ' %btmux%b / %bbyobu%b Multiplexadores de terminal (várias sessões).\n' "${G}" "${R}" "${G}" "${R}" +printf ' %bmutt%b E-mail no terminal.\n' "${G}" "${R}" +printf ' %bweechat%b IRC no terminal.\n' "${G}" "${R}" +printf ' %btree%b Árvore de diretórios.\n' "${G}" "${R}" +printf ' %badventure%b Jogo de aventura (bsdgames).\n' "${G}" "${R}" +printf '\n' + +printf '%b%bSite pessoal (%b~/public_html/%b)%b\n' "${Y}" "${B}" "${C}" "${G}" "${R}" +printf ' • Coloque ficheiros %bHTML/CSS estáticos%b; o endereço público depende da configuração\n' "${C}" "${R}" +printf ' do servidor (pergunte aos administradores se não souber o URL).\n' +printf ' • %bchmod 755%b a sua home e %b~/public_html%b; %bchmod 644%b nos ficheiros do site.\n' "${C}" "${R}" "${C}" "${R}" "${C}" "${R}" +printf ' • Tudo dentro de %bpublic_html%b é %bvisível na web%b — não coloque chaves nem dados privados.\n' "${G}" "${R}" "${B}" "${R}" +printf '\n' + +printf '%b%bBoas práticas (máquina partilhada)%b\n' "${Y}" "${B}" "${R}" +printf ' • Use CPU, RAM e disco com moderação; evite processos pesados em background contínuo.\n' +printf ' • Não execute miners, scans agressivos nem actividades que prejudiquem outros utilizadores.\n' +printf ' • %bQuota e políticas%b podem ser aplicadas pelos administradores.\n' "${C}" "${R}" +printf '\n' + +printf '%b%bSegurança%b\n' "${Y}" "${B}" "${R}" +printf ' • Leia %b~/README.md%b após o primeiro login.\n' "${C}" "${R}" +printf ' • Chaves SSH: só a %bchave pública%b vai para pedidos de conta; nunca partilhe a privada.\n' "${C}" "${R}" +printf ' • Não reutilize senhas importantes; nunca envie credenciais por IRC ou e-mail em claro.\n' +printf '\n' + +printf '%b%bDicas para começar%b\n' "${Y}" "${B}" "${R}" +printf ' • Edite o site em %b~/public_html/index.html%b para começar.\n' "${C}" "${R}" +printf '\n' + +printf '%b%bAjuda e mais informações%b\n' "${Y}" "${B}" "${R}" +printf ' • %brunv-links%b — URLs úteis.\n' "${G}" "${R}" +printf ' • Site: %bhttps://runv.club%b\n' "${C}" "${R}" +printf ' • Dúvidas ou problemas: contacte os %badministradores%b do runv.club (canal ou e-mail indicados no site).\n' "${C}" "${R}" +printf '\n' + +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" diff --git a/tools/bin/runv-links b/tools/bin/runv-links @@ -0,0 +1,26 @@ +#!/bin/sh +# runv.club — links úteis +# +# Usar printf %b para argumentos com sequências ANSI (\033). + +R='\033[0m' +G='\033[0;32m' +C='\033[0;36m' +B='\033[1m' + +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" +printf '%b %bLinks úteis — runv.club%b\n' "${B}" "${G}" "${R}" +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" +printf '\n' + +printf '%b Comunidade e site%b\n' "${B}" "${R}" +printf ' %b•%b https://runv.club\n' "${C}" "${R}" +printf '\n' + +printf '%b Instituição mantenedora%b\n' "${B}" "${R}" +printf ' %b•%b https://portalidea.com.br\n' "${C}" "${R}" +printf '\n' + +printf '%b (Lista pode ser ampliada no futuro pelos administradores.)%b\n' "${G}" "${R}" +printf '\n' +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" diff --git a/tools/bin/runv-status b/tools/bin/runv-status @@ -0,0 +1,51 @@ +#!/bin/sh +# runv.club — status rápido do servidor +# +# Usar printf %b para argumentos com sequências ANSI (\033). + +R='\033[0m' +G='\033[0;32m' +C='\033[0;36m' +Y='\033[0;33m' +B='\033[1m' + +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" +printf '%b %brunv.club — status do servidor%b\n' "${B}" "${G}" "${R}" +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" +printf '\n' + +if command -v hostname >/dev/null 2>&1; then + printf '%bHostname:%b %s\n' "${B}" "${R}" "$(hostname)" +else + printf '%bHostname:%b (indisponível)\n' "${B}" "${R}" +fi + +if command -v uptime >/dev/null 2>&1; then + printf '%bUptime / carga:%b %s\n' "${B}" "${R}" "$(uptime 2>/dev/null || true)" +fi + +if command -v free >/dev/null 2>&1; then + printf '\n%bMemória:%b\n' "${B}" "${R}" + free -h 2>/dev/null || free 2>/dev/null || printf ' (indisponível)\n' +fi + +if command -v df >/dev/null 2>&1; then + printf '\n%bDisco (raiz e /home se existir):%b\n' "${B}" "${R}" + df -h / 2>/dev/null || true + if [ -d /home ]; then + df -h /home 2>/dev/null || true + fi +fi + +if command -v who >/dev/null 2>&1; then + printf '\n%bUsuários com sessão (who):%b\n' "${B}" "${R}" + wcnt=$(who 2>/dev/null | wc -l | tr -d ' ') + if [ "${wcnt:-0}" -gt 0 ] 2>/dev/null; then + who 2>/dev/null + else + printf ' (nenhuma sessão listada ou comando indisponível)\n' + fi +fi + +printf '\n%bAtalhos:%b runv-help · runv-links · runv-status\n' "${Y}" "${R}" +printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" diff --git a/tools/docs/ADMIN.md b/tools/docs/ADMIN.md @@ -0,0 +1,105 @@ +# Administração — módulo `tools/` + +Operação contínua do runv.club em **Debian**. + +## Atualizar a lista de pacotes + +1. Edite **`tools/manifests/apt_packages.txt`** (um pacote por linha; comentários com `#`). +2. No servidor: + +```bash +sudo python3 tools/tools.py --verbose +``` + +Use **`--skip-apt`** se quiser **não** rodar o apt nesta passada (por exemplo, durante janela de manutenção em que só atualiza arquivos). + +## Trocar textos do MOTD + +- Edite **`tools/motd/60-runv`** no repositório (shell `sh`, sem `figlet`). O logótipo **RUNV** usa as mesmas linhas UTF-8 que a landing e o `entre_app.py`; só esse bloco leva ANSI verde (`%b` + literais `\033`, não `echo -e`). +- Reaplique: + +```bash +sudo python3 tools/tools.py --force --skip-apt +``` + +(`--force` força cópia mesmo sem mudança no conteúdo; sem ele, basta alterar o ficheiro no repo e rodar `tools.py`.) + +**Boas práticas:** mantenha fallbacks (`command -v` / redirecionar stderr) para não quebrar o login se algum binário sumir. + +## Editar `runv-help`, `runv-links`, `runv-status` + +1. Altere os arquivos em **`tools/bin/`**. +2. Instale de novo: + +```bash +sudo python3 tools/tools.py --force --skip-apt +``` + +Confirme permissões **755** em `/usr/local/bin/`. + +## Reaplicar tudo com `tools.py` + +Instalação completa (apt + arquivos): + +```bash +sudo python3 tools/tools.py --force --verbose +``` + +Só arquivos (sem apt): + +```bash +sudo python3 tools/tools.py --force --skip-apt +``` + +## Remover um script + +O `tools.py` **não remove** arquivos do sistema. Para retirar, por exemplo, `runv-help`: + +```bash +sudo rm -f /usr/local/bin/runv-help +``` + +Para o MOTD: + +```bash +sudo rm -f /etc/update-motd.d/60-runv +``` + +Para modelos no skel (cuidado — afeta **novas** contas, não apaga homes existentes): + +```bash +sudo rm -f /etc/skel/README.md +# etc. +``` + +Depois, se quiser reinstalar só a partir do repositório: + +```bash +sudo python3 tools/tools.py --force --skip-apt +``` + +## Ajustar permissões manualmente + +Se algo ficou com modo errado: + +```bash +sudo chmod 755 /usr/local/bin/runv-help /usr/local/bin/runv-links /usr/local/bin/runv-status +sudo chmod 755 /etc/update-motd.d/60-runv +sudo chmod 644 /etc/skel/README.md /etc/skel/.bash_aliases /etc/skel/public_html/index.html +sudo chmod 755 /etc/skel/public_html +``` + +Dono típico: **root:root** (o script tenta `chown` após copiar). + +## Byobu + +- **Instalado** globalmente com o apt deste módulo. +- **Não** habilitado automaticamente para todos (evita surpresas no login). +- Usuários podem usar **`byobu-enable`** quando quiserem. +- Documentar ou automatizar no **onboarding** / `create_runv_user` é decisão futura — ver **`tools/README.md`**. + +## Idempotência + +- Rodar **`tools.py`** **sem `--force`** compara origem e destino: se forem **idênticos**, pula; se o repo tiver **versão nova**, copia e atualiza. +- **`apt-get install`** já é idempotente para pacotes instalados. +- Use **`--force`** para sobrescrever **sempre** (mesmo conteúdo igual), por exemplo para repor dono/permissões. diff --git a/tools/docs/INSTALL.md b/tools/docs/INSTALL.md @@ -0,0 +1,118 @@ +# Instalação — módulo `tools/` (runv.club) + +Guia em **português** para administradores. Ambiente alvo: **Debian 13** (ou Debian estável recente). + +## Dependências + +- **root** no servidor (sudo). +- **Python 3** do Debian (sem PyPI obrigatório). +- **`apt`** funcional (`apt-get`). +- Rede para `apt-get update` / `install` (ou espelho local configurado). + +Não é necessário Docker, banco de dados nem painel web. + +## O que o `tools.py` faz + +1. Valida execução como **root** (exceto em `--dry-run`, que só simula). +2. Lê **`manifests/apt_packages.txt`** (ignora linhas vazias e `#`). +3. Executa **`apt-get update -qq`** e **`apt-get install -y --no-install-recommends`** com esses pacotes. +4. Copia **`bin/runv-help`**, **`runv-links`**, **`runv-status`** → **`/usr/local/bin/`** com modo **755**. +5. Copia **`motd/60-runv`** → **`/etc/update-motd.d/60-runv`** com modo **755**. +6. Copia o **`skel/`** do repositório para **`/etc/skel/`**: + - `README.md` → **644** + - `.bash_aliases` → **644** + - `public_html/index.html` → diretório **`public_html` 755**, arquivo **644** + +Se o destino **já existir** e for **idêntico** (conteúdo byte-a-byte) à origem no repositório, a cópia é **ignorada**. Se o ficheiro no repo **mudou**, o `tools.py` **atualiza** o destino mesmo sem **`--force`**. Use **`--force`** para sobrescrever sempre (útil para repor permissões/mtime ou forçar cópia igual). + +## Execução + +```bash +cd /caminho/para/runv-server +sudo python3 tools/tools.py +``` + +### Flags + +| Flag | Efeito | +|------|--------| +| `--dry-run` | Não grava nem chama apt de verdade; mostra o que seria feito. | +| `--verbose` | Log detalhado no stderr. | +| `--force` | Sobrescreve sempre, mesmo quando origem e destino são idênticos. | +| `--skip-apt` | Pula `apt-get` (útil para atualizar só MOTD/bin/skel). | + +Exemplo seguro antes da primeira aplicação: + +```bash +sudo python3 tools/tools.py --dry-run --verbose +``` + +## Verificar pacotes instalados + +```bash +dpkg -l byobu tmux lynx weechat mutt bsdgames tree less curl wget git +``` + +Ou: + +```bash +apt list --installed 2>/dev/null | grep -E 'byobu|tmux|lynx|weechat|mutt|bsdgames|tree|less|curl|wget|git' +``` + +**Importante:** esses programas são **globais**. **Não** dependem do `/etc/skel`. Qualquer usuário com shell pode usá-los após a instalação (e após login, se o pacote estiver no `PATH`). + +## Verificar comandos em `/usr/local/bin` + +```bash +ls -l /usr/local/bin/runv-help /usr/local/bin/runv-links /usr/local/bin/runv-status +/usr/local/bin/runv-help +``` + +Devem ser executáveis (**`-rwxr-xr-x`**) e imprimir texto em português com cores. + +## Verificar MOTD + +O Debian monta o MOTD com scripts em `/etc/update-motd.d/`. Para testar **só** o fragmento runv: + +```bash +sudo chmod +x /etc/update-motd.d/60-runv # se ainda não estiver +/etc/update-motd.d/60-runv +``` + +Para ver a sequência completa (pode ser longa): + +```bash +run-parts /etc/update-motd.d/ +``` + +Em novo login SSH você deve ver o bloco **verde** com arte **RUNV**, a tagline, a lista de comandos úteis e a dica **“digite runv-help para começar”**. Estatísticas (data, uptime, memória, disco, sessões) estão em **`runv-status`**, não no MOTD. + +## Verificar `/etc/skel` + +```bash +ls -la /etc/skel/ +ls -la /etc/skel/public_html/ +``` + +Esperado: + +- `README.md` e `.bash_aliases` com permissões **644** (arquivos). +- `public_html` como diretório **755**. +- `public_html/index.html` **644**. + +Novas contas criadas com `adduser` **depois** desta instalação recebem esses arquivos na home (junto com o restante do skel padrão do Debian, como `.bashrc`, se existir no sistema). + +## Instruções de teste (checklist) + +1. **Dry-run:** `sudo python3 tools/tools.py --dry-run --verbose` — revisar saída. +2. **Aplicar:** `sudo python3 tools/tools.py --verbose`. +3. **Segunda execução** sem `--force` com repo **inalterado** — deve **pular** ficheiros já iguais; após **editar** MOTD/bin/skel no repo, a mesma execução deve **copiar de novo**. +4. **`runv-help` / `runv-links` / `runv-status`** — executar manualmente. +5. **MOTD:** rodar `/etc/update-motd.d/60-runv` ou novo login SSH. +6. **Skel:** criar usuário de teste com `adduser` e conferir `~usuario/README.md` e `~/public_html/index.html`. + +## Problemas comuns + +- **apt-get update falha:** corrija espelhos/rede; o script registra erro e ainda pode copiar bin/MOTD/skel. +- **Permissão negada:** execute com `sudo` / root. +- **MOTD não aparece:** em alguns setups o display do MOTD depende de `pam_motd` e SSH; confira configuração do `sshd` e PAM no Debian. diff --git a/tools/docs/USER_EXPERIENCE.md b/tools/docs/USER_EXPERIENCE.md @@ -0,0 +1,56 @@ +# Experiência do usuário — runv.club (`tools/`) + +Visão para **quem entra no servidor** pela primeira vez (e para quem documenta suporte). + +## O que aparece no login + +1. **MOTD** — O Debian executa os scripts em `/etc/update-motd.d/`. O fragmento **`60-runv`** mostra: + - logótipo **RUNV** (mesmo desenho UTF-8 da landing) **só nesse bloco** em verde; + - tagline `.club — um computador para compartilhar` (sem bloco de estatísticas no MOTD; use **`runv-status`** para data, uptime, memória, disco, sessões); + - lista curta de comandos (incluindo `lynx`, `tmux`, `byobu`, `mutt`, `weechat`, `adventure`); + - linha final: **digite `runv-help` para começar**. + +2. **Prompt da shell** — Depende do shell padrão (geralmente Bash no Debian). O que o usuário **herda** da home vem do **`/etc/skel`** no momento em que a conta foi criada. + +## Comandos locais do runv + +| Comando | Função | +|---------|--------| +| **`runv-help`** | Texto de ajuda: o que é o runv, comandos úteis, dicas, link do site. | +| **`runv-links`** | Links: runv.club, Portal IDEA, etc. | +| **`runv-status`** | Hostname, uptime, memória, disco, `who`, atalhos. | + +Todos são **shell scripts** em **`/usr/local/bin`**, com cores ANSI simples, texto em **português**. Não dependem de Python na sessão do usuário. + +## O que o usuário recebe na home (contas novas) + +Quando um administrador cria a conta com **`adduser`**, o Debian copia **`/etc/skel`** para a home. Depois de rodar o módulo **`tools/`**, o skel inclui: + +- **`README.md`** — explicação acolhedora: site em `~/public_html`, permissões, `runv-help`, aviso sobre arquivos públicos. +- **`.bash_aliases`** — atalhos (`ll`, `la`, `l`, `help-runv`). +- **`public_html/index.html`** — página inicial mínima em HTML estático (sem JS, sem CDN), em português. + +**Observação:** no Bash do Debian, o arquivo **`~/.bashrc`** costuma ter (por padrão) um bloco que carrega **`~/.bash_aliases`** se existir. Se o usuário remover esse trecho do `.bashrc`, os aliases deixam de carregar — isso é comportamento padrão do Debian, não do runv. + +## Programas globais (apt) + +Pacotes como **tmux**, **lynx**, **weechat**, **mutt**, **git**, **tree**, etc. ficam **instalados no sistema**. O usuário **não** precisa de nada no skel para **executá-los**: após o admin rodar `tools.py`, eles passam a existir em `/usr/bin` (ou caminhos padrão). Ou seja: + +- **Skel** ≠ instalar programas. +- **Skel** = arquivos iniciais na home. +- **apt** = programas para todos. + +## Byobu + +- Está **disponível** após a instalação dos pacotes. +- **Não** abre sozinho para todos no login. +- Quem quiser pode rodar **`byobu-enable`** na própria conta, quando fizer sentido. + +## Como isso ajuda iniciantes + +- **MOTD** orienta na hora do login (**`runv-help`**). +- **`README.md`** na home repete conceitos com calma (site, permissões). +- **`runv-links`** centraliza URLs oficiais. +- **`runv-status`** dá contexto do servidor sem precisar decorar comandos longos. + +Juntos, reduzem fricção para quem nunca usou pubnix ou SSH no dia a dia. diff --git a/tools/manifests/apt_packages.txt b/tools/manifests/apt_packages.txt @@ -0,0 +1,15 @@ +# Pacotes globais (apt) — runv.club / tools +# Um pacote por linha; linhas vazias e # são ignoradas. +# Instalados para todo o sistema; não passam pelo /etc/skel. + +byobu +tmux +lynx +weechat +mutt +bsdgames +tree +less +curl +wget +git diff --git a/tools/motd/60-runv b/tools/motd/60-runv @@ -0,0 +1,29 @@ +#!/bin/sh +# runv.club — MOTD dinâmico (Debian update-motd.d) +# +# RUNV em verde (ANSI); tagline e rodapé em texto normal — alinhado a +# site/public/index.html e terminal/entre_app.py (RUNV_ASCII_ART / ASCII_TAGLINE). +# Estatísticas do sistema ficam em runv-status, não no MOTD. +# +# Cores: cada sequência ANSI vai em argumento próprio com conversão %b (POSIX). +# Não guardar \033 em variável e depois usar %s — em alguns ambientes aparece literal. + +# Bloco RUNV (UTF-8) — só este trecho em verde brilhante +print_runv_art() { + while IFS= read -r line || [ -n "$line" ]; do + printf '%b%s%b\n' '\033[1;32m' "$line" '\033[0m' + done <<'RUNV_ART' +██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗ +██╔══██╗██║ ██║████╗ ██║██║ ██║ +██████╔╝██║ ██║██╔██╗ ██║██║ ██║ +██╔══██╗╚██╗ ██╔╝██║╚██╗██║╚██╗ ██╔╝ +██║ ██║ ╚████╔╝ ██║ ╚████║ ╚████╔╝ +╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═══╝ ╚═══╝ +RUNV_ART +} + +print_runv_art +printf '\n%s\n' '.club — um computador para compartilhar' + +printf '\n%s\n' 'Comandos úteis: runv-help · runv-links · runv-status · lynx · tmux · byobu · mutt · weechat · adventure' +printf '\n%s\n' '→ Digite runv-help para começar.' diff --git a/tools/setuptools/tools.txt b/tools/setuptools/tools.txt @@ -0,0 +1,9 @@ +# create_runv_user.py (ordem): 1) criar usuário (adduser + /etc/skel) 2) chave SSH +# 3) public_html 4) README runv após o skel 5) apply_runv_permissions +# +# Pacotes globais + MOTD + /usr/local/bin + /etc/skel: use o módulo tools/ +# sudo python3 tools/tools.py +# Lista oficial: tools/manifests/apt_packages.txt + +sudo apt update +sudo apt install -y byobu tmux lynx weechat mutt bsdgames tree less curl wget git +\ No newline at end of file diff --git a/tools/skel/.bash_aliases b/tools/skel/.bash_aliases @@ -0,0 +1,7 @@ +# runv.club — aliases sugeridos para novas contas (copiados do /etc/skel) +# Personalize à vontade: este arquivo é seu. + +alias ll='ls -lah' +alias la='ls -A' +alias l='ls -CF' +alias help-runv='runv-help' diff --git a/tools/skel/README.md b/tools/skel/README.md @@ -0,0 +1,59 @@ +# Bem-vindo(a) ao runv.club + +O **runv.club** é um servidor compartilhado (pubnix) pensado para a comunidade brasileira: você acessa por **SSH**, usa a **shell** e pode publicar uma **página web** simples. + +## Sua página na internet + +- Os arquivos públicos do site ficam em **`~/public_html/`**. +- A página principal é **`~/public_html/index.html`** (HTML estático). +- A URL pública será algo como **`https://runv.club/~seu_usuario/`** (o nome após `~` é o seu login). + +Edite o HTML com um editor no terminal, por exemplo: + +```bash +nano ~/public_html/index.html +``` + +## Permissões (importante) + +Para o servidor web enxergar seu site: + +| Local | Modo sugerido | +|-------|----------------| +| Sua home (`~`) | `755` | +| `~/public_html` | `755` | +| Arquivos dentro de `public_html` | `644` | + +Exemplo: + +```bash +chmod 755 ~ ~/public_html +chmod 644 ~/public_html/index.html +``` + +## Ajuda rápida no servidor + +Digite no terminal: + +```bash +runv-help +``` + +Você verá uma lista de **comandos úteis** (navegação no terminal, e-mail, IRC, jogos, etc.) e dicas para quem está começando. + +Outros comandos locais: + +- **`runv-links`** — links do projeto e do mantenedor. +- **`runv-status`** — hostname, uptime, memória, disco e quem está online. + +## Arquivos públicos + +Tudo o que você colocar em **`public_html`** pode ser lido pelo mundo via HTTP. **Não coloque** chaves privadas, senhas ou dados sensíveis nessa pasta. + +## Aliases + +Este diretório pode incluir um arquivo **`.bash_aliases`** (já sugerido no skel) com atalhos como `ll` e `help-runv`. Se o seu shell for Bash, ele costuma carregar aliases desse arquivo se a linha correspondente existir no `~/.bashrc` (no Debian isso costuma vir comentado — você pode descomentar). + +--- + +Seja gentil com a máquina e com a comunidade. Bom uso do runv.club. diff --git a/tools/skel/public_html/index.html b/tools/skel/public_html/index.html @@ -0,0 +1,48 @@ +<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Sua página no runv.club</title> + <style> + :root { color-scheme: dark light; } + body { + font-family: system-ui, "Segoe UI", Roboto, Ubuntu, sans-serif; + line-height: 1.55; + max-width: 40rem; + margin: 2rem auto; + padding: 0 1.25rem; + color: #1a1a1a; + background: #f4f2ee; + } + @media (prefers-color-scheme: dark) { + body { color: #e8e4dc; background: #121018; } + a { color: #7fd4a0; } + } + h1 { font-size: 1.45rem; margin-bottom: 0.5rem; } + .path, .url { font-family: ui-monospace, monospace; font-size: 0.92rem; } + a { color: #0d6b3a; } + </style> +</head> +<body> + <h1>Bem-vindo(a) ao runv.club</h1> + <p> + Esta é a sua página pessoal estática. Você pode editá-la direto no servidor. + </p> + <p> + <strong>Arquivo para editar:</strong> <span class="path">~/public_html/index.html</span> + </p> + <p> + Depois que sua conta estiver publicada na web, a URL seguirá o padrão + <span class="url">https://runv.club/~seu_usuario/</span> + (troque <em>seu_usuario</em> pelo seu nome de login). + </p> + <p> + Use HTML estático; evite colocar segredos aqui — tudo em <span class="path">public_html</span> + pode ser acessível publicamente. + </p> + <p> + No shell, digite <span class="path">runv-help</span> para dicas e comandos úteis. + </p> +</body> +</html> diff --git a/tools/tools.py b/tools/tools.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +runv.club — ferramentas globais, MOTD, comandos em /usr/local/bin e /etc/skel. + +Debian 13 · Python 3 stdlib apenas · sem shell=True. +Execute como root. Ver tools/README.md e tools/docs/INSTALL.md. +""" + +from __future__ import annotations + +import argparse +import filecmp +import logging +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +TOOL_ROOT: Path = Path(__file__).resolve().parent +MANIFEST_PATH: Path = TOOL_ROOT / "manifests" / "apt_packages.txt" +BIN_DIR: Path = TOOL_ROOT / "bin" +MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv" +SKEL_DIR: Path = TOOL_ROOT / "skel" + +DEST_BIN_DIR: Path = Path("/usr/local/bin") +DEST_MOTD: Path = Path("/etc/update-motd.d/60-runv") +DEST_SKEL: Path = Path("/etc/skel") + + +@dataclass +class RunSummary: + """Acumula ações para o resumo final.""" + + dry_run: bool = False + apt_updated: bool = False + apt_install_attempted: bool = False + packages_requested: list[str] = field(default_factory=list) + copied: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +def setup_logging(verbose: bool) -> logging.Logger: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(levelname)s: %(message)s", + ) + return logging.getLogger("runv-tools") + + +def require_root(log: logging.Logger) -> None: + if os.geteuid() != 0: + log.error("Este script precisa ser executado como root (sudo).") + sys.exit(1) + + +def run_subprocess( + cmd: list[str], + *, + dry_run: bool, + log: logging.Logger, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str] | None: + """Executa comando sem shell; em dry-run apenas registra.""" + log.debug("exec: %s", " ".join(cmd)) + if dry_run: + log.info("[dry-run] %s", " ".join(cmd)) + return None + e = os.environ.copy() + if env: + e.update(env) + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + env=e, + timeout=3600, + ) + + +def read_apt_manifest(path: Path, log: logging.Logger) -> list[str]: + if not path.is_file(): + log.error("Manifesto não encontrado: %s", path) + sys.exit(1) + packages: list[str] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + packages.append(line) + return packages + + +def install_apt_packages( + packages: list[str], + *, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + if not packages: + log.info("Nenhum pacote listado no manifesto; etapa apt ignorada.") + return + summary.packages_requested = list(packages) + env_apt = { + "DEBIAN_FRONTEND": "noninteractive", + "LC_ALL": "C", + } + log.info("Atualizando índice apt (apt-get update)...") + r = run_subprocess( + ["apt-get", "update", "-qq"], + dry_run=dry_run, + log=log, + env=env_apt, + ) + if dry_run: + summary.apt_updated = True + elif r is not None: + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + msg = f"apt-get update falhou (código {r.returncode})" + (f": {err}" if err else "") + summary.errors.append(msg) + log.error("%s", msg) + return + summary.apt_updated = True + + log.info("Instalando pacotes: %s", ", ".join(packages)) + summary.apt_install_attempted = True + cmd = ["apt-get", "install", "-y", "--no-install-recommends", *packages] + r = run_subprocess(cmd, dry_run=dry_run, log=log, env=env_apt) + if dry_run: + return + if r is None: + return + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + msg = f"apt-get install falhou (código {r.returncode})" + (f": {err}" if err else "") + summary.errors.append(msg) + log.error("%s", msg) + else: + log.info("Pacotes instalados ou já presentes (apt idempotente).") + + +def ensure_parent(path: Path, log: logging.Logger) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def copy_one( + src: Path, + dst: Path, + mode: int, + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + if not src.is_file(): + summary.errors.append(f"origem inexistente: {src}") + log.error("Origem inexistente: %s", src) + return + + def same_content() -> bool: + if not dst.is_file(): + return False + try: + return filecmp.cmp(src, dst, shallow=False) + except OSError: + return False + + # Sem --force: só pula se o ficheiro já for byte-a-byte igual à origem (reexecução actualiza mudanças do repo). + if not force and dst.exists() and same_content(): + log.info("Destino já coincide com a origem, pulando: %s", dst) + summary.skipped.append(str(dst)) + return + if dry_run: + log.info("[dry-run] copiaria %s -> %s (modo %o)", src, dst, mode) + summary.copied.append(f"{src} -> {dst} (simulado)") + return + ensure_parent(dst, log) + shutil.copy2(src, dst) + os.chmod(dst, mode) + try: + os.chown(dst, 0, 0) + except OSError as e: + log.warning("chown root:root em %s: %s", dst, e) + log.info("Instalado: %s", dst) + summary.copied.append(str(dst)) + + +def install_bin_scripts( + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + if not dry_run: + DEST_BIN_DIR.mkdir(parents=True, exist_ok=True) + for name in ("runv-help", "runv-links", "runv-status"): + copy_one( + BIN_DIR / name, + DEST_BIN_DIR / name, + 0o755, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + + +def install_motd( + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + copy_one( + MOTD_SRC, + DEST_MOTD, + 0o755, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + + +def install_skel( + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + """Copia apenas arquivos modelo; não instala pacotes.""" + if not dry_run: + DEST_SKEL.mkdir(parents=True, exist_ok=True) + + skel_files: list[tuple[Path, Path, int]] = [ + (SKEL_DIR / "README.md", DEST_SKEL / "README.md", 0o644), + (SKEL_DIR / ".bash_aliases", DEST_SKEL / ".bash_aliases", 0o644), + ] + for src, dst, mode in skel_files: + copy_one(src, dst, mode, force=force, dry_run=dry_run, log=log, summary=summary) + + pub_dir = DEST_SKEL / "public_html" + index_src = SKEL_DIR / "public_html" / "index.html" + index_dst = pub_dir / "index.html" + + if not index_src.is_file(): + summary.errors.append(f"origem inexistente: {index_src}") + log.error("Origem inexistente: %s", index_src) + return + + if not dry_run: + pub_dir.mkdir(parents=True, exist_ok=True) + os.chmod(pub_dir, 0o755) + try: + os.chown(pub_dir, 0, 0) + except OSError as e: + log.warning("chown em %s: %s", pub_dir, e) + elif not pub_dir.exists() and dry_run: + log.info("[dry-run] criaria diretório %s (755)", pub_dir) + + copy_one( + index_src, + index_dst, + 0o644, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + + if not dry_run and pub_dir.is_dir(): + os.chmod(pub_dir, 0o755) + try: + os.chown(pub_dir, 0, 0) + except OSError: + pass + + +def print_summary(summary: RunSummary, log: logging.Logger) -> None: + print() + print("========== runv-tools — resumo ==========") + if summary.dry_run: + print("Modo: DRY-RUN (nenhuma alteração no sistema)") + print(f"apt-get update executado/simulado: {summary.apt_updated}") + if summary.packages_requested: + print(f"Pacotes (manifesto): {', '.join(summary.packages_requested)}") + print(f"Instalação apt tentada/simulada: {summary.apt_install_attempted}") + if summary.copied: + print("Copiados / simulados:") + for c in summary.copied: + print(f" + {c}") + if summary.skipped: + print("Sem alteração (destino já idêntico à origem no repositório):") + for s in summary.skipped: + print(f" = {s}") + if summary.errors: + print("Erros:") + for e in summary.errors: + print(f" ! {e}") + print("==========================================") + sys.exit(1) + print("Concluído sem erros fatais registrados pelo script.") + print("==========================================") + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Instala pacotes globais, comandos runv, MOTD e arquivos em /etc/skel.", + ) + p.add_argument( + "--dry-run", + action="store_true", + help="não altera o sistema; mostra o que seria feito", + ) + p.add_argument( + "--verbose", + action="store_true", + help="log detalhado", + ) + p.add_argument( + "--force", + action="store_true", + help="sobrescreve sempre (mesmo conteúdo idêntico); sem isto, só copia se origem e destino diferirem", + ) + p.add_argument( + "--skip-apt", + action="store_true", + help="não executa apt-get (útil para reaplicar só arquivos/MOTD/skel)", + ) + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + log = setup_logging(args.verbose) + summary = RunSummary(dry_run=args.dry_run) + + if not args.dry_run: + require_root(log) + else: + log.info("Dry-run: validação de root ignorada (nada será gravado).") + + if not args.skip_apt: + pkgs = read_apt_manifest(MANIFEST_PATH, log) + install_apt_packages(pkgs, dry_run=args.dry_run, log=log, summary=summary) + else: + log.info("Etapa apt ignorada (--skip-apt).") + + log.info("Instalando scripts em %s", DEST_BIN_DIR) + install_bin_scripts(force=args.force, dry_run=args.dry_run, log=log, summary=summary) + + log.info("Instalando MOTD em %s", DEST_MOTD) + install_motd(force=args.force, dry_run=args.dry_run, log=log, summary=summary) + + log.info("Sincronizando skel em %s", DEST_SKEL) + install_skel(force=args.force, dry_run=args.dry_run, log=log, summary=summary) + + print_summary(summary, log) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())