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