commit e9b032eb093e3ac62215ebee9cbb22dd166361de
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 11:31:34 -0300
Initial commit
Made-with: Cursor
Diffstat:
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 **< 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 < 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())