runv-server

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

commit 042071438c6037ba87fcd6e341ff46d0c344860d
parent e357e9361dee0c77ae3ee7f149c5252f31f8195f
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Tue, 19 May 2026 20:42:03 -0300

Melhorias

Diffstat:
Mdocs/05-tools-and-system-experience.md | 4++--
Mdocs/08-email.md | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/14-smoke-tests-and-validation.md | 27+++++++++++++++++++++++++++
Adocs/17-community-commands.md | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/README.md | 5++++-
Adocs/review-email-aliases-signoff.md | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/setup_email_aliases.py | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/smoke_test_email_aliases.py | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-admin-email-alias | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-bulletin | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-email-alias | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-finger | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-profile | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/bin/runv-who | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/lib/runv_community.py | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/lib/runv_email_aliases.py | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/skel/.plan | 1+
Atools/skel/.project | 1+
Atools/skel/.runv/profile.json | 7+++++++
Mtools/tools.py | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
20 files changed, 2727 insertions(+), 4 deletions(-)

diff --git a/docs/05-tools-and-system-experience.md b/docs/05-tools-and-system-experience.md @@ -7,9 +7,9 @@ **Função:** orquestrar no servidor Debian: 1. Pacotes APT listados em `tools/manifests/apt_packages.txt` (alias `chat` → metapacote `weechat`). O manifesto inclui **`weechat-curses`** explicitamente porque `tools.py` usa `apt-get install --no-install-recommends`: sem isso, o metapacote `weechat` pode satisfazer-se **só** com `weechat-headless` e o comando `chat` deixa de encontrar cliente interactivo (`weechat` / `weechat-curses` no PATH). -2. Cópia de `tools/bin/` para `/usr/local/bin` (`runv-help`, `runv-links`, `runv-status`, `chat`, …). +2. Cópia de `tools/bin/` para `/usr/local/bin` (`runv-help`, `runv-links`, `runv-status`, `chat`, `runv-profile`, `runv-finger`, `runv-who`, `runv-bulletin`, …) e de `tools/lib/runv_community.py` para `/usr/local/share/runv/lib/`. 3. MOTD dinâmico: `tools/motd/60-runv` → `/etc/update-motd.d/60-runv`. -4. Modelos para novas contas: `tools/skel/` → `/etc/skel/`. +4. Modelos para novas contas: `tools/skel/` → `/etc/skel/` (inclui `.plan`, `.project`, `.runv/profile.json`). 5. Drop-in SSH para utilizadores jailed: `tools/sshd/90-runv-jailed.conf` → `/etc/ssh/sshd_config.d/`. 6. Sudo administrativo para `pmurad-admin`: `tools/sudoers/90-runv-pmurad-admin` → `/etc/sudoers.d/`. 7. Reconciliação do jail SSH em membros existentes via `scripts/admin/perm1.py`. diff --git a/docs/08-email.md b/docs/08-email.md @@ -25,6 +25,80 @@ - **Não** é MTA completo (não recebe correio para caixas locais de membros como produto deste repositório). +## Aliases de email para membros + +O email transacional da runv.club (Mailgun, `/etc/runv-email.json`) continua separado deste fluxo. + +Nesta etapa **não** há mailbox local nem caixa `@runv.club` no servidor. Um membro pode pedir um alias fixo: + +`username@runv.club` → email externo de destino + +O alias **não** é activado automaticamente: o membro pede no terminal, um admin aprova, e o registo fica em JSON local. Criar o encaminhamento real no provedor de email (Mailgun, DNS, etc.) continua a ser passo manual ou integração futura. + +### Membro + +```bash +runv-email-alias request usuario@example.org +runv-email-alias status +runv-email-alias cancel +``` + +O alias é sempre `username@runv.club` (username Unix do utilizador que corre o comando). Não é possível escolher outro nome de alias. + +### Admin + +```bash +sudo runv-admin-email-alias pending +sudo runv-admin-email-alias list +sudo runv-admin-email-alias approve pablo +sudo runv-admin-email-alias reject pablo --reason "email destino inválido" +``` + +### Setup inicial no servidor + +```bash +sudo python3 scripts/admin/setup_email_aliases.py +sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users +``` + +Depois instalar os comandos com `sudo python3 tools/tools.py` (ver [05-tools-and-system-experience.md](05-tools-and-system-experience.md)). + +### Ficheiros e permissões + +| Caminho | Função | +|---------|--------| +| `/var/lib/runv/email-aliases.json` | Aliases aprovados (activos) | +| `/var/lib/runv/email-aliases.lock` | Lock para escrita segura | +| `/var/lib/runv/email-alias-queue/` | Pedidos pendentes | +| `.../approved/`, `.../rejected/`, `.../cancelled/` | Histórico de pedidos | + +Permissões sugeridas após o setup: + +| Caminho | Modo | Dono:grupo | +|---------|------|------------| +| `/var/lib/runv/email-aliases.json` | 640 | root:runv-members | +| `/var/lib/runv/email-aliases.lock` | 660 | root:runv-members | +| `/var/lib/runv/email-alias-queue/` | 2770 | root:runv-members | + +Variáveis de ambiente para testes locais: `RUNV_EMAIL_ALIASES_PATH`, `RUNV_EMAIL_ALIASES_LOCK_PATH`, `RUNV_EMAIL_ALIAS_QUEUE_DIR`, `RUNV_EMAIL_ALIAS_DOMAIN`. + +Mais detalhe dos comandos: [17-community-commands.md](17-community-commands.md). + +### O que isto não faz + +- Não cria mailbox. +- Não recebe email. +- Não envia email (além do stack transacional já existente). +- Não configura DNS. +- Não configura SPF/DKIM/DMARC. +- Não configura Postfix/Dovecot. +- Não configura Mailgun para aliases de membros. +- Não activa encaminhamento real automaticamente. + +### Próximo passo futuro + +Um script como `runv-email-provider-sync` poderá ler `email-aliases.json` e aplicar aliases no provedor real. + ## Testes - Existem testes em `email/tests/` (ex.: `test_mailgun_client.py`). Ver [14-smoke-tests-and-validation.md](14-smoke-tests-and-validation.md). diff --git a/docs/14-smoke-tests-and-validation.md b/docs/14-smoke-tests-and-validation.md @@ -38,6 +38,33 @@ Vários scripts importam `fcntl` ou `grp` — **não executáveis** em Windows t Em **Debian:** correr os `--help` acima e guardar a saída para operadores. Confirmar que `site/genlanding.py --help` lista **`--sync-public-only`**. +## Aliases de email para membros (Linux) + +Revisão estática (qualquer OS): + +```bash +python3 -m compileall -q tools scripts/admin/setup_email_aliases.py scripts/admin/smoke_test_email_aliases.py +python3 tools/bin/runv-email-alias --help +python3 tools/bin/runv-admin-email-alias --help +``` + +Smoke test integrado (VPS ou WSL; usa diretório temporário por defeito): + +```bash +cd REPO +sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO_TESTE +``` + +Setup + instalação em produção (antes do smoke com paths reais): + +```bash +sudo python3 scripts/admin/setup_email_aliases.py --verbose +sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users +cd tools && sudo python3 tools.py --verbose +``` + +Ver também [08-email.md](08-email.md) e [17-community-commands.md](17-community-commands.md). Registo de revisão: [review-email-aliases-signoff.md](review-email-aliases-signoff.md). + ## O que **não** existe no repo (facto) - **Sem** workflows `.github/workflows` na raiz do projecto runv (verificado por ausência de `.github/` no clone típico). diff --git a/docs/17-community-commands.md b/docs/17-community-commands.md @@ -0,0 +1,296 @@ +# Comandos comunitários + +[← Índice](README.md) + +## Visão geral + +Estes comandos dão mais vida pubnix ao servidor runv.club: + +- **perfil local** (`runv-profile`) — ficheiros em `~/.runv/profile.json`, `~/.plan` e `~/.project`; +- **finger moderno** (`runv-finger`) — ver o perfil público de outro membro; +- **listagem de membros** (`runv-who`) — quem está na comunidade e sinais de actividade; +- **mural comunitário** (`runv-bulletin`) — mensagens curtas partilhadas no terminal. + +São instalados por [`tools/tools.py`](../tools/tools.py) em `/usr/local/bin` (junto com `runv-help`, `chat`, etc.). A biblioteca partilhada fica em `/usr/local/share/runv/lib/runv_community.py`. + +Não expõem email, chave pública nem fingerprint de `/var/lib/runv/users.json`. + +## `runv-profile` + +### O que faz + +Gerencia o perfil local público: + +- `~/.runv/profile.json` +- `~/.plan` +- `~/.project` + +### Exemplos + +```bash +runv-profile init +runv-profile show +runv-profile path +``` + +### Arquivos criados + +| Caminho | Descrição | +|---------|-----------| +| `~/.runv/profile.json` | Nome, bio, local, links, interesses | +| `~/.plan` | Plano actual (texto livre) | +| `~/.project` | Projecto actual (texto livre) | + +### Permissões esperadas + +| Caminho | Modo | +|---------|------| +| `~/.runv` | `755` | +| `~/.runv/profile.json` | `644` | +| `~/.plan` | `644` | +| `~/.project` | `644` | + +### Observações + +- `init` **não sobrescreve** ficheiros existentes. +- Não guarde dados sensíveis no perfil (são legíveis por outros membros via `runv-finger`). +- Contas antigas sem estes ficheiros podem correr `runv-profile init` uma vez. + +--- + +## `runv-finger` + +### O que faz + +Mostra o perfil público de outro membro (estilo `finger`). + +### Exemplo + +```bash +runv-finger pablo +``` + +### Dados exibidos + +- `~/.runv/profile.json` (campos públicos) +- `~/.plan` +- `~/.project` +- existência e última actualização de `~/public_html/index.html` (como `Home: /~USER/`) + +### Segurança + +- Não mostra email. +- Não mostra chave pública. +- Não mostra fingerprint. +- Cada ficheiro é lido com limite de **16 KiB**. + +--- + +## `runv-who` + +### O que faz + +Lista membros da runv.club com indícios de actividade (homepage, `.plan`, `.project`). + +### Exemplos + +```bash +runv-who +runv-who --active +runv-who --limit 20 +runv-who --json +``` + +### Fontes de dados + +**Preferencial:** `/var/lib/runv/users.json` (apenas usernames; formatos suportados: lista de objectos com `username`, objecto com chaves = usernames, ou `{ "users": [ ... ] }`). + +**Fallback:** directórios em `/home/` cujo nome passa na regex de username. + +Se `users.json` existir mas for inválido, aparece um aviso e usa-se `/home`. + +### Campos exibidos + +| Campo | Significado | +|-------|-------------| +| `username` | Nome Unix | +| `homepage` | Sempre `/~USER/` | +| `has_homepage` | Existe `~/public_html/index.html` | +| `homepage_mtime` | ISO UTC da última modificação da homepage, ou `null` | +| `has_plan` | `.plan` existe e não está vazio | +| `has_project` | `.project` existe e não está vazio | + +### Ordenação + +1. Membros com homepage, por data da homepage (mais recente primeiro). +2. Membros sem homepage, por ordem alfabética. + +Com `--active`, só entram quem tem homepage **ou** `.plan` **ou** `.project`. + +### JSON + +`--json` imprime um array JSON só com os campos acima — útil para integração futura com site, Garden, Gotchi ou outros scripts. + +--- + +## `runv-bulletin` + +### O que faz + +Mural comunitário simples em terminal (uma linha JSON por post). + +### Exemplos + +```bash +runv-bulletin +runv-bulletin list +runv-bulletin post "Hoje configurei meu gopher" +runv-bulletin --limit 10 +runv-bulletin --json +``` + +Sem subcomando, equivale a `list`. + +### Arquivos usados + +| Caminho | Função | +|---------|--------| +| `/var/lib/runv/bulletin/posts.ndjson` | Posts (NDJSON) | +| `/var/lib/runv/bulletin/posts.lock` | Lock `flock` em escritas | + +Testes locais: + +```bash +export RUNV_BULLETIN_PATH=/tmp/runv-bulletin/posts.ndjson +``` + +### Formato + +Uma linha JSON por post, por exemplo: + +```json +{"id":"20260519T120000Z-pablo-a1b2c3","username":"pablo","created_at":"2026-05-19T12:00:00Z","body":"Hoje configurei meu gopher"} +``` + +O username em `post` vem sempre do utilizador Unix actual (`getpwuid`); não se aceita username por argumento. + +### Permissões + +O directório global precisa permitir escrita pelos membros. Sugestão operacional (ajuste o grupo se o vosso não for `runv`): + +```bash +sudo mkdir -p /var/lib/runv/bulletin +sudo touch /var/lib/runv/bulletin/posts.ndjson +sudo touch /var/lib/runv/bulletin/posts.lock +sudo chgrp -R runv /var/lib/runv/bulletin +sudo chmod 2775 /var/lib/runv/bulletin +sudo chmod 664 /var/lib/runv/bulletin/posts.ndjson /var/lib/runv/bulletin/posts.lock +``` + +Sem permissão de escrita, `post` mostra mensagem clara — **não** há fallback para `/tmp`. + +--- + +## Instalação + +No servidor (clone em `REPO`): + +```bash +cd REPO/tools +sudo python3 tools.py --dry-run --verbose +sudo python3 tools.py +``` + +Verificar: + +```bash +which runv-profile runv-finger runv-who runv-bulletin runv-email-alias runv-admin-email-alias +ls -l /usr/local/share/runv/lib/runv_community.py /usr/local/share/runv/lib/runv_email_aliases.py +``` + +Novas contas recebem modelos em `/etc/skel` (`.plan`, `.project`, `.runv/profile.json`) após `tools.py`. + +--- + +## Testes manuais rápidos + +```bash +runv-profile init +runv-profile show +runv-finger "$USER" +runv-who +runv-who --json + +mkdir -p /tmp/runv-bulletin-test +export RUNV_BULLETIN_PATH=/tmp/runv-bulletin-test/posts.ndjson +runv-bulletin post "primeiro teste do mural" +runv-bulletin +runv-bulletin --json +``` + +Sintaxe Python (repo): + +```bash +cd REPO +python3 -m compileall -q tools +``` + +--- + +## `runv-email-alias` + +### O que faz + +Permite pedir um alias de email fixo `username@runv.club` que, após aprovação admin, deve encaminhar para um email externo. + +- Não cria mailbox no servidor. +- Não activa o encaminhamento automaticamente. +- O membro só indica o email de destino; o nome do alias segue o username Unix. + +### Exemplos + +```bash +runv-email-alias request usuario@example.org +runv-email-alias status +runv-email-alias cancel +``` + +Política, ficheiros em `/var/lib/runv/` e setup: [08-email.md](08-email.md). + +--- + +## `runv-admin-email-alias` + +### O que faz + +Comando **root** para listar pedidos, aprovar ou rejeitar aliases, e actualizar `email-aliases.json` localmente. Não chama Mailgun nem configura DNS. + +### Exemplos + +```bash +sudo runv-admin-email-alias pending +sudo runv-admin-email-alias list +sudo runv-admin-email-alias approve pablo +sudo runv-admin-email-alias reject pablo --reason "email destino inválido" +``` + +Setup inicial da fila e permissões: + +```bash +sudo python3 scripts/admin/setup_email_aliases.py +sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users +``` + +--- + +## Próximos passos futuros + +Possíveis evoluções (fora do âmbito actual): + +- `runv-admin bulletin hide/delete` (moderação); +- feed público do mural no site; +- integração com MOTD; +- integração com Garden / Gotchi; +- backfill admin para membros existentes. + +Próximo: [15-glossary-and-reference.md](15-glossary-and-reference.md). diff --git a/docs/README.md b/docs/README.md @@ -20,7 +20,8 @@ 14. [Resolução de problemas](13-troubleshooting.md) 15. [Smoke tests](14-smoke-tests-and-validation.md) 16. [Reparar usuários](16-repair-users.md) -17. [Glossário e referência](15-glossary-and-reference.md) +17. [Comandos comunitários](17-community-commands.md) +18. [Glossário e referência](15-glossary-and-reference.md) ## Mapa rápido @@ -33,7 +34,9 @@ | Pedidos SSH `entre` | [09-terminal-entre.md](09-terminal-entre.md) | | Criar conta membro | [10-user-provisioning-and-admin-ops.md](10-user-provisioning-and-admin-ops.md) | | Reparar home incompleta / `Index of /~USER` | [16-repair-users.md](16-repair-users.md) | +| Usar comandos sociais pubnix (`runv-who`, `runv-finger`, mural) | [17-community-commands.md](17-community-commands.md) | | Email Mailgun / legado | [08-email.md](08-email.md) | +| Solicitar alias `usuario@runv.club` | [08-email.md](08-email.md), [17-community-commands.md](17-community-commands.md) | ## Diagramas (Mermaid) diff --git a/docs/review-email-aliases-signoff.md b/docs/review-email-aliases-signoff.md @@ -0,0 +1,93 @@ +# Revisão: aliases de email (Parte 2) + +[← Índice](README.md) + +Registo da revisão de código e validação. Atualizar a secção **VPS** após correr o smoke test no servidor. + +| Campo | Valor | +|-------|--------| +| Data | 2026-05-19 | +| Commit | `e357e93` (ajustar após `git rev-parse HEAD` no deploy) | +| Revisor | Automatizado + checklist manual | + +## Fase 1 — Estática (Windows / clone) + +| Item | Resultado | Notas | +|------|-----------|--------| +| 1.1 Sem Mailgun/Postfix/DNS no código `tools/` | **PASS** | Grep sem matches | +| 1.1 Alias fixo `username@domínio` | **PASS** | `alias_address()` | +| 1.1 Usernames reservados | **PASS** | `ALIAS_RESERVED_USERNAMES` completo | +| 1.1 Validação destino | **PASS** | Script Python com 6 casos | +| 1.1 `O_EXCL` em pedidos | **PASS** | `create_pending_request()` | +| 1.1 Arquivo sem apagar | **PASS** | `archive_request()` | +| 1.1 Admin root | **PASS** | `require_root()` após `parse_args` | +| 1.1 Lock + escrita atómica | **PASS** | `approve_pending()` | +| 1.1 Setup idempotente | **PASS** | `setup_email_aliases.py` | +| 1.2 Sem leitura `users.json` nos bins membro | **PASS** | Só setup `--add-existing-users` | +| 1.3 `compileall` | **PASS** | | +| 1.3 `--help` (3 entrypoints) | **PASS** | | +| 1.3 `setup --dry-run` | **PASS** | | + +### Correcções aplicadas na revisão + +- Re-validação de `destination` em `approve_pending()` antes de gravar alias activo. +- Rejeição de `--reason` vazio em `runv-admin-email-alias reject`. +- Script [`scripts/admin/smoke_test_email_aliases.py`](../scripts/admin/smoke_test_email_aliases.py) para VPS/WSL. +- Secção em [14-smoke-tests-and-validation.md](14-smoke-tests-and-validation.md). + +## Fase 2 — VPS Linux + +### 2.3–2.5 Lógica (WSL, modo direct, temp dir) + +| Secção | Resultado | Notas | +|--------|-----------|--------| +| 2.3 E2E request → approve → list | **PASS** | `smoke_test_email_aliases.py --user pablo` em WSL | +| 2.4 Validações / cancel / reject | **PASS** | mesmo script | +| 2.5 Alteração destino | **PASS** | `created_at` preservado | + +### 2.2 / bins instalados (executar na VPS) + +```bash +cd /caminho/para/runv-server +git pull +COMMIT=$(git rev-parse --short HEAD) +echo "Testando commit $COMMIT" + +sudo python3 scripts/admin/setup_email_aliases.py --verbose +sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users +cd tools && sudo python3 tools.py --verbose + +sudo python3 scripts/admin/smoke_test_email_aliases.py --user SEU_MEMBRO_TESTE +``` + +| Secção | Resultado | Data / notas | +|--------|-----------|----------------| +| 2.2 Setup + instalação | _pendente VPS_ | `which`, `ls -la /var/lib/runv/email-*` | +| 2.2 Smoke subprocess (bins reais) | _pendente VPS_ | sem `--direct`; exige `sudo` | +| 2.6 Regressão Parte 1 | _pendente VPS_ | `runv-profile`, `runv-who`, etc. | +| 2.7 Docs vs saída real | **PASS** | revisão estática 08 + 17 | + +### Permissões esperadas (2.2) + +```text +/var/lib/runv/email-aliases.json 640 root:runv-members +/var/lib/runv/email-aliases.lock 660 root:runv-members +/var/lib/runv/email-alias-queue/ 2770 root:runv-members +``` + +## Critérios finais + +- [x] Fase 1 sem bloqueantes +- [x] Fase 2.3–2.5 lógica validada (WSL + smoke script) +- [ ] Fase 2.2 + smoke **subprocess** na VPS Debian (operador) +- [x] Nenhum código envia email ou configura Mailgun/DNS para aliases +- [x] Documentação 08 + 17 alinhada com implementação +- [ ] Operador confirmou passo manual pós-`approve` no provedor real + +## Comando único recomendado na VPS + +```bash +sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO +``` + +Saída esperada final: `Smoke test aliases de email: PASS` diff --git a/scripts/admin/setup_email_aliases.py b/scripts/admin/setup_email_aliases.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +Prepara diretórios, permissões e grupo para pedidos de alias de email runv.club. + +Não configura Postfix, Dovecot, Mailgun nem DNS. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import subprocess +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_TOOLS_LIB = _SCRIPT_DIR.parent.parent / "tools" / "lib" +if str(_REPO_TOOLS_LIB) not in sys.path: + sys.path.insert(0, str(_REPO_TOOLS_LIB)) + +import runv_community as rc # noqa: E402 + +DEFAULT_GROUP = "runv-members" +VAR_LIB_RUNV = Path("/var/lib/runv") +ALIASES_JSON = VAR_LIB_RUNV / "email-aliases.json" +ALIASES_LOCK = VAR_LIB_RUNV / "email-aliases.lock" +QUEUE_DIR = VAR_LIB_RUNV / "email-alias-queue" +USERS_JSON = VAR_LIB_RUNV / "users.json" + + +def _run(cmd: list[str], *, dry_run: bool, log: logging.Logger) -> subprocess.CompletedProcess[str]: + log.info("exec: %s", " ".join(cmd)) + if dry_run: + return subprocess.CompletedProcess(cmd, 0, "", "") + return subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + +def require_root(dry_run: bool) -> None: + if dry_run: + return + geteuid = getattr(os, "geteuid", None) + if geteuid is None or geteuid() != 0: + print("este script precisa ser executado como root (ou use --dry-run).", file=sys.stderr) + raise SystemExit(1) + + +def group_exists(name: str) -> bool: + try: + import grp + except ModuleNotFoundError: + return False + try: + grp.getgrnam(name) + return True + except KeyError: + return False + + +def ensure_group(name: str, *, dry_run: bool, log: logging.Logger) -> None: + if dry_run: + print(f"[dry-run] groupadd {name} (se não existir)") + return + if group_exists(name): + log.info("grupo %s já existe", name) + return + r = _run(["groupadd", name], dry_run=dry_run, log=log) + if dry_run: + print(f"[dry-run] groupadd {name}") + return + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + print(f"groupadd {name} falhou: {err}", file=sys.stderr) + raise SystemExit(1) + log.info("grupo %s criado", name) + + +def ensure_dir(path: Path, mode: int, *, dry_run: bool, log: logging.Logger) -> None: + if dry_run: + print(f"[dry-run] mkdir {path} mode {oct(mode)}") + return + path.mkdir(parents=True, exist_ok=True) + os.chmod(path, mode) + log.info("directório %s (%s)", path, oct(mode)) + + +def ensure_file(path: Path, default_content: str, mode: int, *, dry_run: bool, log: logging.Logger) -> None: + if dry_run: + action = "criar" if not path.is_file() else "manter" + print(f"[dry-run] {action} {path}") + return + path.parent.mkdir(parents=True, exist_ok=True) + if not path.is_file(): + path.write_text(default_content, encoding="utf-8") + log.info("ficheiro criado: %s", path) + else: + raw = path.read_text(encoding="utf-8").strip() + if not raw: + path.write_text(default_content, encoding="utf-8") + log.info("ficheiro inicializado: %s", path) + else: + log.info("ficheiro existente preservado: %s", path) + os.chmod(path, mode) + + +def chown_path(path: Path, user: str, group: str, *, dry_run: bool, log: logging.Logger) -> None: + if dry_run: + print(f"[dry-run] chown {user}:{group} {path}") + return + import grp + import pwd + + try: + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group).gr_gid + os.chown(path, uid, gid) + log.info("chown %s:%s %s", user, group, path) + except (KeyError, OSError) as e: + log.warning("não foi possível chown %s: %s", path, e) + + +def apply_permissions(group: str, *, dry_run: bool, log: logging.Logger) -> None: + ensure_dir(VAR_LIB_RUNV, 0o755, dry_run=dry_run, log=log) + if not dry_run: + try: + os.chown(VAR_LIB_RUNV, 0, 0) + except OSError: + pass + + ensure_file(ALIASES_JSON, "{}\n", 0o640, dry_run=dry_run, log=log) + ensure_file(ALIASES_LOCK, "", 0o660, dry_run=dry_run, log=log) + + for sub in ("", "approved", "rejected", "cancelled"): + d = QUEUE_DIR if not sub else QUEUE_DIR / sub + ensure_dir(d, 0o2770, dry_run=dry_run, log=log) + + if dry_run: + print(f"[dry-run] chown root:{group} em aliases e fila") + return + + chown_path(ALIASES_JSON, "root", group, dry_run=False, log=log) + chown_path(ALIASES_LOCK, "root", group, dry_run=False, log=log) + for sub in ("", "approved", "rejected", "cancelled"): + d = QUEUE_DIR if not sub else QUEUE_DIR / sub + chown_path(d, "root", group, dry_run=False, log=log) + + +def add_existing_users(group: str, *, dry_run: bool, log: logging.Logger) -> None: + if not USERS_JSON.is_file(): + print(f"aviso: {USERS_JSON} não encontrado; --add-existing-users ignorado.") + return + names, warning = rc.load_member_usernames(USERS_JSON, rc.DEFAULT_HOME_ROOT) + if warning: + print(warning) + if not names: + print("aviso: nenhum username encontrado em users.json.") + return + import pwd + + for username in names: + if dry_run: + print(f"[dry-run] usermod -aG {group} {username}") + continue + try: + pwd.getpwnam(username) + except KeyError: + print(f"aviso: utilizador Unix {username!r} não existe; ignorado.") + continue + r = _run(["usermod", "-aG", group, username], dry_run=False, log=log) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + print(f"aviso: usermod -aG {group} {username}: {err}") + else: + log.info("utilizador %s adicionado ao grupo %s", username, group) + + +def print_final_instructions(repo_root: Path) -> None: + tools_dir = repo_root / "tools" + print() + print("Setup de aliases de email concluído.\n") + print("Para instalar comandos:") + print(f" cd {tools_dir}") + print(" sudo python3 tools.py\n") + print("Para testar como utilizador:") + print(" runv-email-alias request seu-email@exemplo.com") + print(" runv-email-alias status\n") + print("Para admin:") + print(" sudo runv-admin-email-alias pending") + print(" sudo runv-admin-email-alias approve USER\n") + print( + "Se o servidor não usar o grupo runv-members, pode escolher outro com --group NOME." + ) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Prepara /var/lib/runv para pedidos de alias de email (sem MTA).", + ) + p.add_argument( + "--dry-run", + action="store_true", + help="mostrar acções sem alterar o sistema", + ) + p.add_argument( + "--group", + default=DEFAULT_GROUP, + metavar="NOME", + help=f"grupo Unix com acesso à fila (padrão: {DEFAULT_GROUP})", + ) + p.add_argument( + "--add-existing-users", + action="store_true", + help="adicionar usernames de /var/lib/runv/users.json ao grupo", + ) + p.add_argument("-v", "--verbose", action="store_true", help="mais detalhe no log") + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s: %(message)s", + ) + log = logging.getLogger("setup_email_aliases") + require_root(bool(args.dry_run)) + group = args.group.strip() + if not group: + print("nome de grupo inválido.", file=sys.stderr) + return 1 + + ensure_group(group, dry_run=bool(args.dry_run), log=log) + apply_permissions(group, dry_run=bool(args.dry_run), log=log) + if args.add_existing_users: + add_existing_users(group, dry_run=bool(args.dry_run), log=log) + + repo_root = _SCRIPT_DIR.parent.parent + if not args.dry_run: + print_final_instructions(repo_root) + else: + print("\n[dry-run] nenhuma alteração aplicada.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/smoke_test_email_aliases.py b/scripts/admin/smoke_test_email_aliases.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +""" +Smoke test dos aliases de email runv.club (Linux). + +Por defeito usa diretório temporário. Modo --direct chama a biblioteca in-process +(útil em dev/WSL sem sudo). Na VPS use sudo sem --direct para testar os bins. + + sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from collections.abc import Callable +from contextlib import contextmanager +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +BIN_EMAIL = REPO_ROOT / "tools" / "bin" / "runv-email-alias" +BIN_ADMIN = REPO_ROOT / "tools" / "bin" / "runv-admin-email-alias" + + +def fail(msg: str) -> None: + print(f"FAIL: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def ok(msg: str) -> None: + print(f"OK: {msg}") + + +@contextmanager +def push_env(updates: dict[str, str]): + old: dict[str, str | None] = {} + for key, val in updates.items(): + old[key] = os.environ.get(key) + os.environ[key] = val + try: + yield + finally: + for key, prev in old.items(): + if prev is None: + os.environ.pop(key, None) + else: + os.environ[key] = prev + + +def run_cmd( + cmd: list[str], + *, + env: dict[str, str], + as_root: bool = False, + as_user: str | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + full = list(cmd) + if as_user: + full = ["sudo", "-n", "-u", as_user, "-E"] + full + elif as_root and os.geteuid() != 0: + full = ["sudo", "-n", "-E"] + full + proc = subprocess.run( + full, + env=env, + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + timeout=60, + ) + if check and proc.returncode != 0: + fail( + f"{' '.join(full)}\nstdout={proc.stdout!r}\nstderr={proc.stderr!r}" + ) + return proc + + +def expect_exit(fn: Callable[[], object]) -> bool: + try: + fn() + except SystemExit: + return True + return False + + +def main() -> int: + if sys.platform == "win32": + print( + "Este smoke test requer Linux (pwd/grp/fcntl). " + "Execute na VPS runv ou em WSL.", + file=sys.stderr, + ) + return 2 + + p = argparse.ArgumentParser(description="Smoke test aliases de email") + p.add_argument( + "--production", + action="store_true", + help="usar paths em /var/lib/runv (cuidado: altera estado real)", + ) + p.add_argument( + "--direct", + action="store_true", + help="chamar runv_email_aliases in-process (temp dir; sem testar bins admin root)", + ) + p.add_argument( + "--user", + default="", + help="username Unix para pedidos", + ) + args = p.parse_args() + if args.production and args.direct: + fail("--production e --direct são incompatíveis") + + username = args.user.strip() or os.environ.get("SUDO_USER", "").strip() + if not username: + username = os.environ.get("USER", "").strip() + if not username: + fail("defina --user ou execute com USER/SUDO_USER definido") + if os.geteuid() == 0 and not args.user and not os.environ.get("SUDO_USER"): + fail("como root directo, use --user MEMBRO (não reservado)") + + sys.path.insert(0, str(REPO_ROOT / "tools" / "lib")) + import runv_email_aliases as ea # noqa: E402 + + try: + ea.validate_alias_username(username) + except SystemExit: + fail(f"username {username!r} inválido ou reservado para alias") + + if args.production: + queue = Path("/var/lib/runv/email-alias-queue") + aliases = Path("/var/lib/runv/email-aliases.json") + lock = Path("/var/lib/runv/email-aliases.lock") + tmp_ctx = None + else: + tmp_ctx = tempfile.TemporaryDirectory(prefix="runv-email-smoke-") + base = Path(tmp_ctx.name) + queue = base / "queue" + aliases = base / "email-aliases.json" + lock = base / "email-aliases.lock" + queue.mkdir(parents=True, exist_ok=True) + for sub in ("approved", "rejected", "cancelled"): + (queue / sub).mkdir(parents=True, exist_ok=True) + aliases.write_text("{}\n", encoding="utf-8") + lock.touch() + + env = { + "RUNV_EMAIL_ALIAS_QUEUE_DIR": str(queue), + "RUNV_EMAIL_ALIASES_PATH": str(aliases), + "RUNV_EMAIL_ALIASES_LOCK_PATH": str(lock), + } + + use_direct = bool(args.direct) or (tmp_ctx is not None and os.geteuid() != 0) + if use_direct and not args.direct: + print( + "aviso: sem root; a usar modo direct (biblioteca). " + "Na VPS: sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO", + file=sys.stderr, + ) + + with push_env(env): + if use_direct: + _run_direct(username, queue, aliases) + else: + _run_subprocess(env, username, queue, aliases) + + if tmp_ctx is not None: + tmp_ctx.cleanup() + + print("\nSmoke test aliases de email: PASS") + return 0 + + +def _run_direct(username: str, queue: Path, aliases: Path) -> None: + import runv_email_aliases as ea + + for dest in ("foo", "x@runv.club"): + if not expect_exit(lambda d=dest: ea.validate_destination_email(d)): + fail(f"validate {dest!r} deveria falhar") + ok("validações de destino inválido rejeitadas") + + dest_ok = "smoke-alias-test@example.org" + ea.create_pending_request(username, dest_ok) + ok("request criado") + + if ea.find_pending_for_user(username) is None: + fail("pending não encontrado") + if not expect_exit(lambda: ea.create_pending_request(username, dest_ok)): + fail("segundo request deveria falhar") + ok("duplo pending bloqueado") + + entry = ea.approve_pending(username, "smoke-test") + ok("approve") + + rows = ea.list_active_aliases() + if not any(r[2] == dest_ok for r in rows): + fail(f"list_active sem {dest_ok!r}") + ok("list") + + if ea.get_active_alias(username) is None: + fail("alias activo ausente após approve") + ok("status active") + + data = json.loads(aliases.read_text(encoding="utf-8")) + created_at = data[username].get("created_at") + if not any((queue / "approved").glob("*.json")): + fail("nenhum pedido em approved/") + ok("pedido arquivado em approved/") + + dest2 = "smoke-alias-test2@example.org" + ea.create_pending_request(username, dest2) + ea.approve_pending(username, "smoke-test") + data2 = json.loads(aliases.read_text(encoding="utf-8")) + if data2[username].get("destination") != dest2: + fail("destino não actualizado") + if data2[username].get("created_at") != created_at: + fail("created_at não preservado") + ok("alteração de destino com created_at preservado") + + dest3 = "smoke-cancel@example.org" + ea.create_pending_request(username, dest3) + if ea.cancel_latest_pending(username) is None: + fail("cancel falhou") + ok("cancel") + + dest4 = "smoke-reject@example.org" + ea.create_pending_request(username, dest4) + ea.reject_pending(username, "smoke-test", "smoke test") + if not any((queue / "rejected").glob("*.json")): + fail("reject não arquivou") + ok("reject") + + if not expect_exit(lambda: ea.approve_pending("entre", "smoke-test")): + fail("approve entre deveria falhar") + ok("username reservado rejeitado no approve") + + +def _run_subprocess( + env: dict[str, str], + username: str, + queue: Path, + aliases: Path, +) -> None: + py = sys.executable + email_bin = str(BIN_EMAIL) if BIN_EMAIL.is_file() else "runv-email-alias" + admin_bin = str(BIN_ADMIN) if BIN_ADMIN.is_file() else "runv-admin-email-alias" + member_user = username if os.geteuid() == 0 else None + + for dest in ("foo", "x@runv.club"): + proc = run_cmd( + [py, email_bin, "request", dest], + env=env, + as_user=member_user, + check=False, + ) + if proc.returncode == 0: + fail(f"request {dest!r} deveria falhar") + ok("validações de destino inválido rejeitadas") + + dest_ok = "smoke-alias-test@example.org" + run_cmd([py, email_bin, "request", dest_ok], env=env, as_user=member_user) + ok("request criado") + + proc = run_cmd([py, email_bin, "status"], env=env, as_user=member_user) + if "pending" not in proc.stdout.lower(): + fail(f"status sem pending: {proc.stdout!r}") + + proc2 = run_cmd( + [py, email_bin, "request", dest_ok], + env=env, + as_user=member_user, + check=False, + ) + if proc2.returncode == 0: + fail("segundo request deveria falhar") + ok("duplo pending bloqueado") + + run_cmd([py, admin_bin, "approve", username], env=env, as_root=True) + ok("approve") + + proc = run_cmd([py, admin_bin, "list"], env=env, as_root=True) + if dest_ok not in proc.stdout: + fail(f"list não mostra destino: {proc.stdout!r}") + ok("list") + + proc = run_cmd([py, email_bin, "status"], env=env, as_user=member_user) + if "active" not in proc.stdout.lower(): + fail(f"status sem active: {proc.stdout!r}") + ok("status active") + + data = json.loads(aliases.read_text(encoding="utf-8")) + entry = data.get(username) + if not entry or entry.get("status") != "active": + fail(f"email-aliases.json inválido: {data!r}") + if not any((queue / "approved").glob("*.json")): + fail("nenhum pedido em approved/") + ok("pedido arquivado em approved/") + + dest2 = "smoke-alias-test2@example.org" + run_cmd([py, email_bin, "request", dest2], env=env, as_user=member_user) + run_cmd([py, admin_bin, "approve", username], env=env, as_root=True) + data2 = json.loads(aliases.read_text(encoding="utf-8")) + if data2[username].get("destination") != dest2: + fail("destino não actualizado") + if data2[username].get("created_at") != entry.get("created_at"): + fail("created_at não preservado") + ok("alteração de destino") + + dest3 = "smoke-cancel@example.org" + run_cmd([py, email_bin, "request", dest3], env=env, as_user=member_user) + run_cmd([py, email_bin, "cancel"], env=env, as_user=member_user) + if not any((queue / "cancelled").glob("*.json")): + fail("cancel não arquivou") + ok("cancel") + + dest4 = "smoke-reject@example.org" + run_cmd([py, email_bin, "request", dest4], env=env, as_user=member_user) + run_cmd( + [py, admin_bin, "reject", username, "--reason", "smoke test"], + env=env, + as_root=True, + ) + if not any((queue / "rejected").glob("*.json")): + fail("reject não arquivou") + ok("reject") + + proc = run_cmd( + [py, admin_bin, "approve", "entre"], + env=env, + as_root=True, + check=False, + ) + if proc.returncode == 0: + fail("approve entre deveria falhar") + ok("username reservado rejeitado") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-admin-email-alias b/tools/bin/runv-admin-email-alias @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Administração de pedidos e aliases de email runv.club.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.tracebacklimit = 0 + + +def _bootstrap() -> None: + installed = Path("/usr/local/share/runv/lib") + candidates = [installed] + script = Path(__file__).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + for c in candidates: + if (c / "runv_email_aliases.py").is_file() and str(c) not in sys.path: + sys.path.insert(0, str(c)) + return + + +_bootstrap() +import runv_email_aliases as ea # noqa: E402 + + +def cmd_pending() -> int: + pending = ea.iter_pending_requests() + if not pending: + print("Nenhum pedido pendente.") + return 0 + print("Pedidos pendentes de alias\n") + for req in pending: + user = req.get("username", "?") + print(user) + print(f" alias: {req.get('alias', '?')}") + print(f" destino: {req.get('destination', '?')}") + print(f" criado: {req.get('created_at', '?')}") + print() + return 0 + + +def cmd_list() -> int: + rows = ea.list_active_aliases() + if not rows: + print("Nenhum alias ativo.") + return 0 + print("Aliases ativos\n") + for _user, alias, dest in rows: + print(f"{alias} -> {dest}") + return 0 + + +def cmd_approve(username: str) -> int: + user = ea.validate_alias_username(username) + operator = ea.admin_operator() + entry = ea.approve_pending(user, operator) + print("Alias aprovado localmente.\n") + print("Alias:") + print(f" {entry.get('alias')}\n") + print("Destino:") + print(f" {entry.get('destination')}\n") + print("Próximo passo manual:") + print( + " criar/atualizar o encaminhamento real no provedor de email:\n" + f" {entry.get('alias')} -> {entry.get('destination')}" + ) + return 0 + + +def cmd_reject(username: str, reason: str) -> int: + if not reason.strip(): + ea.rc.friendly_exit("motivo da rejeição obrigatório (--reason).") + user = ea.validate_alias_username(username) + operator = ea.admin_operator() + ea.reject_pending(user, operator, reason.strip()) + print("Pedido rejeitado.\n") + print("Usuário:") + print(f" {user}\n") + print("Motivo:") + print(f" {reason}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="runv-admin-email-alias", + description="Aprovar ou rejeitar pedidos de alias de email (requer root).", + ) + sub = p.add_subparsers(dest="command", required=True) + sub.add_parser("pending", help="listar pedidos pendentes") + sub.add_parser("list", help="listar aliases activos") + appr = sub.add_parser("approve", help="aprovar pedido pendente mais recente") + appr.add_argument("user", metavar="USER", help="nome de utilizador Unix") + rej = sub.add_parser("reject", help="rejeitar pedido pendente") + rej.add_argument("user", metavar="USER", help="nome de utilizador Unix") + rej.add_argument( + "--reason", + required=True, + help='motivo da rejeição (obrigatório)', + ) + return p + + +def main(argv: list[str] | None = None) -> int: + try: + args = build_parser().parse_args(argv) + ea.require_root() + if args.command == "pending": + return cmd_pending() + if args.command == "list": + return cmd_list() + if args.command == "approve": + return cmd_approve(args.user) + if args.command == "reject": + return cmd_reject(args.user, args.reason) + return 1 + except KeyboardInterrupt: + print("\nInterrompido.", file=sys.stderr) + return 130 + except SystemExit: + raise + except Exception as e: + ea.rc.friendly_exit(f"erro: {e}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-bulletin b/tools/bin/runv-bulletin @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Mural comunitário simples da runv.club.""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +sys.tracebacklimit = 0 + + +def _bootstrap() -> None: + installed = Path("/usr/local/share/runv/lib") + candidates = [installed] + script = Path(__file__).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + for c in candidates: + if (c / "runv_community.py").is_file() and str(c) not in sys.path: + sys.path.insert(0, str(c)) + return + + +_bootstrap() +import runv_community as rc # noqa: E402 + +DEFAULT_BULLETIN_PATH = Path("/var/lib/runv/bulletin/posts.ndjson") +MAX_BODY_LEN = 500 +DEFAULT_LIST_LIMIT = 20 + + +def bulletin_paths() -> tuple[Path, Path]: + raw = os.environ.get("RUNV_BULLETIN_PATH", "").strip() + ndjson = Path(raw) if raw else DEFAULT_BULLETIN_PATH + lock = ndjson.parent / "posts.lock" + return ndjson, lock + + +def current_username() -> str: + import pwd + + try: + name = pwd.getpwuid(os.getuid()).pw_name + except (KeyError, OSError) as e: + rc.friendly_exit(f"não foi possível determinar o utilizador: {e}") + return rc.validate_username(name) + + +def new_post_id(username: str) -> str: + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + suffix = secrets.token_hex(3) + return f"{ts}-{username}-{suffix}" + + +def append_post(body: str) -> dict[str, Any]: + username = current_username() + clean = body.strip() + if not clean: + rc.friendly_exit("mensagem obrigatória.") + if "\x00" in clean: + rc.friendly_exit("mensagem inválida (caracter NUL).") + if len(clean) > MAX_BODY_LEN: + rc.friendly_exit(f"mensagem demasiado longa (máximo {MAX_BODY_LEN} caracteres).") + + post = { + "id": new_post_id(username), + "username": username, + "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "body": clean, + } + line = json.dumps(post, ensure_ascii=False) + "\n" + + ndjson_path, lock_path = bulletin_paths() + try: + ndjson_path.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + rc.friendly_exit(f"não foi possível criar o diretório do mural: {e}") + + try: + import fcntl + + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "a+", encoding="utf-8") as lock_f: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) + try: + with open(ndjson_path, "a", encoding="utf-8") as out: + out.write(line) + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + except PermissionError: + rc.friendly_exit( + f"sem permissão para escrever em {ndjson_path}\n" + "peça ao admin para criar o diretório com permissões de grupo para membros" + ) + except OSError as e: + rc.friendly_exit(f"erro ao escrever no mural: {e}") + + return post + + +def read_posts(limit: int) -> list[dict[str, Any]]: + ndjson_path, _lock_path = bulletin_paths() + if not ndjson_path.is_file(): + return [] + try: + raw = ndjson_path.read_text(encoding="utf-8") + except OSError: + return [] + posts: list[dict[str, Any]] = [] + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(item, dict) and isinstance(item.get("id"), str): + posts.append(item) + return posts[-limit:] + + +def format_post_line(post: dict[str, Any]) -> str: + created = post.get("created_at", "") + display_time = created + try: + if isinstance(created, str) and created: + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + display_time = dt.astimezone().strftime("%Y-%m-%d %H:%M") + except ValueError: + pass + user = post.get("username", "?") + body = post.get("body", "") + return f"[{display_time}] {user}\n {body}" + + +def cmd_list(limit: int, as_json: bool) -> int: + posts = read_posts(limit) + if as_json: + print(json.dumps(posts, ensure_ascii=False, indent=2)) + return 0 + if not posts: + print("mural vazio ainda.") + return 0 + print("Mural runv.club\n") + for post in posts: + print() + print(format_post_line(post)) + print() + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="runv-bulletin", + description="Mural comunitário simples em terminal.", + ) + p.add_argument("--json", action="store_true", help="saída em JSON (list)") + p.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help=f"últimos N posts (padrão {DEFAULT_LIST_LIMIT} em list)", + ) + sub = p.add_subparsers(dest="command") + sub.add_parser("list", help="listar posts recentes") + post_p = sub.add_parser("post", help="publicar mensagem") + post_p.add_argument("message", help="texto do post") + return p + + +def main(argv: list[str] | None = None) -> int: + try: + args = build_parser().parse_args(argv) + if args.command is None: + args.command = "list" + + limit = args.limit if args.limit is not None else DEFAULT_LIST_LIMIT + if limit < 1: + rc.friendly_exit("--limit deve ser um inteiro positivo.") + + if args.command == "post": + append_post(args.message) + print("post publicado.") + return 0 + + return cmd_list(limit, bool(args.json)) + except KeyboardInterrupt: + print("\nInterrompido.", file=sys.stderr) + return 130 + except SystemExit: + raise + except Exception as e: + rc.friendly_exit(f"erro: {e}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-email-alias b/tools/bin/runv-email-alias @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Pedidos de alias de email runv.club para membros (username@runv.club).""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.tracebacklimit = 0 + + +def _bootstrap() -> None: + installed = Path("/usr/local/share/runv/lib") + candidates = [installed] + script = Path(__file__).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + for c in candidates: + if (c / "runv_email_aliases.py").is_file() and str(c) not in sys.path: + sys.path.insert(0, str(c)) + return + + +_bootstrap() +import runv_email_aliases as ea # noqa: E402 + + +def cmd_request(destination: str) -> int: + username = ea.current_username() + dest = ea.validate_destination_email(destination) + active = ea.get_active_alias(username) + if active is not None: + print( + "Aviso: já tem alias activo; este pedido altera o destino após aprovação admin.\n" + ) + payload = ea.create_pending_request(username, dest) + print("Pedido de alias criado.\n") + print("Alias:") + print(f" {payload['alias']}\n") + print("Destino:") + print(f" {payload['destination']}\n") + print("Status:") + print(" pending\n") + print("Um admin precisa aprovar antes de ativar.") + return 0 + + +def cmd_status() -> int: + username = ea.current_username() + active = ea.get_active_alias(username) + pending = ea.find_pending_for_user(username) + + if active is None and pending is None: + print("Você ainda não tem alias de email.\n") + print("Para solicitar:") + print(" runv-email-alias request seu-email@exemplo.com") + return 0 + + if active is None and pending is not None: + print("Alias:") + print(f" {pending.get('alias', ea.alias_address(username))}\n") + print("Destino solicitado:") + print(f" {pending.get('destination', '?')}\n") + print("Status:") + print(" pending") + return 0 + + if active is not None and pending is None: + print("Alias:") + print(f" {active.get('alias', ea.alias_address(username))}\n") + print("Destino:") + print(f" {active.get('destination', '?')}\n") + print("Status:") + print(" active") + return 0 + + print("Alias ativo:") + print(f" {active.get('alias')} -> {active.get('destination')}\n") + print("Alteração pendente:") + print(f" {pending.get('alias')} -> {pending.get('destination')}\n") + print("Status:") + print(" pending") + return 0 + + +def cmd_cancel() -> int: + username = ea.current_username() + cancelled = ea.cancel_latest_pending(username) + if cancelled is None: + print("nenhum pedido pendente para cancelar.") + return 1 + print("Pedido cancelado.") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="runv-email-alias", + description="Solicitar alias username@runv.club que encaminha para o seu email externo.", + ) + sub = p.add_subparsers(dest="command", required=True) + req = sub.add_parser("request", help="pedir alias (só informa o email de destino)") + req.add_argument("destination", help="email externo de destino") + sub.add_parser("status", help="ver alias activo e pedidos pendentes") + sub.add_parser("cancel", help="cancelar pedido pendente mais recente") + return p + + +def main(argv: list[str] | None = None) -> int: + try: + args = build_parser().parse_args(argv) + if args.command == "request": + return cmd_request(args.destination) + if args.command == "status": + return cmd_status() + if args.command == "cancel": + return cmd_cancel() + return 1 + except KeyboardInterrupt: + print("\nInterrompido.", file=sys.stderr) + return 130 + except SystemExit: + raise + except Exception as e: + ea.rc.friendly_exit(f"erro: {e}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-finger b/tools/bin/runv-finger @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Mostra o perfil público de um membro runv.club (estilo finger).""" + +from __future__ import annotations + +import argparse +import sys + +sys.tracebacklimit = 0 + + +def _bootstrap() -> None: + from pathlib import Path + + installed = Path("/usr/local/share/runv/lib") + candidates = [installed] + script = Path(__file__).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + for c in candidates: + if (c / "runv_community.py").is_file() and str(c) not in sys.path: + sys.path.insert(0, str(c)) + return + + +_bootstrap() +import runv_community as rc # noqa: E402 + + +def cmd_finger(username: str) -> int: + user = rc.validate_username(username) + paths = rc.home_paths(user) + if not paths["home"].is_dir(): + rc.friendly_exit(f"usuário não encontrado: {user}") + + print(f"{user} @ runv.club\n") + + profile_text = rc.read_text_limited(paths["profile"]) + profile, warn = rc.parse_profile_json(profile_text) + if warn: + print(warn) + + def field(label: str, value: str | None) -> None: + print(f"{label}: {value if value and value.strip() else 'não informado'}") + + if profile: + field("Nome", profile.get("display_name", "")) + field("Bio", profile.get("bio", "")) + field("Local", profile.get("location", "")) + else: + field("Nome", None) + field("Bio", None) + field("Local", None) + + if paths["public_index"].is_file(): + print(f"Home: /~{user}/") + mtime = rc.format_mtime_local(paths["public_index"]) + if mtime: + print(f"Home atualizada: {mtime}") + else: + print("Home: não informado") + + project_text = rc.read_text_limited(paths["project"]) + plan_text = rc.read_text_limited(paths["plan"]) + + print("\nProjeto:") + if project_text and project_text.strip(): + for line in project_text.strip().splitlines(): + print(f" {line}") + else: + print(" não informado") + + print("\n.plan:") + if plan_text and plan_text.strip(): + for line in plan_text.strip().splitlines(): + print(f" {line}") + else: + print(" não informado") + + if profile: + links = profile.get("links") or [] + print("\nLinks:") + if links: + for link in links: + print(f" {link}") + else: + print(" não informado") + interests = profile.get("interests") or [] + print("\nInteresses:") + if interests: + for item in interests: + print(f" {item}") + else: + print(" não informado") + + return 0 + + +def main(argv: list[str] | None = None) -> int: + try: + p = argparse.ArgumentParser( + prog="runv-finger", + description="Mostra o perfil público de outro membro.", + ) + p.add_argument("username", help="nome de utilizador Unix") + args = p.parse_args(argv) + return cmd_finger(args.username) + except KeyboardInterrupt: + print("\nInterrompido.", file=sys.stderr) + return 130 + except SystemExit: + raise + except Exception as e: + rc.friendly_exit(f"erro: {e}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-profile b/tools/bin/runv-profile @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Gerencia o perfil local público do utilizador (runv.club).""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.tracebacklimit = 0 + + +def _bootstrap() -> None: + from pathlib import Path + + installed = Path("/usr/local/share/runv/lib") + candidates = [installed] + script = Path(__file__).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + for c in candidates: + if (c / "runv_community.py").is_file() and str(c) not in sys.path: + sys.path.insert(0, str(c)) + return + + +_bootstrap() +import runv_community as rc # noqa: E402 + +PLAN_INITIAL = "Ainda estou escrevendo meu .plan.\n" +PROJECT_INITIAL = "Explorando a runv.club.\n" + + +def local_paths() -> dict[str, Path]: + home = Path.home() + return { + "runv_dir": home / ".runv", + "profile": home / ".runv" / "profile.json", + "plan": home / ".plan", + "project": home / ".project", + } + + +def cmd_init() -> int: + paths = local_paths() + created: list[str] = [] + existed: list[str] = [] + + if not paths["runv_dir"].exists(): + paths["runv_dir"].mkdir(parents=True, exist_ok=True) + os.chmod(paths["runv_dir"], 0o755) + created.append(str(paths["runv_dir"])) + else: + os.chmod(paths["runv_dir"], 0o755) + existed.append(str(paths["runv_dir"])) + + targets: list[tuple[Path, str]] = [ + (paths["profile"], rc.profile_json_default_text()), + (paths["plan"], PLAN_INITIAL), + (paths["project"], PROJECT_INITIAL), + ] + for path, content in targets: + if path.exists(): + existed.append(str(path)) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + os.chmod(path, 0o644) + created.append(str(path)) + + print("Perfil runv inicializado.\n") + if created: + print("criado:") + for p in created: + print(f" {p}") + if existed: + print("\njá existia:") + for p in existed: + print(f" {p}") + if not created and not existed: + print("(nada a fazer)") + return 0 + + +def cmd_show() -> int: + paths = local_paths() + print("Perfil runv\n") + print("Arquivo:") + print(f" {paths['profile']}\n") + + if not paths["profile"].is_file(): + print("Perfil ainda não existe.") + print("Rode: runv-profile init\n") + else: + text = rc.read_text_limited(paths["profile"]) + profile, warn = rc.parse_profile_json(text) + if warn: + print(warn) + if profile: + print(f"Nome:\n {profile.get('display_name') or '(vazio)'}\n") + print(f"Bio:\n {profile.get('bio') or '(vazio)'}\n") + print(f"Local:\n {profile.get('location') or '(vazio)'}\n") + links = profile.get("links") or [] + print("Links:") + if links: + for link in links: + print(f" {link}") + else: + print(" (vazio)") + print() + interests = profile.get("interests") or [] + print("Interesses:") + if interests: + for item in interests: + print(f" {item}") + else: + print(" (vazio)") + print() + + plan_text = rc.read_text_limited(paths["plan"]) + project_text = rc.read_text_limited(paths["project"]) + print(".project:") + if project_text and project_text.strip(): + for line in project_text.strip().splitlines(): + print(f" {line}") + else: + print(" (vazio)") + print() + print(".plan:") + if plan_text and plan_text.strip(): + for line in plan_text.strip().splitlines(): + print(f" {line}") + else: + print(" (vazio)") + return 0 + + +def cmd_path() -> int: + paths = local_paths() + print(f"profile: {paths['profile']}") + print(f"plan: {paths['plan']}") + print(f"project: {paths['project']}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="runv-profile", + description="Gerencia o perfil local público (~/.runv/profile.json, .plan, .project).", + ) + sub = p.add_subparsers(dest="command") + + sub.add_parser("init", help="cria ficheiros de perfil se ainda não existirem") + sub.add_parser("show", help="mostra o perfil local") + sub.add_parser("path", help="mostra caminhos dos ficheiros de perfil") + return p + + +def main(argv: list[str] | None = None) -> int: + try: + args = build_parser().parse_args(argv) + if args.command == "init": + return cmd_init() + if args.command == "show": + return cmd_show() + if args.command == "path": + return cmd_path() + build_parser().print_help() + return 1 + except KeyboardInterrupt: + print("\nInterrompido.", file=sys.stderr) + return 130 + except SystemExit: + raise + except Exception as e: + rc.friendly_exit(f"erro: {e}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bin/runv-who b/tools/bin/runv-who @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Lista membros da runv.club.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.tracebacklimit = 0 + + +def _bootstrap() -> None: + from pathlib import Path + + installed = Path("/usr/local/share/runv/lib") + candidates = [installed] + script = Path(__file__).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + for c in candidates: + if (c / "runv_community.py").is_file() and str(c) not in sys.path: + sys.path.insert(0, str(c)) + return + + +_bootstrap() +import runv_community as rc # noqa: E402 + + +def collect_member(username: str) -> dict[str, Any]: + paths = rc.home_paths(username) + has_homepage = paths["public_index"].is_file() + homepage_mtime = rc.homepage_mtime_iso(paths["public_index"]) if has_homepage else None + return { + "username": username, + "homepage": f"/~{username}/", + "has_homepage": has_homepage, + "homepage_mtime": homepage_mtime, + "has_plan": rc.file_has_content(paths["plan"]), + "has_project": rc.file_has_content(paths["project"]), + } + + +def sort_members(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + with_home = [r for r in rows if r["has_homepage"]] + without_home = [r for r in rows if not r["has_homepage"]] + + def mtime_key(r: dict[str, Any]) -> str: + return r.get("homepage_mtime") or "" + + with_home.sort(key=lambda r: mtime_key(r), reverse=True) + without_home.sort(key=lambda r: r["username"].lower()) + return with_home + without_home + + +def filter_active(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + r + for r in rows + if r["has_homepage"] or r["has_plan"] or r["has_project"] + ] + + +def print_text_table(rows: list[dict[str, Any]]) -> None: + print("Membros runv.club\n") + if not rows: + print("(nenhum membro encontrado)") + return + for r in rows: + user = r["username"] + if r["has_homepage"]: + hm = rc.format_mtime_local(r.get("homepage_mtime")) or "?" + home_col = f"home {hm}" + else: + home_col = "sem homepage" + flags = [] + if r["has_plan"]: + flags.append(".plan") + if r["has_project"]: + flags.append(".project") + flag_col = " ".join(flags) if flags else "-" + print(f"{user:<12} {home_col:<22} {flag_col:<14} {r['homepage']}") + + +def main(argv: list[str] | None = None) -> int: + try: + p = argparse.ArgumentParser( + prog="runv-who", + description="Lista membros da runv.club.", + ) + p.add_argument("--json", action="store_true", help="saída em JSON") + p.add_argument("--limit", type=int, default=None, metavar="N", help="limitar resultados") + p.add_argument( + "--active", + action="store_true", + help="só utilizadores com homepage, .plan ou .project", + ) + args = p.parse_args(argv) + + if args.limit is not None and args.limit < 1: + rc.friendly_exit("--limit deve ser um inteiro positivo.") + + names, warning = rc.load_member_usernames(rc.DEFAULT_USERS_JSON, rc.DEFAULT_HOME_ROOT) + if warning: + print(warning, file=sys.stderr) + + rows = [collect_member(u) for u in names] + if args.active: + rows = filter_active(rows) + rows = sort_members(rows) + if args.limit is not None: + rows = rows[: args.limit] + + if args.json: + print(json.dumps(rows, ensure_ascii=False, indent=2)) + return 0 + + print_text_table(rows) + return 0 + except KeyboardInterrupt: + print("\nInterrompido.", file=sys.stderr) + return 130 + except SystemExit: + raise + except Exception as e: + rc.friendly_exit(f"erro: {e}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/lib/runv_community.py b/tools/lib/runv_community.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Utilitários partilhados pelos comandos comunitários runv.club (stdlib apenas). +""" + +from __future__ import annotations + +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Final + +USERNAME_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") +MAX_READ_BYTES: Final[int] = 16 * 1024 +DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") +DEFAULT_HOME_ROOT: Final[Path] = Path("/home") +INSTALLED_LIB_DIR: Final[Path] = Path("/usr/local/share/runv/lib") + +PROFILE_DEFAULT: Final[dict[str, Any]] = { + "display_name": "", + "bio": "", + "location": "", + "links": [], + "interests": [], +} + + +def install_bootstrap() -> None: + """Permite importar este módulo a partir de tools/bin/ ou de /usr/local/bin/.""" + here = Path(__file__).resolve().parent + candidates: list[Path] = [INSTALLED_LIB_DIR, here] + try: + script = Path(sys.argv[0]).resolve() + if script.parent.name == "bin": + candidates.insert(0, script.parent.parent / "lib") + except (IndexError, OSError): + pass + for candidate in candidates: + if (candidate / "runv_community.py").is_file(): + s = str(candidate) + if s not in sys.path: + sys.path.insert(0, s) + return + + +def friendly_exit(msg: str, code: int = 1) -> None: + print(msg, file=sys.stderr) + raise SystemExit(code) + + +def validate_username(username: str) -> str: + u = username.strip() + if not USERNAME_RE.fullmatch(u): + friendly_exit( + "nome de utilizador inválido: use letras minúsculas, dígitos, _ e -; " + "comece com letra; entre 2 e 32 caracteres." + ) + return u + + +def read_text_limited(path: Path, *, max_bytes: int = MAX_READ_BYTES) -> str | None: + if not path.is_file(): + return None + try: + with path.open("rb") as f: + data = f.read(max_bytes + 1) + if len(data) > max_bytes: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace") + except OSError: + return None + + +def file_has_content(path: Path) -> bool: + text = read_text_limited(path) + return text is not None and bool(text.strip()) + + +def parse_profile_json(text: str | None) -> tuple[dict[str, Any] | None, str | None]: + if text is None: + return None, None + raw = text.strip() + if not raw: + return None, None + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + return None, f"aviso: profile.json inválido ({e.msg})." + if not isinstance(data, dict): + return None, "aviso: profile.json deve ser um objeto JSON." + safe: dict[str, Any] = {} + for key in ("display_name", "bio", "location"): + val = data.get(key, "") + safe[key] = val if isinstance(val, str) else "" + links = data.get("links", []) + safe["links"] = [x for x in links if isinstance(x, str)] if isinstance(links, list) else [] + interests = data.get("interests", []) + safe["interests"] = ( + [x for x in interests if isinstance(x, str)] if isinstance(interests, list) else [] + ) + return safe, None + + +def home_paths(username: str) -> dict[str, Path]: + base = DEFAULT_HOME_ROOT / username + return { + "home": base, + "profile": base / ".runv" / "profile.json", + "plan": base / ".plan", + "project": base / ".project", + "public_index": base / "public_html" / "index.html", + } + + +def homepage_mtime_iso(path: Path) -> str | None: + try: + st = path.stat() + ts = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) + return ts.isoformat().replace("+00:00", "Z") + except OSError: + return None + + +def format_mtime_local(iso_or_path: str | Path | None) -> str | None: + if iso_or_path is None: + return None + if isinstance(iso_or_path, Path): + iso = homepage_mtime_iso(iso_or_path) + if iso is None: + return None + else: + iso = iso_or_path + try: + if iso.endswith("Z"): + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + else: + dt = datetime.fromisoformat(iso) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + local = dt.astimezone() + return local.strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError): + return None + + +def _username_from_item(item: Any) -> str | None: + if isinstance(item, str) and USERNAME_RE.fullmatch(item): + return item + if isinstance(item, dict): + uname = item.get("username") + if isinstance(uname, str) and USERNAME_RE.fullmatch(uname): + return uname + return None + + +def _usernames_from_parsed(data: Any) -> list[str]: + found: list[str] = [] + seen: set[str] = set() + if isinstance(data, list): + items = data + elif isinstance(data, dict): + if isinstance(data.get("users"), list): + items = data["users"] + else: + items = list(data.keys()) + else: + return [] + for item in items: + uname = _username_from_item(item) + if uname and uname not in seen: + seen.add(uname) + found.append(uname) + return found + + +def load_member_usernames( + users_json_path: Path, + home_root: Path, +) -> tuple[list[str], str | None]: + warning: str | None = None + if users_json_path.is_file(): + try: + raw = users_json_path.read_text(encoding="utf-8").strip() + if raw: + parsed = json.loads(raw) + names = _usernames_from_parsed(parsed) + if names: + return sorted(names, key=str.lower), None + except (OSError, json.JSONDecodeError): + warning = ( + f"aviso: não foi possível ler {users_json_path}; " + "a listar diretórios em /home." + ) + names_home: list[str] = [] + if home_root.is_dir(): + for entry in home_root.iterdir(): + if entry.is_dir() and USERNAME_RE.fullmatch(entry.name): + names_home.append(entry.name) + return sorted(set(names_home), key=str.lower), warning + + +def profile_json_default_text() -> str: + return json.dumps(PROFILE_DEFAULT, ensure_ascii=False, indent=2) + "\n" diff --git a/tools/lib/runv_email_aliases.py b/tools/lib/runv_email_aliases.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +Utilitários para pedidos e aprovação de aliases de email runv.club (stdlib apenas). +""" + +from __future__ import annotations + +import json +import os +import re +import secrets +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Final, Literal + +import runv_community as rc + +DEFAULT_ALIASES_PATH: Final[Path] = Path("/var/lib/runv/email-aliases.json") +DEFAULT_ALIASES_LOCK: Final[Path] = Path("/var/lib/runv/email-aliases.lock") +DEFAULT_QUEUE_DIR: Final[Path] = Path("/var/lib/runv/email-alias-queue") +DEFAULT_ALIAS_DOMAIN: Final[str] = "runv.club" + +ALIAS_RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( + { + "root", + "admin", + "postmaster", + "abuse", + "security", + "support", + "contato", + "contact", + "noreply", + "no-reply", + "mailer-daemon", + "hostmaster", + "webmaster", + "entre", + "join", + "welcome", + } +) + +DEST_EMAIL_RE: Final[re.Pattern[str]] = re.compile( + r"^[^@\s\x00\r\n]+@[^@\s\x00\r\n]+\.[^@\s\x00\r\n]+$" +) + +ArchiveKind = Literal["approved", "rejected", "cancelled"] + + +def iso_utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def alias_domain() -> str: + raw = os.environ.get("RUNV_EMAIL_ALIAS_DOMAIN", "").strip().lower() + return raw if raw else DEFAULT_ALIAS_DOMAIN + + +def aliases_paths() -> tuple[Path, Path]: + aliases = os.environ.get("RUNV_EMAIL_ALIASES_PATH", "").strip() + lock = os.environ.get("RUNV_EMAIL_ALIASES_LOCK_PATH", "").strip() + return ( + Path(aliases) if aliases else DEFAULT_ALIASES_PATH, + Path(lock) if lock else DEFAULT_ALIASES_LOCK, + ) + + +def queue_paths() -> dict[str, Path]: + raw = os.environ.get("RUNV_EMAIL_ALIAS_QUEUE_DIR", "").strip() + base = Path(raw) if raw else DEFAULT_QUEUE_DIR + return { + "queue": base, + "approved": base / "approved", + "rejected": base / "rejected", + "cancelled": base / "cancelled", + } + + +def validate_alias_username(username: str) -> str: + u = rc.validate_username(username) + if u in ALIAS_RESERVED_USERNAMES: + rc.friendly_exit(f"nome de utilizador reservado para alias de email: {u!r}") + return u + + +def validate_destination_email(email: str) -> str: + addr = email.strip() + if not addr: + rc.friendly_exit("email de destino obrigatório.") + if len(addr) > 254: + rc.friendly_exit("email de destino demasiado longo (máximo 254 caracteres).") + if "\x00" in addr or "\n" in addr or "\r" in addr or " " in addr: + rc.friendly_exit("email de destino inválido.") + if addr.count("@") != 1: + rc.friendly_exit("email de destino inválido: deve conter exactamente um @.") + if not DEST_EMAIL_RE.fullmatch(addr): + rc.friendly_exit("email de destino inválido.") + local, domain = addr.rsplit("@", 1) + if not local or not domain: + rc.friendly_exit("email de destino inválido.") + if "." not in domain: + rc.friendly_exit("email de destino inválido: domínio deve conter pelo menos um ponto.") + if domain.lower() == alias_domain().lower(): + rc.friendly_exit( + f"email de destino não pode ser @{alias_domain()} (evita encaminhamento em loop)." + ) + return addr + + +def alias_address(username: str) -> str: + return f"{username}@{alias_domain()}" + + +def current_username() -> str: + import pwd + + try: + name = pwd.getpwuid(os.getuid()).pw_name + except (KeyError, OSError) as e: + rc.friendly_exit(f"não foi possível determinar o utilizador: {e}") + return validate_alias_username(name) + + +def admin_operator() -> str: + sudo_user = os.environ.get("SUDO_USER", "").strip() + if sudo_user: + return sudo_user + import pwd + + try: + return pwd.getpwuid(os.getuid()).pw_name + except (KeyError, OSError): + return "root" + + +def require_root() -> None: + geteuid = getattr(os, "geteuid", None) + if geteuid is None or geteuid() != 0: + rc.friendly_exit("este comando precisa ser executado como root.") + + +def new_request_id(username: str) -> str: + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + suffix = secrets.token_hex(3) + return f"{ts}-{username}-{suffix}" + + +def _read_json_file(path: Path) -> Any | None: + if not path.is_file(): + return None + try: + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return None + return json.loads(raw) + except (OSError, json.JSONDecodeError): + return None + + +def load_aliases_unlocked(aliases_path: Path) -> dict[str, dict[str, Any]]: + parsed = _read_json_file(aliases_path) + if parsed is None: + return {} + if not isinstance(parsed, dict): + rc.friendly_exit(f"formato inválido em {aliases_path}: esperado objecto JSON.") + out: dict[str, dict[str, Any]] = {} + for key, val in parsed.items(): + if isinstance(key, str) and isinstance(val, dict): + out[key] = val + return out + + +def load_aliases() -> dict[str, dict[str, Any]]: + aliases_path, _ = aliases_paths() + return load_aliases_unlocked(aliases_path) + + +def save_aliases(data: dict[str, dict[str, Any]]) -> None: + import fcntl + + aliases_path, lock_path = aliases_paths() + aliases_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) + tmp_fd, tmp_name = tempfile.mkstemp( + prefix="email-aliases.", + suffix=".tmp", + dir=str(aliases_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.write("\n") + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_path, aliases_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + lock_f.close() + + +def get_active_alias(username: str) -> dict[str, Any] | None: + entry = load_aliases().get(username) + if not entry or entry.get("status") != "active": + return None + return entry + + +def _queue_request_file(request_id: str) -> Path: + return queue_paths()["queue"] / f"{request_id}.json" + + +def _parse_request(path: Path) -> dict[str, Any] | None: + data = _read_json_file(path) + if not isinstance(data, dict): + return None + return data + + +def iter_pending_requests() -> list[dict[str, Any]]: + qdir = queue_paths()["queue"] + if not qdir.is_dir(): + return [] + pending: list[dict[str, Any]] = [] + for path in sorted(qdir.glob("*.json")): + if not path.is_file(): + continue + req = _parse_request(path) + if req and req.get("status") == "pending": + pending.append({**req, "_path": str(path)}) + pending.sort(key=lambda r: str(r.get("created_at", ""))) + return pending + + +def find_pending_for_user(username: str) -> dict[str, Any] | None: + for req in iter_pending_requests(): + if req.get("username") == username: + return req + return None + + +def find_latest_pending_for_user(username: str) -> tuple[dict[str, Any], Path] | None: + matches: list[tuple[dict[str, Any], Path]] = [] + qdir = queue_paths()["queue"] + if not qdir.is_dir(): + return None + for path in qdir.glob("*.json"): + if not path.is_file(): + continue + req = _parse_request(path) + if req and req.get("username") == username and req.get("status") == "pending": + matches.append((req, path)) + if not matches: + return None + + def sort_key(item: tuple[dict[str, Any], Path]) -> str: + req, path = item + created = req.get("created_at") + if isinstance(created, str) and created: + return created + try: + return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + except OSError: + return "" + + matches.sort(key=sort_key) + req, path = matches[-1] + return req, path + + +def create_pending_request(username: str, destination: str) -> dict[str, Any]: + paths = queue_paths() + try: + paths["queue"].mkdir(parents=True, exist_ok=True) + except OSError as e: + rc.friendly_exit(f"não foi possível criar o diretório da fila: {e}") + + if find_pending_for_user(username) is not None: + rc.friendly_exit( + "Já existe pedido pendente.\n" + "Veja o status com:\n" + " runv-email-alias status\n\n" + "Para cancelar:\n" + " runv-email-alias cancel" + ) + + request_id = new_request_id(username) + alias = alias_address(username) + payload: dict[str, Any] = { + "request_id": request_id, + "username": username, + "alias": alias, + "destination": destination, + "status": "pending", + "created_at": iso_utc_now(), + } + dest_path = _queue_request_file(request_id) + try: + fd = os.open( + dest_path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o664, + ) + except FileExistsError: + rc.friendly_exit("conflito ao criar pedido; tente novamente.") + except PermissionError: + rc.friendly_exit( + f"sem permissão para criar pedido em {paths['queue']}\n" + "peça ao admin para executar:\n" + " sudo python3 scripts/admin/setup_email_aliases.py" + ) + except OSError as e: + rc.friendly_exit(f"erro ao criar pedido: {e}") + + try: + with os.fdopen(fd, "w", encoding="utf-8") as out: + json.dump(payload, out, indent=2, ensure_ascii=False) + out.write("\n") + except OSError as e: + dest_path.unlink(missing_ok=True) + rc.friendly_exit(f"erro ao gravar pedido: {e}") + return payload + + +def archive_request( + path: Path, + payload: dict[str, Any], + kind: ArchiveKind, +) -> Path: + paths = queue_paths() + dest_dir = paths[kind] + dest_dir.mkdir(parents=True, exist_ok=True) + request_id = payload.get("request_id") + if not isinstance(request_id, str) or not request_id: + request_id = path.stem + dest = dest_dir / f"{request_id}.json" + if dest.exists(): + dest = dest_dir / f"{request_id}-{secrets.token_hex(2)}.json" + try: + path.replace(dest) + except OSError: + dest.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + path.unlink(missing_ok=True) + return dest + + +def cancel_latest_pending(username: str) -> dict[str, Any] | None: + found = find_latest_pending_for_user(username) + if found is None: + return None + req, path = found + req["status"] = "cancelled" + req["cancelled_at"] = iso_utc_now() + archive_request(path, req, "cancelled") + return req + + +def approve_pending(username: str, operator: str) -> dict[str, Any]: + validate_alias_username(username) + found = find_latest_pending_for_user(username) + if found is None: + rc.friendly_exit(f"nenhum pedido pendente para o utilizador {username!r}.") + req, path = found + destination = req.get("destination") + if not isinstance(destination, str): + rc.friendly_exit("pedido pendente sem email de destino válido.") + destination = validate_destination_email(destination) + + aliases_path, _ = aliases_paths() + import fcntl + + aliases_path.parent.mkdir(parents=True, exist_ok=True) + lock_path = aliases_paths()[1] + 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 = load_aliases_unlocked(aliases_path) + now = iso_utc_now() + alias = alias_address(username) + existing = data.get(username) + created_at = now + if isinstance(existing, dict) and isinstance(existing.get("created_at"), str): + created_at = existing["created_at"] + data[username] = { + "username": username, + "alias": alias, + "destination": destination, + "status": "active", + "created_at": created_at, + "updated_at": now, + "approved_by": operator, + } + tmp_fd, tmp_name = tempfile.mkstemp( + prefix="email-aliases.", + suffix=".tmp", + dir=str(aliases_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.write("\n") + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_path, aliases_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + lock_f.close() + + req["status"] = "approved" + req["approved_at"] = iso_utc_now() + req["approved_by"] = operator + archive_request(path, req, "approved") + return data[username] + + +def reject_pending(username: str, operator: str, reason: str) -> dict[str, Any]: + validate_alias_username(username) + found = find_latest_pending_for_user(username) + if found is None: + rc.friendly_exit(f"nenhum pedido pendente para o utilizador {username!r}.") + req, path = found + req["status"] = "rejected" + req["rejected_at"] = iso_utc_now() + req["rejected_by"] = operator + req["reason"] = reason + archive_request(path, req, "rejected") + return req + + +def list_active_aliases() -> list[tuple[str, str, str]]: + data = load_aliases() + rows: list[tuple[str, str, str]] = [] + for username in sorted(data.keys(), key=str.lower): + entry = data[username] + if entry.get("status") != "active": + continue + alias = entry.get("alias") + dest = entry.get("destination") + if isinstance(alias, str) and isinstance(dest, str): + rows.append((username, alias, dest)) + return rows diff --git a/tools/skel/.plan b/tools/skel/.plan @@ -0,0 +1 @@ +Ainda estou escrevendo meu .plan. diff --git a/tools/skel/.project b/tools/skel/.project @@ -0,0 +1 @@ +Explorando a runv.club. diff --git a/tools/skel/.runv/profile.json b/tools/skel/.runv/profile.json @@ -0,0 +1,7 @@ +{ + "display_name": "", + "bio": "", + "location": "", + "links": [], + "interests": [] +} diff --git a/tools/tools.py b/tools/tools.py @@ -32,11 +32,13 @@ _APT_PACKAGE_ALIASES: dict[str, str] = { "chat": "weechat", } BIN_DIR: Path = TOOL_ROOT / "bin" +LIB_DIR: Path = TOOL_ROOT / "lib" MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv" SKEL_DIR: Path = TOOL_ROOT / "skel" SUDOERS_ADMIN_SRC: Path = TOOL_ROOT / "sudoers" / "90-runv-pmurad-admin" DEST_BIN_DIR: Path = Path("/usr/local/bin") +DEST_COMMUNITY_LIB_DIR: Path = Path("/usr/local/share/runv/lib") DEST_MOTD: Path = Path("/etc/update-motd.d/60-runv") DEST_SKEL: Path = Path("/etc/skel") DEST_SSHD_DROPIN: Path = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf") @@ -217,7 +219,20 @@ def install_bin_scripts( ) -> None: if not dry_run: DEST_BIN_DIR.mkdir(parents=True, exist_ok=True) - for name in ("runv-help", "runv-links", "runv-status", "runv-games", "runvers", "chat"): + for name in ( + "runv-help", + "runv-links", + "runv-status", + "runv-games", + "runvers", + "chat", + "runv-profile", + "runv-finger", + "runv-who", + "runv-bulletin", + "runv-email-alias", + "runv-admin-email-alias", + ): copy_one( BIN_DIR / name, DEST_BIN_DIR / name, @@ -229,6 +244,28 @@ def install_bin_scripts( ) +def install_community_lib( + *, + force: bool, + dry_run: bool, + log: logging.Logger, + summary: RunSummary, +) -> None: + """Copia bibliotecas partilhadas para /usr/local/share/runv/lib/.""" + if not dry_run: + DEST_COMMUNITY_LIB_DIR.mkdir(parents=True, exist_ok=True) + for src in sorted(LIB_DIR.glob("*.py")): + copy_one( + src, + DEST_COMMUNITY_LIB_DIR / src.name, + 0o644, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + + def install_motd( *, force: bool, @@ -373,10 +410,38 @@ def install_skel( skel_files: list[tuple[Path, Path, int]] = [ (SKEL_DIR / ".bash_aliases", DEST_SKEL / ".bash_aliases", 0o644), + (SKEL_DIR / ".plan", DEST_SKEL / ".plan", 0o644), + (SKEL_DIR / ".project", DEST_SKEL / ".project", 0o644), ] for src, dst, mode in skel_files: copy_one(src, dst, mode, force=force, dry_run=dry_run, log=log, summary=summary) + runv_skel_dir = DEST_SKEL / ".runv" + profile_src = SKEL_DIR / ".runv" / "profile.json" + profile_dst = runv_skel_dir / "profile.json" + if not profile_src.is_file(): + summary.errors.append(f"origem inexistente: {profile_src}") + log.error("Origem inexistente: %s", profile_src) + else: + if not dry_run: + runv_skel_dir.mkdir(parents=True, exist_ok=True) + os.chmod(runv_skel_dir, 0o755) + try: + os.chown(runv_skel_dir, 0, 0) + except OSError as e: + log.warning("chown em %s: %s", runv_skel_dir, e) + elif not runv_skel_dir.exists(): + log.info("[dry-run] criaria diretório %s (755)", runv_skel_dir) + copy_one( + profile_src, + profile_dst, + 0o644, + 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" @@ -628,6 +693,9 @@ def main(argv: list[str] | None = None) -> int: 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 biblioteca comunitária em %s", DEST_COMMUNITY_LIB_DIR) + install_community_lib(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)