runv-server

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

commit 27ae2f5decf16d8af7006b6398048df3c3e90d75
parent 969b0e9ba4093ef614e52811b505379bbbcef616
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sat, 21 Mar 2026 13:28:12 -0300

chat, gemini and gopher

Diffstat:
MREADME.md | 24+++++++++++++++++++++---
Apatches/patch_permissions.py | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/admin/create_runv_user.py | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mscripts/admin/del-user.py | 30++++++++++++++++++++++++++++++
Ascripts/admin/patch_irc.py | 646+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/admin/setup_alt_protocols.py | 642+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/create_runv_user.md | 19++++++++++++-------
Mscripts/del-user.md | 1+
Ascripts/docs/alt_protocols.md | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/docs/irc_patch.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mterminal/README.md | 5++---
Mterminal/docs/ADMIN.md | 3++-
Mterminal/docs/ARCHITECTURE.md | 8++++----
Mterminal/docs/USO.md | 17+++++++++--------
Mterminal/entre_app.py | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mterminal/entre_core.py | 37++++++++++++++++++++++++++++++++++++-
Mterminal/setup_entre.py | 1-
Mterminal/templates/admin_console_notice.txt | 2+-
Mterminal/templates/admin_mail.txt | 5+++++
Mterminal/templates/confirm.txt | 10++++++----
Mterminal/templates/intro.txt | 64++++++++++------------------------------------------------------
Mterminal/templates/warning_public_key.txt | 28+++++++---------------------
Mtools/README.md | 4++--
Atools/bin/chat | 28++++++++++++++++++++++++++++
Mtools/bin/runv-help | 3+--
Mtools/bin/runv-status | 9++++++++-
Mtools/docs/ADMIN.md | 2++
Mtools/docs/INSTALL.md | 14+++++++++-----
Mtools/docs/USER_EXPERIENCE.md | 11++++++-----
Mtools/manifests/apt_packages.txt | 3++-
Mtools/motd/60-runv | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtools/setuptools/tools.txt | 4++--
Mtools/skel/README.md | 1-
Atools/skel/public_gemini/index.gmi | 10++++++++++
Atools/skel/public_gopher/gophermap | 3+++
Mtools/tools.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
36 files changed, 2434 insertions(+), 161 deletions(-)

diff --git a/README.md b/README.md @@ -1,3 +1,21 @@ -runv.club server stuff +# runv.club — runv-server -~pmurad -\ No newline at end of file +Repositório de scripts e documentação para o servidor **runv.club** (Debian, pubnix). + +## Conteúdo principal + +| Área | Descrição | +|------|-----------| +| **`scripts/admin/create_runv_user.py`** | Provisiona contas Unix: SSH, `~/public_html` (HTTP), **`~/public_gopher`** (Gopher), **`~/public_gemini`** (Gemini), symlink em `/var/gemini/users/`, README, quota, metadados. | +| **`scripts/admin/setup_alt_protocols.py`** | Instala/configura **gophernicus** (porta 70) e **molly-brown** (Gemini, TLS, porta 1965), UFW se ativo, backfill para utilizadores existentes. Ver **`scripts/docs/alt_protocols.md`**. | +| **`scripts/admin/patch_irc.py`** | IRC (estilo tilde.club): comando **`chat`** para utilizadores; rede por defeito `irc.portalidea.com.br`. Ver **`scripts/docs/irc_patch.md`**. | +| **`tools/tools.py`** | Pacotes globais (incl. IRC), MOTD, `/usr/local/bin` (**`chat`**, `runv-help`, …), **`/etc/skel`**. | +| **`terminal/`** | Fluxo SSH «entre» (pedidos de conta). | + +## Protocolos públicos por utilizador + +- **HTTP:** ficheiros em `~/public_html/` (Apache `mod_userdir`). +- **Gopher:** `~/public_gopher/` (ficheiro inicial `gophermap`); URL típica `gopher://runv.club/1/~usuario`. +- **Gemini:** `~/public_gemini/` (`index.gmi`); URL típica `gemini://runv.club/~usuario/` (serviço global + TLS). + +— ~pmurad +\ No newline at end of file diff --git a/patches/patch_permissions.py b/patches/patch_permissions.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +""" +runv.club — privacidade em /home e drop-in SSH para confinamento (ChrootDirectory). + +Dois níveis (resumo do modelo POSIX/OpenSSH): + +1. **Privacidade entre utilizadores** — não impede «cd ..»; apenas impede listar/entrar nas + homes alheias: ``chmod 711 /home`` e ``chmod 700`` em cada ``/home/<user>``. + +2. **Confinamento real** — ``Match Group`` + ``ChrootDirectory /srv/jail/%u``; o caminho do + chroot e ascendentes devem ser **root-owned** e não graváveis por outros (requisito do + sshd). ``rbash`` sozinho não substitui jail/container. + +Este script **não** constrói um jail completo (libs, /dev, etc.); aplica ou documenta o +drop-in e as permissões de /home. Debian 13 · Python 3 stdlib · sem shell=True. + +Executar como root em produção. Ver ``--help``. +""" + +from __future__ import annotations + +import argparse +import grp +import os +import pwd +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Final + +VERSION: Final[str] = "0.01" + +GROUP_NAME: Final[str] = "runv-jailed" +SSHD_DROPIN: Final[str] = "/etc/ssh/sshd_config.d/runv-jailed.conf" +HOME_ROOT: Final[str] = "/home" +JAIL_ROOT: Final[str] = "/srv/jail" + +SSHD_BLOCK: Final[str] = f"""# runv.club — grupo {GROUP_NAME}: shell dentro de ChrootDirectory +# Requisitos sshd: {JAIL_ROOT}/<user> e todos os ascendentes owned por root, sem escrita +# para grupo/outros; dentro do jail é preciso árvore mínima executável (ex. /bin/sh). +# Validar: sshd -t && systemctl reload ssh + +Match Group {GROUP_NAME} + ChrootDirectory {JAIL_ROOT}/%u + ForceCommand /bin/sh + X11Forwarding no + AllowTcpForwarding no + AllowAgentForwarding no + PermitTunnel no + DisableForwarding yes +""" + +CHROOT_NOTES: Final[str] = """ +=== ChrootDirectory (OpenSSH) — notas === + +- «cd ..» com permissões Unix normais não se «proíbe»; ou restringe-se visibilidade + (r/x em diretórios) ou usa-se confinamento real (chroot, container, zone). + +- ChrootDirectory exige que o directório do chroot e **todos** os componentes do caminho + até à raiz sejam propriedade de root e **não** graváveis por grupo nem outros. + +- Não use ChrootDirectory apontando para a home do próprio utilizador se essa home for + dele e gravável — o sshd rejeita ou quebra o modelo de segurança. + +- Layout típico para utilizador «alice»: + + /srv/jail/alice root:root 0755 (raiz do chroot) + /srv/jail/alice/home alice:alice 0700 (área gravável; cd ~) + + O utilizador em passwd pode continuar a ter home «/home/alice» no sistema real, mas + dentro do chroot o shell vê a raiz em /srv/jail/alice; costuma montar-se ou replicar-se + binários, libs e dispositivos mínimos sob /srv/jail/alice (ou usar abordagem com + container em vez de chroot «manual»). + +- Quem **não** estiver no grupo runv-jailed não recebe este Match e mantém sessão normal + (ex.: administrador com conta fora do grupo). + +- rbash não é substituto de jail; ver manual do Bash (restricted shell). +""" + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def require_root() -> None: + if os.geteuid() != 0: + eprint("Execute como root (sudo).") + raise SystemExit(1) + + +def run(cmd: list[str], *, timeout: int = 120) -> None: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") + + +def sshd_main_config_mentions_dropin() -> bool: + main = Path("/etc/ssh/sshd_config") + if not main.is_file(): + return False + try: + text = main.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return "sshd_config.d" in text and "Include" in text + + +def apply_sshd_dropin(*, dry_run: bool, no_reload: bool) -> None: + path = Path(SSHD_DROPIN) + if dry_run: + print(f"[dry-run] escreveria {path}") + print(SSHD_BLOCK) + print("[dry-run] sshd -t && systemctl reload ssh") + return + + if not sshd_main_config_mentions_dropin(): + eprint( + "AVISO: /etc/ssh/sshd_config pode não incluir /etc/ssh/sshd_config.d/*.conf.\n" + " Confirme uma linha «Include … sshd_config.d»." + ) + + path.parent.mkdir(parents=True, exist_ok=True) + backup: Path | None = None + if path.is_file(): + backup = path.with_name(f"{path.name}.bak.{int(time.time())}") + shutil.copy2(path, backup) + print(f"Backup: {backup}") + + path.write_text(SSHD_BLOCK, encoding="utf-8") + path.chmod(0o644) + print(f"Escrito {path}") + + def revert() -> None: + if backup is not None: + shutil.copy2(backup, path) + eprint(f"Revertido {path}") + else: + path.unlink(missing_ok=True) + eprint(f"Removido {path}") + + try: + run(["sshd", "-t"]) + except RuntimeError as e: + revert() + raise SystemExit(f"sshd -t falhou; configuração revertida.\n{e}") from e + print("sshd -t: OK.") + + if no_reload: + print("Saltado reload; execute: systemctl reload ssh") + return + try: + run(["systemctl", "reload", "ssh"], timeout=60) + except RuntimeError: + try: + run(["systemctl", "reload", "sshd"], timeout=60) + except RuntimeError as e2: + raise SystemExit( + "sshd -t OK mas falhou systemctl reload ssh/sshd; recarregue manualmente." + ) from e2 + print("Serviço SSH recarregado.") + + +def ensure_group(*, dry_run: bool) -> None: + try: + grp.getgrnam(GROUP_NAME) + print(f"Grupo «{GROUP_NAME}» já existe.") + return + except KeyError: + pass + if dry_run: + print(f"[dry-run] groupadd {GROUP_NAME}") + return + run(["groupadd", GROUP_NAME]) + print(f"Criado grupo «{GROUP_NAME}».") + + +def apply_home_privacy( + *, + dry_run: bool, + home_path: Path, + exclude: frozenset[str], +) -> None: + if not home_path.is_dir(): + raise SystemExit(f"Não é directório: {home_path}") + + if dry_run: + print(f"[dry-run] chmod 711 {home_path}") + else: + os.chmod(home_path, 0o711) + print(f"chmod 711 {home_path}") + + for child in sorted(home_path.iterdir(), key=lambda p: p.name): + if child.name in exclude: + print(f"Omitido (—exclude): {child}") + continue + if not child.is_dir(): + continue + if dry_run: + print(f"[dry-run] chmod 700 {child}") + else: + os.chmod(child, 0o700) + print(f"chmod 700 {child}") + + print() + print("Verificação sugerida: ls -ld /home /home/*") + + +def scaffold_jail_tree(username: str, *, dry_run: bool) -> None: + """Cria apenas a árvore mínima de directórios e donos; não copia binários/libs.""" + try: + pw = pwd.getpwnam(username) + except KeyError as e: + raise SystemExit(f"Utilizador «{username}» não existe em passwd.") from e + + jail = Path(JAIL_ROOT) / username + jail_home = jail / "home" + + if dry_run: + print(f"[dry-run] mkdir -p {jail_home}") + print(f"[dry-run] chown root:root {jail} && chmod 755 {jail}") + print( + f"[dry-run] chown {pw.pw_uid}:{pw.pw_gid} {jail_home} && chmod 700 {jail_home}" + ) + return + + jail_home.parent.mkdir(parents=True, exist_ok=True) + jail_home.mkdir(parents=True, exist_ok=True) + os.chown(jail, 0, 0) + os.chmod(jail, 0o755) + os.chown(jail_home, pw.pw_uid, pw.pw_gid) + os.chmod(jail_home, 0o700) + print(f"Criado {jail} (root:root 755) e {jail_home} ({username}, 700).") + eprint( + "Aviso: para shell interactivo no chroot ainda precisa de /bin/sh, libs e " + "normalmente /dev dentro do jail — este comando só cria directórios vazios." + ) + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Permissões /home + drop-in SSH Match Group runv-jailed (ChrootDirectory)." + ) + p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") + p.add_argument( + "--dry-run", + action="store_true", + help="Mostra acções sem escrever no sistema (exceto --print-chroot-notes).", + ) + p.add_argument( + "--apply-ssh", + action="store_true", + help=f"Instala {SSHD_DROPIN} com Match Group {GROUP_NAME}.", + ) + p.add_argument( + "--apply-home", + action="store_true", + help=f"chmod 711 {HOME_ROOT} e 700 em cada subdirectório (privacidade básica).", + ) + p.add_argument( + "--ensure-group", + action="store_true", + help=f"Cria o grupo {GROUP_NAME} se não existir (groupadd).", + ) + p.add_argument( + "--no-reload", + action="store_true", + help="Após sshd -t, não executa systemctl reload ssh.", + ) + p.add_argument( + "--home-root", + type=Path, + default=Path(HOME_ROOT), + help=f"Raiz das homes (omissão: {HOME_ROOT}).", + ) + p.add_argument( + "--exclude", + action="append", + default=[], + metavar="NAME", + help="Nome de entrada em /home a omitir no chmod 700 (repetível).", + ) + p.add_argument( + "--print-chroot-notes", + action="store_true", + help="Imprime notas sobre ChrootDirectory e layout /srv/jail.", + ) + p.add_argument( + "--scaffold-jail", + metavar="USER", + default=None, + help=f"Cria {JAIL_ROOT}/USER e .../home com donos mínimos (sem binários).", + ) + return p.parse_args() + + +def main() -> None: + args = parse_args() + if args.print_chroot_notes: + print(CHROOT_NOTES.strip()) + if not any( + [ + args.apply_ssh, + args.apply_home, + args.ensure_group, + args.scaffold_jail, + ] + ): + return + + want_any = ( + args.apply_ssh + or args.apply_home + or args.ensure_group + or args.scaffold_jail is not None + ) + if not want_any and not args.print_chroot_notes: + eprint( + "Indique pelo menos uma acção: --apply-ssh, --apply-home, --ensure-group, " + "--scaffold-jail USER, ou --print-chroot-notes." + ) + raise SystemExit(2) + + if want_any and not args.dry_run: + require_root() + + excl = frozenset(args.exclude) if args.exclude else frozenset() + + if args.ensure_group: + ensure_group(dry_run=args.dry_run) + + if args.apply_ssh: + apply_sshd_dropin(dry_run=args.dry_run, no_reload=args.no_reload) + + if args.apply_home: + apply_home_privacy( + dry_run=args.dry_run, + home_path=args.home_root, + exclude=excl, + ) + + if args.scaffold_jail is not None: + scaffold_jail_tree(args.scaffold_jail.strip(), dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -7,11 +7,14 @@ Contrato de provisionamento (ordem garantida após validação): 1. **Criar o usuário** — ``adduser --disabled-password``. 2. **Instalar a chave** — ``~/.ssh/authorized_keys`` com modos ``700`` / ``600``. 3. **Preparar public_html** — diretório ``755``, ``index.html`` estático ``644``. -4. **Copiar o skel** — o Debian copia ``/etc/skel`` para a home **durante** o passo 1; depois, - após ``public_html``, o script acrescenta ``README.md`` runv (português), sem apagar o que - veio do skel (use ``--force-readme`` para substituir). Prepare ``/etc/skel`` com ``skel.py`` +4. **Preparar public_gopher / public_gemini** — ``gophermap`` e ``index.gmi`` modelo (não + sobrescreve sem ``--force-gopher`` / ``--force-gemini``); symlink Gemini em + ``/var/gemini/users/<user>`` quando o diretório existir. +5. **Copiar o skel** — o Debian copia ``/etc/skel`` para a home **durante** o passo 1; depois, + após os diretórios públicos, o script acrescenta ``README.md`` runv (português), sem apagar o que + veio do skel (use ``--force-readme`` para substituir). Prepare ``/etc/skel`` com ``tools.py`` antes das contas, se for política do servidor. -5. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, site e README com modos +6. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, sites públicos e README com modos e donos corretos, antes da quota e da verificação final. Quota ext4, metadados JSON e logging seguem após estes passos. @@ -103,6 +106,8 @@ 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_GEMINI_HOST_PUBLIC: Final[str] = "runv.club" +GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") # Quota ext4 (valores padrão runv; limites em MiB = 1024² bytes → setquota usa kiB de 1024 B) DEFAULT_QUOTA_SOFT_MIB: Final[int] = 450 @@ -173,6 +178,13 @@ def validate_email(email: str) -> str: if email != email.strip(): raise ValidationError("email não pode ter espaços no início ou fim") e = email.strip() + at = e.count("@") + if at == 0: + raise ValidationError( + "indica um endereço com @, por exemplo nome@exemplo.org." + ) + if at != 1: + raise ValidationError("o email deve ter um único @.") if not EMAIL_PATTERN.fullmatch(e): raise ValidationError("formato de email inválido") return e @@ -393,6 +405,13 @@ e `644` nos ficheiros servidos. Tudo o que colocares em **`public_html`** pode ser lido pelo mundo via HTTP no endereço `~{username}/...`. Não coloques aí segredos, chaves privadas nem dados sensíveis. +## Gopher e Gemini (protocolos alternativos) + +- **Gopher:** edita `~/public_gopher/gophermap` (e outros ficheiros nessa pasta). URL típica: + `gopher://{DEFAULT_GEMINI_HOST_PUBLIC}/1/~{username}` (o caminho exacto depende do servidor). +- **Gemini:** edita `~/public_gemini/index.gmi`. URL típica: `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/` +- Mantém **755** nas pastas públicas e **644** nos ficheiros, para o servidor conseguir ler. + ## Comandos úteis na shell ```bash @@ -440,6 +459,130 @@ def prepare_public_html( raise SystemProvisionError(f"não foi possível ajustar dono de {index}: {e}") from e +def default_gophermap_text(username: str) -> str: + return f"""iBem-vindo ao teu espaço Gopher no runv.club. fake NULL 0 +iEdita este ficheiro em ~/public_gopher/gophermap para personalizares o menu. fake NULL 0 +iDocumentação: man gophermap (no pacote gophernicus). fake NULL 0 +""" + + +def default_gemini_index_gmi(username: str) -> str: + return f"""# ~{username} — runv.club (Gemini) + +Bem-vindo ao teu capsule em `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/`. + +Edita este ficheiro em `~/public_gemini/index.gmi`. Mantém pastas **755** e ficheiros **644**. + +## Dicas + +* Ficheiros `.gmi` são Texto Gemini. +* Não coloques segredos em diretórios públicos. +""" + + +def prepare_public_gopher( + home: Path, + username: str, + uid: int, + gid: int, + force_gopher: bool, + log: logging.Logger, +) -> None: + d = home / "public_gopher" + d.mkdir(parents=True, exist_ok=True) + os.chmod(d, 0o755) + try: + os.chown(d, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {d}: {e}") from e + gmap = d / "gophermap" + if gmap.exists() and not force_gopher: + log.info("%s já existe; não sobrescrevendo (use --force-gopher)", gmap) + return + if gmap.exists() and force_gopher: + log.warning("sobrescrevendo %s (--force-gopher)", gmap) + gmap.write_text(default_gophermap_text(username), encoding="utf-8") + os.chmod(gmap, 0o644) + try: + os.chown(gmap, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {gmap}: {e}") from e + + +def prepare_public_gemini( + home: Path, + username: str, + uid: int, + gid: int, + force_gemini: bool, + log: logging.Logger, +) -> None: + d = home / "public_gemini" + d.mkdir(parents=True, exist_ok=True) + os.chmod(d, 0o755) + try: + os.chown(d, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {d}: {e}") from e + idx = d / "index.gmi" + if idx.exists() and not force_gemini: + log.info("%s já existe; não sobrescrevendo (use --force-gemini)", idx) + return + if idx.exists() and force_gemini: + log.warning("sobrescrevendo %s (--force-gemini)", idx) + idx.write_text(default_gemini_index_gmi(username), encoding="utf-8") + os.chmod(idx, 0o644) + try: + os.chown(idx, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar dono de {idx}: {e}") from e + + +def ensure_gemini_user_symlink( + username: str, + home: Path, + log: logging.Logger, + *, + force: bool, +) -> None: + """Cria /var/gemini/users/<user> -> <home>/public_gemini se o diretório global existir.""" + target = (home / "public_gemini").resolve() + if not GEMINI_USERS_DIR.is_dir(): + log.warning( + "diretório %s inexistente — symlink Gemini não criado. " + "Execute scripts/admin/setup_alt_protocols.py no servidor.", + GEMINI_USERS_DIR, + ) + return + link = GEMINI_USERS_DIR / username + if link.is_symlink(): + if link.resolve() == target: + log.info("symlink Gemini já correto: %s", link) + return + if force: + link.unlink() + log.info("symlink Gemini antigo removido: %s", link) + else: + log.warning( + "symlink %s aponta para %s (esperado %s); não altero sem --force-gemini", + link, + link.resolve(), + target, + ) + return + elif link.exists(): + if not force: + log.warning("%s existe e não é symlink; não sobrescrevo sem --force-gemini", link) + return + if link.is_dir(): + shutil.rmtree(link) + else: + link.unlink() + log.info("removido destino em conflito para symlink Gemini: %s", link) + link.symlink_to(target, target_is_directory=True) + log.info("symlink Gemini: %s -> %s", link, target) + + def prepare_user_readme( home: Path, username: str, @@ -659,6 +802,31 @@ def apply_runv_permissions(home: Path, uid: int, gid: int) -> None: except PermissionError as e: raise SystemProvisionError(f"não foi possível ajustar permissões de {readme}: {e}") from e + for label, path in ( + ("public_gopher", home / "public_gopher"), + ("public_gemini", home / "public_gemini"), + ): + if path.is_dir(): + try: + os.chmod(path, 0o755) + os.chown(path, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {path}: {e}") from e + gmap = home / "public_gopher" / "gophermap" + if gmap.is_file(): + try: + os.chmod(gmap, 0o644) + os.chown(gmap, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {gmap}: {e}") from e + gmi = home / "public_gemini" / "index.gmi" + if gmi.is_file(): + try: + os.chmod(gmi, 0o644) + os.chown(gmi, uid, gid) + except PermissionError as e: + raise SystemProvisionError(f"não foi possível ajustar permissões de {gmi}: {e}") from e + def verify_user_artifact_permissions(home: Path, uid: int, gid: int) -> None: """ @@ -670,6 +838,10 @@ def verify_user_artifact_permissions(home: Path, uid: int, gid: int) -> None: (home / ".ssh" / "authorized_keys", 0o600, "authorized_keys"), (home / "public_html", 0o755, "public_html"), (home / "public_html" / "index.html", 0o644, "index.html"), + (home / "public_gopher", 0o755, "public_gopher"), + (home / "public_gopher" / "gophermap", 0o644, "gophermap"), + (home / "public_gemini", 0o755, "public_gemini"), + (home / "public_gemini" / "index.gmi", 0o644, "index.gmi"), (home / "README.md", 0o644, "README.md"), ] for path, want_mode, label in checks: @@ -986,12 +1158,22 @@ def interactive_fill(args: argparse.Namespace) -> None: "Se já existir ~/public_html/index.html, sobrescrever (--force-index)?", default_no=True, ) + args.force_gopher = prompt_yes_no( + "Se já existir ~/public_gopher/gophermap, sobrescrever (--force-gopher)?", + default_no=True, + ) + args.force_gemini = prompt_yes_no( + "Se já existir ~/public_gemini/index.gmi, sobrescrever (--force-gemini)?", + default_no=True, + ) args.force_readme = prompt_yes_no( "Se já existir ~/README.md, sobrescrever (--force-readme)?", default_no=True, ) else: args.force_index = False + args.force_gopher = False + args.force_gemini = False args.force_readme = False args.verbose = prompt_yes_no("Log verboso no terminal?", default_no=True) @@ -1070,6 +1252,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="sobrescrever ~/README.md se já existir", ) p.add_argument( + "--force-gopher", + action="store_true", + help="sobrescrever ~/public_gopher/gophermap se já existir", + ) + p.add_argument( + "--force-gemini", + action="store_true", + help="sobrescrever ~/public_gemini/index.gmi e corrigir symlink em /var/gemini/users se necessário", + ) + p.add_argument( "--metadata-file", type=Path, default=DEFAULT_METADATA_PATH, @@ -1234,7 +1426,8 @@ def main(argv: list[str] | None = None) -> int: print(f" fingerprint: {fingerprint}") print( " ações: (1) adduser + /etc/skel (2) authorized_keys (3) public_html " - "(4) README.md (5) permissões consolidadas + quota (se ativa) + metadados JSON" + "(4) public_gopher + public_gemini + symlink Gemini (5) README.md " + "(6) permissões consolidadas + quota (se ativa) + metadados JSON" ) if args.no_quota: print(" quota: desativada (--no-quota)") @@ -1271,10 +1464,15 @@ def main(argv: list[str] | None = None) -> int: log.info("=== fase 3: public_html e index.html estático") prepare_public_html(home, user, uid, gid, args.force_index, log) + log.info("=== fase 3b: public_gopher (gophermap) e public_gemini (index.gmi)") + prepare_public_gopher(home, user, uid, gid, args.force_gopher, log) + prepare_public_gemini(home, user, uid, gid, args.force_gemini, log) + ensure_gemini_user_symlink(user, home, log, force=args.force_gemini) + log.info("=== fase 4: README.md runv (após skel /etc/skel do adduser; texto em português)") prepare_user_readme(home, user, uid, gid, args.base_url, args.force_readme, log) - log.info("=== fase 5: permissões consolidadas (home, .ssh, site, README)") + log.info("=== fase 5: permissões consolidadas (home, .ssh, sites públicos, README)") apply_runv_permissions(home, uid, gid) log.info("=== fase: quota (setquota em ext4 com usrquota)") @@ -1345,6 +1543,9 @@ def main(argv: list[str] | None = None) -> int: print(f" home: {home}") print(" ssh: authorized_keys instalado") print(" public_html: pronto (index.html estático)") + print(" public_gopher: pronto (gophermap)") + print(" public_gemini: pronto (index.gmi)") + print(" symlink Gemini: /var/gemini/users/<user> (se o diretório existir)") print(" README.md: criado em ~/README.md (pt-BR)") print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") print(f" fingerprint: {fingerprint}") diff --git a/scripts/admin/del-user.py b/scripts/admin/del-user.py @@ -152,6 +152,33 @@ def confirm_interactive(username: str) -> bool: # --------------------------------------------------------------------------- +# Gemini (symlink em /var/gemini/users) +# --------------------------------------------------------------------------- + +GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") + + +def remove_gemini_user_symlink(username: str, *, dry_run: bool, verbose: bool) -> None: + """Remove apenas o symlink /var/gemini/users/<user> se existir e for symlink.""" + link = GEMINI_USERS_DIR / username + if not link.is_symlink(): + if link.exists() and verbose: + print( + f" [aviso] {link} existe mas não é symlink; não removo automaticamente.", + file=sys.stderr, + ) + return + if dry_run: + print(f" [dry-run] removeria symlink Gemini {link}") + return + try: + link.unlink() + print(f" [ok] symlink Gemini removido: {link}") + except OSError as e: + print(f" [aviso] não foi possível remover {link}: {e}", file=sys.stderr) + + +# --------------------------------------------------------------------------- # deluser # --------------------------------------------------------------------------- @@ -428,6 +455,7 @@ def main() -> int: verbose=args.verbose, dry_run=True, ) + remove_gemini_user_symlink(username, dry_run=True, verbose=args.verbose) run_deluser( username, purge_all_files=args.purge_all_files, @@ -461,6 +489,8 @@ def main() -> int: dry_run=False, ) + remove_gemini_user_symlink(username, dry_run=False, verbose=args.verbose) + run_deluser( username, purge_all_files=args.purge_all_files, diff --git a/scripts/admin/patch_irc.py b/scripts/admin/patch_irc.py @@ -0,0 +1,646 @@ +#!/usr/bin/env python3 +""" +Provisiona a rede IRC da casa (estilo tilde.club) e o comando «chat» para utilizadores. + +- Config em ~/.config/weechat (XDG), servidor interno «runv», autoconnect. +- Aplicação **só** via binário ``weechat-headless`` (-a, -r, --stdout); não usar cliente interactivo no patch. +- Instala /usr/local/bin/chat (launcher) salvo --skip-launcher. + +MOTD e runv-help referem apenas **chat** (sem expor outros nomes de comando ao utilizador). + +Executar como root no Debian. Ver scripts/docs/irc_patch.md. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import pwd +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Final + +# --------------------------------------------------------------------------- +# SASL / NickServ (futuro) +# --------------------------------------------------------------------------- +# Não gravar senhas em texto plano. Para SASL, usar depois comandos WeeChat + dados +# seguros (sec.conf), por exemplo: +# /set irc.server.<name>.sasl_mechanism plain +# /secure set runv_irc_senha ... +# /set irc.server.<name>.sasl_password "${sec.data.runv_irc_senha}" +# Documentação: https://weechat.org/doc/ + +VERSION: Final[str] = "0.01" + +DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") +DEFAULT_HOMES_ROOT: Final[Path] = Path("/home") +DEFAULT_HOST: Final[str] = "irc.portalidea.com.br" +DEFAULT_SERVER_NAME: Final[str] = "runv" + +MIN_UID_USER: Final[int] = 1000 + +IRC_PATCH_SKIP_USERS: Final[frozenset[str]] = frozenset( + { + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "_apt", + "nobody", + "pmurad-admin", + "entre", + "admin", + "postmaster", + } +) + +CHAT_DEST: Final[Path] = Path("/usr/local/bin/chat") + + +def setup_logging(verbose: bool) -> logging.Logger: + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(levelname)s: %(message)s", + ) + return logging.getLogger("patch_irc") + + +def require_root(log: logging.Logger) -> None: + if os.geteuid() != 0: + log.error("Execute como root (sudo).") + sys.exit(1) + + +def run_cmd( + cmd: list[str], + *, + dry_run: bool, + log: logging.Logger, + timeout: int = 180, +) -> subprocess.CompletedProcess[str] | None: + log.debug("exec: %s", " ".join(cmd)) + if dry_run: + log.info("[dry-run] %s", " ".join(cmd)) + return None + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent.parent + + +def launcher_source_path() -> Path: + return repo_root() / "tools" / "bin" / "chat" + + +def embedded_launcher_text() -> str: + return """#!/bin/sh +# runv.club — fallback mínimo (preferir tools/bin/chat do repositório) +IRC_UI="" +for c in weechat weechat-curses; do + command -v "$c" >/dev/null 2>&1 && IRC_UI=$c && break +done +if [ -z "$IRC_UI" ]; then + echo "runv: cliente IRC interactivo não encontrado; corra tools/tools.py." >&2 + exit 127 +fi +CONFIG_DIR="${WEECHAT_HOME:-$HOME/.config/weechat}" +exec "$IRC_UI" -d "$CONFIG_DIR" "$@" +""" + + +def install_chat_launcher(*, dry_run: bool, log: logging.Logger) -> bool: + src = launcher_source_path() + if dry_run: + log.info("[dry-run] instalaria %s -> %s", src if src.is_file() else "(embutido)", CHAT_DEST) + return True + CHAT_DEST.parent.mkdir(parents=True, exist_ok=True) + if src.is_file(): + shutil.copy2(src, CHAT_DEST) + else: + log.warning("origem %s inexistente; escrevo launcher mínimo embutido", src) + CHAT_DEST.write_text(embedded_launcher_text(), encoding="utf-8") + os.chmod(CHAT_DEST, 0o755) + try: + os.chown(CHAT_DEST, 0, 0) + except OSError as e: + log.warning("chown em %s: %s", CHAT_DEST, e) + log.info("launcher: %s", CHAT_DEST) + return True + + +def find_weechat_headless(log: logging.Logger) -> str | None: + """Apenas weechat-headless — o patch não usa cliente interactivo.""" + p = shutil.which("weechat-headless") + if p: + log.debug("binário de provisionamento IRC: %s", p) + return p + + +def load_usernames_from_json(path: Path, log: logging.Logger) -> list[str] | None: + if not path.is_file(): + return None + try: + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return [] + data = json.loads(raw) + if not isinstance(data, list): + log.warning("%s: JSON não é lista; ignoro.", path) + return None + names: list[str] = [] + for item in data: + if isinstance(item, dict): + u = item.get("username") + if isinstance(u, str) and u: + names.append(u) + return sorted(set(names)) + except (json.JSONDecodeError, OSError) as e: + log.warning("falha ao ler %s: %s — uso fallback /home", path, e) + return None + + +def usernames_from_homes(homes_root: Path, log: logging.Logger) -> list[str]: + names: list[str] = [] + if not homes_root.is_dir(): + log.warning("homes_root inexistente: %s", homes_root) + return [] + for entry in sorted(homes_root.iterdir()): + if not entry.is_dir() or entry.name.startswith("."): + continue + try: + pw = pwd.getpwnam(entry.name) + except KeyError: + continue + if pw.pw_uid < MIN_UID_USER: + continue + if entry.name in IRC_PATCH_SKIP_USERS: + continue + names.append(entry.name) + return sorted(set(names)) + + +def resolve_all_users(users_json: Path, homes_root: Path, log: logging.Logger) -> list[str]: + from_json = load_usernames_from_json(users_json, log) + if from_json is not None and from_json: + log.info("utilizadores a partir de %s (%d)", users_json, len(from_json)) + return [u for u in from_json if u not in IRC_PATCH_SKIP_USERS] + if from_json is not None and from_json == []: + log.info("%s vazio — fallback /home", users_json) + users = usernames_from_homes(homes_root, log) + log.info("utilizadores a partir de %s (%d)", homes_root, len(users)) + return users + + +def weechat_config_dir(home: Path) -> Path: + return home / ".config" / "weechat" + + +def parse_server_options(irc_conf_text: str, server: str) -> dict[str, str]: + opts: dict[str, str] = {} + in_server = False + prefix = f"{server}." + for raw in irc_conf_text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line == "[server]": + in_server = True + continue + if line.startswith("[") and line.endswith("]"): + in_server = False + continue + if not in_server: + continue + if not line.startswith(prefix): + continue + key_part, _, rest = line.partition("=") + key_part = key_part.strip() + val = rest.strip() + if len(key_part) <= len(prefix): + continue + sub = key_part[len(prefix) :] + if val.startswith('"') and val.endswith('"') and len(val) >= 2: + val = val[1:-1] + opts[sub] = val + return opts + + +def tls_effective(opts: dict[str, str]) -> bool: + v = (opts.get("tls") or opts.get("ssl") or "off").lower() + return v in ("on", "true", "yes", "1") + + +def expected_nicks(username: str) -> str: + return f"{username},{username}_,{username}__,{username}|away" + + +def config_matches( + irc_conf: Path, + *, + server: str, + host: str, + port: int, + tls: bool, + username: str, + autojoin: str, + log: logging.Logger, +) -> bool: + if not irc_conf.is_file(): + return False + try: + text = irc_conf.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log.debug("ler %s: %s", irc_conf, e) + return False + opts = parse_server_options(text, server) + if "addresses" not in opts: + return False + addr = opts["addresses"].lower() + expect_addr = f"{host.lower()}/{port}" + if addr != expect_addr: + log.debug("addresses %r != %r", addr, expect_addr) + return False + if tls_effective(opts) != tls: + log.debug("tls/ssl diverge") + return False + if opts.get("nicks") != expected_nicks(username): + log.debug("nicks divergem") + return False + if (opts.get("username") or "") != username: + return False + if (opts.get("realname") or "") != username: + return False + ac = (opts.get("autoconnect") or "off").lower() + if ac not in ("on", "true", "yes", "1"): + return False + aj = opts.get("autojoin") or "" + if aj != autojoin: + log.debug("autojoin %r != %r", aj, autojoin) + return False + return True + + +def build_apply_command_chain( + *, + server: str, + host: str, + port: int, + tls: bool, + username: str, + autojoin: str, +) -> str: + add_tokens = [f"/server add {server} {host}/{port}"] + if tls: + add_tokens.append("-tls") + add_tokens.append("-autoconnect") + parts: list[str] = [" ".join(add_tokens)] + nicks = expected_nicks(username) + parts.append(f'/set irc.server.{server}.nicks "{nicks}"') + parts.append(f'/set irc.server.{server}.username "{username}"') + parts.append(f'/set irc.server.{server}.realname "{username}"') + parts.append(f"/set irc.server.{server}.autoconnect on") + if autojoin: + parts.append(f'/set irc.server.{server}.autojoin "{autojoin}"') + else: + parts.append(f'/set irc.server.{server}.autojoin ""') + parts.append("/save") + parts.append("/quit") + return " ; ".join(parts) + + +def ensure_xdg_weechat_dir(home: Path, uid: int, gid: int, log: logging.Logger, dry_run: bool) -> Path: + xdg = home / ".config" + weechat_d = weechat_config_dir(home) + if dry_run: + log.info("[dry-run] garantiria dirs %s e %s (700, dono %d:%d)", xdg, weechat_d, uid, gid) + return weechat_d + if not home.is_dir(): + raise FileNotFoundError(f"home inexistente: {home}") + if not xdg.is_dir(): + xdg.mkdir(parents=True, exist_ok=True) + os.chmod(xdg, 0o700) + os.chown(xdg, uid, gid) + elif xdg.stat().st_uid != uid: + log.warning("%s não pertence a uid %d; não altero dono do .config inteiro", xdg, uid) + if not weechat_d.is_dir(): + weechat_d.mkdir(parents=True, exist_ok=True) + os.chmod(weechat_d, 0o700) + os.chown(weechat_d, uid, gid) + else: + os.chmod(weechat_d, 0o700) + try: + os.chown(weechat_d, uid, gid) + except OSError as e: + log.warning("chown %s: %s", weechat_d, e) + return weechat_d + + +def run_weechat_script( + *, + username: str, + home: Path, + weechat_bin: str, + command_chain: str, + dry_run: bool, + log: logging.Logger, + allow_failure: bool = False, +) -> bool: + runuser = shutil.which("runuser") + if not runuser: + log.error("runuser não encontrado (pacote util-linux).") + return False + weechat_dir = weechat_config_dir(home) + cmd: list[str] = [ + runuser, + "-u", + username, + "--", + weechat_bin, + "-d", + str(weechat_dir), + "-a", + "--stdout", + "-r", + command_chain, + ] + r = run_cmd(cmd, dry_run=dry_run, log=log) + if dry_run: + return True + assert r is not None + out = (r.stdout or "") + (r.stderr or "") + if r.returncode != 0: + msg = f"weechat-headless código {r.returncode} para {username}: {out.strip() or '(sem saída)'}" + if allow_failure: + log.debug("%s (ignorado)", msg) + return True + log.error("%s", msg) + return False + if out.strip(): + log.debug("weechat-headless saída (%s): %s", username, out.strip()[:2000]) + return True + + +def patch_user( + username: str, + *, + host: str, + port: int, + tls: bool, + server: str, + autojoin: str, + force: bool, + weechat_bin: str, + dry_run: bool, + log: logging.Logger, +) -> bool: + try: + pw = pwd.getpwnam(username) + except KeyError: + log.error("utilizador inexistente: %s", username) + return False + if username in IRC_PATCH_SKIP_USERS: + log.warning("utilizador reservado, ignorado: %s", username) + return False + if pw.pw_uid < MIN_UID_USER: + log.warning("UID < %d, ignorado: %s", MIN_UID_USER, username) + return False + + home = Path(pw.pw_dir) + uid, gid = pw.pw_uid, pw.pw_gid + try: + ensure_xdg_weechat_dir(home, uid, gid, log, dry_run) + except OSError as e: + log.error("%s: %s", username, e) + return False + + irc_conf = weechat_config_dir(home) / "irc.conf" + if ( + not force + and config_matches( + irc_conf, + server=server, + host=host, + port=port, + tls=tls, + username=username, + autojoin=autojoin, + log=log, + ) + ): + log.info("%s: servidor %s já coincide com o desejado — a saltar", username, server) + return True + + if not force and irc_conf.is_file() and parse_server_options( + irc_conf.read_text(encoding="utf-8", errors="replace"), server + ).get("addresses"): + log.warning( + "%s: servidor %s existe mas difere do alvo; use --force para reconfigurar", + username, + server, + ) + return False + + if force: + del_chain = f"/server del {server} ; /quit" + log.info("%s: remover servidor %s existente (--force)", username, server) + run_weechat_script( + username=username, + home=home, + weechat_bin=weechat_bin, + command_chain=del_chain, + dry_run=dry_run, + log=log, + allow_failure=True, + ) + + chain = build_apply_command_chain( + server=server, + host=host, + port=port, + tls=tls, + username=username, + autojoin=autojoin, + ) + log.info("%s: aplicar configuração IRC — servidor «%s» (weechat-headless)", username, server) + ok = run_weechat_script( + username=username, + home=home, + weechat_bin=weechat_bin, + command_chain=chain, + dry_run=dry_run, + log=log, + ) + if not ok: + return False + if not dry_run and irc_conf.is_file(): + try: + os.chown(irc_conf, uid, gid) + except OSError: + pass + return True + + +def validate_post( + sample_user: str | None, + server: str, + log: logging.Logger, +) -> None: + if not CHAT_DEST.is_file() or not os.access(CHAT_DEST, os.X_OK): + log.warning("validação: %s em falta ou não executável", CHAT_DEST) + else: + log.info("validação: launcher %s OK", CHAT_DEST) + if not sample_user: + return + try: + pw = pwd.getpwnam(sample_user) + except KeyError: + return + irc_conf = weechat_config_dir(Path(pw.pw_dir)) / "irc.conf" + if not irc_conf.is_file(): + log.warning("validação: %s sem %s", sample_user, irc_conf) + return + txt = irc_conf.read_text(encoding="utf-8", errors="replace") + if re.search(rf"^{re.escape(server)}\.addresses\s*=", txt, re.MULTILINE): + log.info("validação: %s tem %s.addresses em %s", sample_user, server, irc_conf) + else: + log.warning("validação: %s.addresses não encontrado em %s", server, irc_conf) + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Provisiona IRC (servidor runv, weechat-headless) e instala o comando chat.", + ) + p.add_argument("--dry-run", action="store_true", help="só mostrar o plano") + p.add_argument("--verbose", action="store_true", help="log detalhado") + p.add_argument("--force", action="store_true", help="reconfigurar mesmo se existir servidor divergente") + p.add_argument("--skip-launcher", action="store_true", help="não instalar /usr/local/bin/chat") + p.add_argument("--skip-backfill", action="store_true", help="não aplicar config por utilizador") + p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON, metavar="PATH") + p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT, metavar="PATH") + p.add_argument("--host", default=DEFAULT_HOST, help="hostname IRC") + p.add_argument( + "--port", + type=int, + default=None, + metavar="PORT", + help="porta (omissão: 6697 com TLS, 6667 sem TLS)", + ) + tls_g = p.add_mutually_exclusive_group() + tls_g.add_argument("--tls", dest="tls", action="store_true", help="usar TLS (padrão)") + tls_g.add_argument("--no-tls", dest="tls", action="store_false", help="IRC sem TLS") + p.set_defaults(tls=True) + p.add_argument( + "--server-name", + default=DEFAULT_SERVER_NAME, + metavar="NAME", + help="nome interno na config IRC (equivalente a /server add …)", + ) + p.add_argument( + "--autojoin", + default="", + metavar="CHANNELS", + help='canais separados por vírgula, ex.: "#runv,#geral" (vazio = nenhum)', + ) + ug = p.add_mutually_exclusive_group(required=True) + ug.add_argument("--user", metavar="USER", help="apenas este utilizador Unix") + ug.add_argument("--all-users", action="store_true", help="todos os utilizadores válidos") + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + log = setup_logging(args.verbose) + + if args.port is None: + port = 6697 if args.tls else 6667 + else: + port = args.port + + if not args.dry_run: + require_root(log) + else: + log.info("dry-run: não grava alterações.") + + if not args.skip_launcher: + install_chat_launcher(dry_run=args.dry_run, log=log) + + weechat_bin = find_weechat_headless(log) + if not args.skip_backfill and not weechat_bin: + log.error( + "weechat-headless não encontrado no PATH; instale o pacote Debian «weechat-headless» (ex.: apt).", + ) + return 1 + + if args.all_users: + users = resolve_all_users(args.users_json, args.homes_root, log) + else: + assert args.user is not None + users = [args.user] + + failures = 0 + if not args.skip_backfill: + assert weechat_bin is not None + for u in users: + if u in IRC_PATCH_SKIP_USERS: + log.warning("ignorado (reservado): %s", u) + continue + ok = patch_user( + u, + host=args.host, + port=port, + tls=args.tls, + server=args.server_name, + autojoin=args.autojoin.strip(), + force=args.force, + weechat_bin=weechat_bin, + dry_run=args.dry_run, + log=log, + ) + if not ok: + failures += 1 + else: + log.info("backfill ignorado (--skip-backfill).") + + sample = users[0] if users else None + validate_post(sample, args.server_name, log) + + print() + print("========== patch_irc — resumo ==========") + print(f"Modo: {'DRY-RUN' if args.dry_run else 'aplicação'}") + print(f"Host: {args.host}:{port} TLS: {args.tls} servidor na config: {args.server_name}") + if not args.skip_backfill: + print(f"Utilizadores processados: {len(users)} falhas: {failures}") + print("Comando para utilizadores: chat") + print("========================================") + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/admin/setup_alt_protocols.py b/scripts/admin/setup_alt_protocols.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +""" +Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club. + +- Gopher: raiz em /var/gopher, espaços de utilizador em ~/public_gopher (gophermap). +- Gemini: DocBase /var/gemini, symlinks /var/gemini/users/<user> -> ~/public_gemini. + +Idempotente, dry-run, subprocess sem shell. Executar como root no Debian. + +Versão 0.01 — runv.club +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import pwd +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Final + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +VERSION: Final[str] = "0.01" + +DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") +DEFAULT_HOMES_ROOT: Final[Path] = Path("/home") +DEFAULT_GEMINI_HOSTNAME: Final[str] = "runv.club" +DEFAULT_LE_CERT: Final[Path] = Path("/etc/letsencrypt/live/runv.club/fullchain.pem") +DEFAULT_LE_KEY: Final[Path] = Path("/etc/letsencrypt/live/runv.club/privkey.pem") + +GOPHER_ROOT: Final[Path] = Path("/var/gopher") +GEMINI_ROOT: Final[Path] = Path("/var/gemini") +GEMINI_USERS: Final[Path] = GEMINI_ROOT / "users" + +GOPHER_DEFAULT_PATH: Final[Path] = Path("/etc/default/gophernicus") +GOPHER_SYSTEMD_SERVICE: Final[Path] = Path("/lib/systemd/system/gophernicus@.service") +MOLLY_CONF_DIR: Final[Path] = Path("/etc/molly-brown") +MOLLY_INSTANCE: Final[str] = "runv.club" # molly-brown@runv.club.service + +PACKAGES_GOPHER: Final[tuple[str, ...]] = ("gophernicus",) +PACKAGES_GEMINI: Final[tuple[str, ...]] = ("molly-brown",) + +MIN_UID_USER: Final[int] = 1000 + +ALT_PROTOCOL_SKIP_USERS: Final[frozenset[str]] = frozenset( + { + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "_apt", + "nobody", + "pmurad-admin", + "entre", + "admin", + "postmaster", + } +) + +DEFAULT_ROOT_GOPHERMAP: Final[str] = """iBem-vindo ao Gopher em runv.club — pubnix. fake NULL 0 +iCada utilizador com ~/public_gopher aparece como ~user no menu do servidor. fake NULL 0 +""" + +DEFAULT_USER_GOPHERMAP: Final[str] = """iBem-vindo ao teu espaço Gopher no runv.club. fake NULL 0 +iEdita este ficheiro em ~/public_gopher/gophermap. fake NULL 0 +""" + +DEFAULT_USER_INDEX_GMI: Final[str] = """# ~{username} — runv.club (Gemini) + +Bem-vindo ao teu capsule em `gemini://runv.club/~{username}/`. + +Edita este ficheiro em `~/public_gemini/index.gmi`. Mantém pastas **755** e ficheiros **644** para o servidor ler o conteúdo. + +## Dicas + +* Ficheiros `.gmi` são Texto Gemini. +* Não coloques segredos em diretórios públicos. +""" + + +# --------------------------------------------------------------------------- +# Utilitários +# --------------------------------------------------------------------------- + + +def setup_logging(verbose: bool) -> logging.Logger: + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(levelname)s: %(message)s", + ) + return logging.getLogger("setup_alt_protocols") + + +def run_cmd( + cmd: list[str], + *, + dry_run: bool, + log: logging.Logger, + timeout: int = 600, +) -> subprocess.CompletedProcess[str] | None: + log.debug("exec: %s", " ".join(cmd)) + if dry_run: + log.info("[dry-run] %s", " ".join(cmd)) + return None + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def backup_if_exists(path: Path, log: logging.Logger, dry_run: bool) -> None: + if not path.is_file(): + return + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + bak = path.with_suffix(path.suffix + f".bak.{ts}") + if dry_run: + log.info("[dry-run] faria backup %s -> %s", path, bak) + return + shutil.copy2(path, bak) + log.info("backup: %s -> %s", path, bak) + + +def infer_gopher_env_key(service_path: Path) -> str: + if not service_path.is_file(): + return "OPTIONS" + text = service_path.read_text(encoding="utf-8", errors="replace") + m = re.search(r"ExecStart=.*?\$(\w+)", text, re.MULTILINE | re.DOTALL) + if m: + return m.group(1) + return "OPTIONS" + + +def default_gopher_options(hostname: str) -> str: + return f'-h {hostname} -r {GOPHER_ROOT} -u public_gopher -o UTF-8' + + +def write_gophernicus_default( + path: Path, + options_value: str, + *, + env_key: str, + dry_run: bool, + log: logging.Logger, + force: bool, +) -> None: + lines: list[str] = [] + if path.is_file() and not force: + raw = path.read_text(encoding="utf-8", errors="replace").splitlines() + replaced = False + opt_re = re.compile(rf"^{re.escape(env_key)}=") + for line in raw: + if opt_re.match(line.strip()): + lines.append(f'{env_key}="{options_value}"') + replaced = True + else: + lines.append(line) + if not replaced: + lines.append(f'{env_key}="{options_value}"') + content = "\n".join(lines).rstrip() + "\n" + else: + content = ( + f"# runv.club — gerido por setup_alt_protocols.py\n" + f"# Ver: man gophernicus (8)\n\n" + f'{env_key}="{options_value}"\n' + ) + if dry_run: + log.info("[dry-run] gravaria %s (%s=...)", path, env_key) + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + os.chmod(path, 0o644) + log.info("atualizado: %s", path) + + +def molly_brown_conf_text( + *, + hostname: str, + cert: Path, + key: Path, +) -> str: + return f"""# runv.club — gerido por setup_alt_protocols.py +Hostname = "{hostname}" +Port = 1965 +DocBase = "{GEMINI_ROOT.as_posix()}" +HomeDocBase = "users" +CertPath = "{cert.as_posix()}" +KeyPath = "{key.as_posix()}" +AccessLog = "-" +ErrorLog = "-" +GeminiExt = "gmi" +ReadMollyFiles = true +""" + + +def load_usernames_from_json(path: Path, log: logging.Logger) -> list[str] | None: + if not path.is_file(): + return None + try: + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return [] + data = json.loads(raw) + if not isinstance(data, list): + log.warning("%s: JSON não é lista; ignoro.", path) + return None + names: list[str] = [] + for item in data: + if isinstance(item, dict): + u = item.get("username") + if isinstance(u, str) and u: + names.append(u) + return sorted(set(names)) + except (json.JSONDecodeError, OSError) as e: + log.warning("falha ao ler %s: %s — uso fallback /home", path, e) + return None + + +def usernames_from_homes(homes_root: Path, log: logging.Logger) -> list[str]: + names: list[str] = [] + if not homes_root.is_dir(): + log.warning("homes_root inexistente: %s", homes_root) + return [] + for entry in sorted(homes_root.iterdir()): + if not entry.is_dir() or entry.name.startswith("."): + continue + try: + pw = pwd.getpwnam(entry.name) + except KeyError: + continue + if pw.pw_uid < MIN_UID_USER: + continue + if entry.name in ALT_PROTOCOL_SKIP_USERS: + continue + names.append(entry.name) + return sorted(set(names)) + + +def resolve_user_list( + users_json: Path, + homes_root: Path, + log: logging.Logger, +) -> list[str]: + from_json = load_usernames_from_json(users_json, log) + if from_json is not None and from_json: + log.info("utilizadores a partir de %s (%d)", users_json, len(from_json)) + return [u for u in from_json if u not in ALT_PROTOCOL_SKIP_USERS] + if from_json is not None and from_json == []: + log.info("%s vazio — fallback /home", users_json) + users = usernames_from_homes(homes_root, log) + log.info("utilizadores a partir de %s (%d)", homes_root, len(users)) + return users + + +def ensure_user_public_dirs( + username: str, + homes_root: Path, + *, + force: bool, + dry_run: bool, + log: logging.Logger, +) -> None: + try: + pw = pwd.getpwnam(username) + except KeyError: + log.warning("utilizador %s não existe no sistema — salto backfill", username) + return + home = Path(pw.pw_dir) + uid, gid = pw.pw_uid, pw.pw_gid + gdir = home / "public_gopher" + gmap = gdir / "gophermap" + xdir = home / "public_gemini" + xidx = xdir / "index.gmi" + + if dry_run: + log.info("[dry-run] garantiria ~/public_gopher e ~/public_gemini para %s", username) + return + + gdir.mkdir(parents=True, exist_ok=True) + xdir.mkdir(parents=True, exist_ok=True) + os.chmod(gdir, 0o755) + os.chmod(xdir, 0o755) + os.chown(gdir, uid, gid) + os.chown(xdir, uid, gid) + + if not gmap.exists() or force: + if gmap.exists() and force: + backup_if_exists(gmap, log, dry_run=False) + gmap.write_text(DEFAULT_USER_GOPHERMAP, encoding="utf-8") + os.chmod(gmap, 0o644) + os.chown(gmap, uid, gid) + log.info("gophermap: %s", gmap) + else: + log.debug("gophermap já existe, mantido: %s", gmap) + + if not xidx.exists() or force: + if xidx.exists() and force: + backup_if_exists(xidx, log, dry_run=False) + xidx.write_text( + DEFAULT_USER_INDEX_GMI.format(username=username), + encoding="utf-8", + ) + os.chmod(xidx, 0o644) + os.chown(xidx, uid, gid) + log.info("index.gmi: %s", xidx) + else: + log.debug("index.gmi já existe, mantido: %s", xidx) + + +def ensure_gemini_symlink( + username: str, + homes_root: Path, + *, + force: bool, + dry_run: bool, + log: logging.Logger, +) -> None: + try: + pw = pwd.getpwnam(username) + except KeyError: + return + home = Path(pw.pw_dir) + target = (home / "public_gemini").resolve() + link = GEMINI_USERS / username + + if not GEMINI_USERS.is_dir(): + log.warning("GEMINI_USERS inexistente: %s — symlink não criado", GEMINI_USERS) + return + + if dry_run: + log.info("[dry-run] symlink %s -> %s", link, target) + return + + if link.is_symlink(): + cur = link.resolve() + if cur == target: + log.debug("symlink OK: %s", link) + return + if force: + link.unlink() + log.info("symlink antigo removido: %s", link) + else: + log.warning("symlink %s aponta para %s (esperado %s); use --force", link, cur, target) + return + elif link.exists(): + log.warning("%s existe e não é symlink; não sobrescrevo sem --force", link) + if force: + if link.is_dir(): + shutil.rmtree(link) + else: + link.unlink() + else: + return + + link.symlink_to(target, target_is_directory=True) + log.info("symlink: %s -> %s", link, target) + + +def apt_install( + packages: tuple[str, ...], + *, + dry_run: bool, + log: logging.Logger, +) -> bool: + env = {"DEBIAN_FRONTEND": "noninteractive", "LC_ALL": "C"} + r1 = run_cmd(["apt-get", "update", "-qq"], dry_run=dry_run, log=log) + if not dry_run and r1 is not None and r1.returncode != 0: + log.error("apt-get update falhou: %s", (r1.stderr or r1.stdout or "").strip()) + return False + cmd = ["apt-get", "install", "-y", "--no-install-recommends", *packages] + r2 = run_cmd(cmd, dry_run=dry_run, log=log) + if dry_run: + return True + if r2 is None or r2.returncode != 0: + log.error("apt-get install falhou: %s", (r2.stderr or r2.stdout or "").strip() if r2 else "") + return False + return True + + +def dpkg_installed(package: str) -> bool: + r = subprocess.run( + ["dpkg", "-s", package], + capture_output=True, + text=True, + timeout=30, + ) + return r.returncode == 0 and "Status: install ok installed" in (r.stdout or "") + + +def ufw_maybe_allow( + ports: list[tuple[int, str]], + *, + dry_run: bool, + log: logging.Logger, + skip_firewall: bool, +) -> None: + if skip_firewall: + log.info("firewall ignorado (--skip-firewall)") + return + r = subprocess.run( + ["ufw", "status"], + capture_output=True, + text=True, + timeout=30, + ) + out = (r.stdout or "").lower() + if r.returncode != 0 or "status: active" not in out: + log.warning( + "UFW não está ativo (ou comando falhou). Não abro portas automaticamente. " + "Abra 70/tcp (Gopher) e 1965/tcp (Gemini) se usar firewall." + ) + return + for port, label in ports: + cmd = ["ufw", "allow", f"{port}/tcp"] + run_cmd(cmd, dry_run=dry_run, log=log) + log.info("UFW: permitido %s/tcp (%s)", port, label) + + +def validate_final( + usernames: list[str], + log: logging.Logger, +) -> None: + log.info("--- validação final ---") + for pkg, label in (("gophernicus", "Gopher"), ("molly-brown", "Gemini")): + ok = dpkg_installed(pkg) + log.info("pacote %s (%s): %s", pkg, label, "OK" if ok else "AUSENTE") + + r = subprocess.run( + ["systemctl", "is-active", "gophernicus.socket"], + capture_output=True, + text=True, + timeout=30, + ) + log.info("gophernicus.socket: %s", (r.stdout or "").strip() or r.returncode) + + r2 = subprocess.run( + ["systemctl", "is-active", f"molly-brown@{MOLLY_INSTANCE}.service"], + capture_output=True, + text=True, + timeout=30, + ) + log.info("molly-brown@%s: %s", MOLLY_INSTANCE, (r2.stdout or "").strip() or r2.returncode) + + if usernames: + sample = usernames[0] + try: + pw = pwd.getpwnam(sample) + home = Path(pw.pw_dir) + for p, label in ( + (home / "public_gopher" / "gophermap", "gophermap"), + (home / "public_gemini" / "index.gmi", "index.gmi"), + ): + log.info("amostra %s %s: %s", sample, label, "OK" if p.is_file() else "FALTA") + sl = GEMINI_USERS / sample + ok_sl = sl.is_symlink() and sl.resolve() == (home / "public_gemini").resolve() + log.info("amostra symlink Gemini: %s", "OK" if ok_sl else "FALTA/INCORRETO") + except KeyError: + log.info("amostra %s: utilizador não existe neste sistema", sample) + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Instala/configura Gopher (gophernicus) e Gemini (molly-brown) para runv.club.", + ) + p.add_argument("--dry-run", action="store_true") + p.add_argument("--verbose", action="store_true") + p.add_argument("--force", action="store_true", help="sobrescreve configs e ficheiros modelo com backup") + p.add_argument("--skip-install", action="store_true") + p.add_argument("--skip-gopher", action="store_true") + p.add_argument("--skip-gemini", action="store_true") + p.add_argument("--skip-firewall", action="store_true") + p.add_argument("--skip-backfill", action="store_true") + p.add_argument("--skip-services", action="store_true") + p.add_argument("--skip-system-config", action="store_true") + p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON) + p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT) + p.add_argument("--gemini-hostname", default=DEFAULT_GEMINI_HOSTNAME) + p.add_argument("--gemini-cert", type=Path, default=None) + p.add_argument("--gemini-key", type=Path, default=None) + p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + log = setup_logging(args.verbose) + + if os.geteuid() != 0 and not args.dry_run: + log.error("Execute como root (sudo).") + return 1 + + cert = args.gemini_cert or DEFAULT_LE_CERT + key = args.gemini_key or DEFAULT_LE_KEY + + pkgs: list[str] = [] + if not args.skip_install: + if not args.skip_gopher: + pkgs.extend(PACKAGES_GOPHER) + if not args.skip_gemini: + pkgs.extend(PACKAGES_GEMINI) + pkgs = sorted(set(pkgs)) + if pkgs: + log.info("instalação apt: %s", ", ".join(pkgs)) + if not apt_install(tuple(pkgs), dry_run=args.dry_run, log=log): + return 1 + + if not args.skip_system_config: + env_key = infer_gopher_env_key(GOPHER_SYSTEMD_SERVICE) + opts = default_gopher_options(args.gemini_hostname) + + if not args.skip_gopher: + if args.force and GOPHER_DEFAULT_PATH.is_file(): + backup_if_exists(GOPHER_DEFAULT_PATH, log, args.dry_run) + write_gophernicus_default( + GOPHER_DEFAULT_PATH, + opts, + env_key=env_key, + dry_run=args.dry_run, + log=log, + force=args.force, + ) + if not args.dry_run: + GOPHER_ROOT.mkdir(parents=True, exist_ok=True) + os.chmod(GOPHER_ROOT, 0o755) + root_map = GOPHER_ROOT / "gophermap" + if not root_map.exists() or args.force: + if root_map.exists() and args.force: + backup_if_exists(root_map, log, dry_run=False) + root_map.write_text(DEFAULT_ROOT_GOPHERMAP, encoding="utf-8") + os.chmod(root_map, 0o644) + log.info("gophermap raiz: %s", root_map) + + if not args.dry_run: + GEMINI_ROOT.mkdir(parents=True, exist_ok=True) + GEMINI_USERS.mkdir(parents=True, exist_ok=True) + os.chmod(GEMINI_ROOT, 0o755) + os.chmod(GEMINI_USERS, 0o755) + try: + os.chown(GEMINI_ROOT, 0, 0) + os.chown(GEMINI_USERS, 0, 0) + except OSError as e: + log.warning("chown /var/gemini: %s", e) + + if not args.skip_gemini: + if not cert.is_file() or not key.is_file(): + log.error( + "Certificado ou chave TLS inexistentes (Gemini/molly-brown). " + "cert=%s key=%s — defina --gemini-cert / --gemini-key ou instale Let's Encrypt. " + "Pastas /var/gemini foram criadas; serviço Gemini não será ativado.", + cert, + key, + ) + else: + conf_path = MOLLY_CONF_DIR / f"{MOLLY_INSTANCE}.conf" + body = molly_brown_conf_text(hostname=args.gemini_hostname, cert=cert, key=key) + if args.dry_run: + log.info("[dry-run] gravaria %s", conf_path) + else: + MOLLY_CONF_DIR.mkdir(parents=True, exist_ok=True) + if conf_path.is_file() and args.force: + backup_if_exists(conf_path, log, dry_run=False) + if not conf_path.is_file() or args.force: + conf_path.write_text(body, encoding="utf-8") + os.chmod(conf_path, 0o644) + log.info("molly-brown: %s", conf_path) + + ufw_maybe_allow( + [(70, "gopher"), (1965, "gemini")], + dry_run=args.dry_run, + log=log, + skip_firewall=args.skip_firewall, + ) + + users = resolve_user_list(args.users_json, args.homes_root, log) + if not args.skip_backfill: + for u in users: + ensure_user_public_dirs( + u, + args.homes_root, + force=args.force, + dry_run=args.dry_run, + log=log, + ) + ensure_gemini_symlink( + u, + args.homes_root, + force=args.force, + dry_run=args.dry_run, + log=log, + ) + + if not args.skip_services: + if not args.dry_run: + run_cmd(["systemctl", "daemon-reload"], dry_run=False, log=log) + if not args.skip_gopher: + run_cmd( + ["systemctl", "enable", "--now", "gophernicus.socket"], + dry_run=args.dry_run, + log=log, + ) + if not args.skip_gemini and cert.is_file() and key.is_file(): + run_cmd( + [ + "systemctl", + "enable", + "--now", + f"molly-brown@{MOLLY_INSTANCE}.service", + ], + dry_run=args.dry_run, + log=log, + ) + + validate_final(users, log) + log.info("Concluído.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/create_runv_user.md b/scripts/create_runv_user.md @@ -13,8 +13,9 @@ Ferramenta de linha de comando para **administradores** criarem contas Unix no s 1. **Criar o usuário** — `adduser --disabled-password` (conta Unix). 2. **Instalar a chave** — `~/.ssh/authorized_keys` com chave validada e modos `700` / `600`. 3. **Preparar `public_html`** — diretório `755` e `~/public_html/index.html` estático (sem JavaScript, sem CDN); não sobrescreve sem `--force-index`. -4. **Copiar o skel** — o Debian **copia `/etc/skel` para a home no passo 1**. Depois, o script acrescenta `~/README.md` runv em português (runv.club, URL `~/username/`, permissões, comandos, aviso sobre arquivos públicos); não sobrescreve sem **`--force-readme`**. Se o skel do sistema já tiver um `README.md`, ele permanece até usar `--force-readme`. Para padronizar o skel do servidor, use **[`skel.py`](skel.md)** antes de criar contas. -5. **Aplicar permissões** — `apply_runv_permissions` reforça home `755`, `.ssh` / `authorized_keys`, `public_html` / `index.html` e `README.md` com modos e donos corretos; em seguida quota (se ativa), verificação final e metadados. +4. **Preparar Gopher e Gemini** — `~/public_gopher/` com `gophermap` e `~/public_gemini/` com `index.gmi` (modelos em português); não sobrescreve sem **`--force-gopher`** / **`--force-gemini`**. Se existir **`/var/gemini/users`**, cria o symlink **`/var/gemini/users/<user>` → `~/public_gemini`** (ajustável com `--force-gemini` se o symlink estiver errado). Se essa pasta global não existir, regista **aviso** no log — corra **[`setup_alt_protocols.py`](docs/alt_protocols.md)** no servidor. +5. **Copiar o skel** — o Debian **copia `/etc/skel` para a home no passo 1**. Depois, o script acrescenta `~/README.md` runv em português (runv.club, URL `~/username/`, permissões, comandos, aviso sobre arquivos públicos, Gopher/Gemini); não sobrescreve sem **`--force-readme`**. Se o skel do sistema já tiver um `README.md`, ele permanece até usar `--force-readme`. Para padronizar o skel do servidor, use **`tools/tools.py`** (ou `admin/skel.py`, conforme a política do servidor) antes de criar contas. +6. **Aplicar permissões** — `apply_runv_permissions` reforça home `755`, `.ssh` / `authorized_keys`, `public_html`, `public_gopher`, `public_gemini` e `README.md` com modos e donos corretos; em seguida quota (se ativa), verificação final e metadados. **Log** em arquivo (e stderr com `--verbose`) com estas fases numeradas, quota, metadados e verificação final. @@ -91,11 +92,13 @@ Fluxo típico: 3. Chave SSH: colar **uma linha** OpenSSH ou indicar **caminho** de um arquivo `.pub` 4. Dry-run (só validar, sem criar usuário) — sim/não 5. Se for criar de verdade: sobrescrever `index.html` existente — sim/não -6. Se for criar de verdade: sobrescrever `README.md` existente — sim/não -7. Log verboso — sim/não -8. Criar **sem** quota (`--no-quota`) — sim/não (padrão não) -9. Se for com quota: exigir sistema pronto **antes** de criar (`--require-quota`) — sim/não (padrão não) -10. Confirmação final antes de executar +6. Se for criar de verdade: sobrescrever `gophermap` existente (`--force-gopher`) — sim/não +7. Se for criar de verdade: sobrescrever `index.gmi` existente (`--force-gemini`) — sim/não +8. Se for criar de verdade: sobrescrever `README.md` existente — sim/não +9. Log verboso — sim/não +10. Criar **sem** quota (`--no-quota`) — sim/não (padrão não) +11. Se for com quota: exigir sistema pronto **antes** de criar (`--require-quota`) — sim/não (padrão não) +12. Confirmação final antes de executar `Ctrl+C` cancela. Se responder “não” na confirmação final, o script encerra sem alterar o sistema. @@ -197,6 +200,8 @@ Metadados: `/var/lib/runv/users.json` - `--dry-run` — valida tudo e mostra o plano sem criar usuário - `--verbose` — mais detalhes no stderr - `--force-index` — sobrescreve `~/public_html/index.html` se já existir +- `--force-gopher` — sobrescreve `~/public_gopher/gophermap` se já existir +- `--force-gemini` — sobrescreve `~/public_gemini/index.gmi` se já existir e corrige o symlink em `/var/gemini/users/<user>` se estiver errado - `--force-readme` — sobrescreve `~/README.md` se já existir (útil se o skel do sistema já criou um README) - `--no-quota` — não aplica `setquota` - `--require-quota` — falha antes de `adduser` se quota não estiver disponível diff --git a/scripts/del-user.md b/scripts/del-user.md @@ -6,6 +6,7 @@ Ferramenta para **administradores** removerem **permanentemente** um utilizador - **Não** remove nem altera configuração do Apache ou SSH globalmente. - Opcionalmente remove a entrada correspondente em `/var/lib/runv/users.json` (mesmo formato que `create_runv_user.py`). +- Remove o symlink **`/var/gemini/users/<user>`** se existir e for symlink (Gemini / molly-brown). - Se a home estiver num **ext4** com **usrquota** ativo, tenta **`setquota`** para repor limites a zero **antes** de `deluser` (mount detetado automaticamente, mesma lógica que `create_runv_user.py` / `runv_mount.py`). Se `setquota` falhar, a remoção da conta continua com aviso em stderr. **Ambiente:** servidor **Linux** (Debian). Executar como **root** ou `sudo`. No Windows use só para revisão do código. diff --git a/scripts/docs/alt_protocols.md b/scripts/docs/alt_protocols.md @@ -0,0 +1,74 @@ +# Gopher e Gemini — `setup_alt_protocols.py` + +Script em **`scripts/admin/setup_alt_protocols.py`**: instala e configura **gophernicus** (Gopher, porta **70**) e **molly-brown** (Gemini, TLS, porta **1965**) no Debian, alinhado ao runv.club. + +## Modelo de conteúdo + +| Protocolo | Pasta na home | Ficheiro inicial | URL típica | +|-----------|---------------|------------------|------------| +| **HTTP** (já existente) | `~/public_html/` | `index.html` | `http://runv.club/~user/` | +| **Gopher** | `~/public_gopher/` | `gophermap` | `gopher://runv.club/1/~user` | +| **Gemini** | `~/public_gemini/` | `index.gmi` | `gemini://runv.club/~user/` | + +**Gemini (molly-brown):** `DocBase = /var/gemini`, `HomeDocBase = users`, symlinks **`/var/gemini/users/<user>` → `~/public_gemini`**. + +## Utilizadores antigos vs novos + +- **Novos:** recebem modelos via **`/etc/skel`** (após `tools/tools.py`) e via **`create_runv_user.py`** (sempre que o provisionador corre). +- **Antigos:** correr **`setup_alt_protocols.py`** (backfill) ou criar pastas/ficheiros à mão. + +## Requisitos Gemini + +- **TLS obrigatório** (certificado + chave PEM). Por defeito o script tenta Let's Encrypt em `/etc/letsencrypt/live/runv.club/`; use **`--gemini-cert`** e **`--gemini-key`** se forem noutro sítio. +- Sem certificados válidos, o script **não** ativa o serviço `molly-brown@`, mas pode criar `/var/gemini` e symlinks. + +## Execução (root) + +```bash +cd /caminho/para/runv-server +sudo python3 scripts/admin/setup_alt_protocols.py --dry-run --verbose +sudo python3 scripts/admin/setup_alt_protocols.py --verbose +``` + +### Flags úteis + +| Flag | Efeito | +|------|--------| +| `--dry-run` | Simula; não grava (validação de root ignorada em alguns passos só se documentado). | +| `--verbose` | Log detalhado. | +| `--force` | Sobrescreve configs de sistema (com backup com timestamp) e ficheiros modelo no backfill. | +| `--skip-install` | Não corre `apt-get`. | +| `--skip-gopher` / `--skip-gemini` | Ignora pacote, config e serviço desse protocolo. | +| `--skip-firewall` | Não altera UFW. | +| `--skip-backfill` | Não cria pastas/symlinks por utilizador. | +| `--skip-services` | Não `systemctl enable --now`. | +| `--skip-system-config` | Não escreve `/etc/default/gophernicus`, nem `molly-brown`, nem gophermap raiz. | +| `--users-json PATH` | Fonte de usernames (lista JSON com `username`). Predefinido: `/var/lib/runv/users.json`. | +| `--homes-root PATH` | Fallback se JSON vazio/inexistente (varre UIDs ≥ 1000). | +| `--gemini-hostname HOST` | Predefinido: `runv.club`. | +| `--gemini-cert` / `--gemini-key` | Caminhos PEM para molly-brown. | + +## Descoberta de utilizadores (backfill) + +1. Se **`users.json`** existir e for uma lista JSON válida com objetos que tenham **`username`**, usa essa lista. +2. Caso contrário, varre **`--homes-root`** (predefinido `/home`), UIDs ≥ 1000, excluindo contas reservadas (`root`, `entre`, `pmurad-admin`, contas de sistema, etc.). + +## Relação com outros scripts + +- **`create_runv_user.py`**: após `public_html`, cria `public_gopher`, `public_gemini` e tenta o symlink em `/var/gemini/users/`. +- **`del-user.py`**: remove o symlink `/var/gemini/users/<user>` se existir e for symlink. +- **`tools/tools.py`**: copia modelos para `/etc/skel` (só contas futuras). + +## Testes manuais sugeridos + +1. `sudo python3 scripts/admin/setup_alt_protocols.py --dry-run --verbose` +2. `sudo python3 scripts/admin/setup_alt_protocols.py --verbose` +3. `dpkg -l gophernicus molly-brown` +4. `systemctl is-active gophernicus.socket` e `systemctl is-active molly-brown@runv.club.service` +5. `ufw status` (se ativo, confirmar 70/tcp e 1965/tcp permitidos) +6. Verificar `/etc/skel/public_gopher` e `public_gemini` após `tools.py` +7. Criar utilizador de teste com `create_runv_user.py` +8. `ls -la /home/teste/public_gopher/gophermap /home/teste/public_gemini/index.gmi` e `ls -la /var/gemini/users/teste` +9. Cliente Gopher/Gemini: `gopher://runv.club/1/~teste` e `gemini://runv.club/~teste/` + +Versão do script: ver `python3 scripts/admin/setup_alt_protocols.py --version`. diff --git a/scripts/docs/irc_patch.md b/scripts/docs/irc_patch.md @@ -0,0 +1,65 @@ +# IRC no runv.club — comando **`chat`** + +Estilo [tilde.club](https://tilde.club): o utilizador corre só **`chat`** e liga ao IRC da casa com auto-ligação já preparada. + +## Alinhamento (plano / produto) + +- **MOTD** (`tools/motd/60-runv`) e **`runv-help`** referem **apenas o comando `chat`** — sem citar outros nomes de binário ao utilizador. +- **Provisionamento** (`patch_irc.py`) usa **sempre** `weechat-headless` (`-a`, `-r`, `--stdout`): é o fluxo suportado para automatizar `/server add` e `/set` sem editar ficheiros à mão. +- O **cliente interactivo** no terminal é instalado pelos **pacotes globais** em `tools/manifests/apt_packages.txt` (o launcher `chat` escolhe o primeiro binário adequado no PATH); utilizadores continuam a ver só **`chat`**. + +## O que o admin faz + +```bash +cd /caminho/runv-server/scripts +sudo python3 admin/patch_irc.py --all-users --verbose +# ou um utilizador: +sudo python3 admin/patch_irc.py --user alice --verbose +``` + +- Instala **`/usr/local/bin/chat`** (salvo `--skip-launcher`). +- Por utilizador: `~/.config/weechat/`, servidor interno **`runv`** (por defeito), nick = **username Unix**, nicks alternativos `user_`, `user__`, `user|away`. +- Exige **`weechat-headless`** no sistema para aplicar o patch; sem esse binário o script falha com mensagem clara (`apt install weechat-headless`). + +## O que o utilizador faz + +```bash +chat +``` + +Opcional: variável de ambiente **`WEECHAT_HOME`** para outro directório de dados (convénio do cliente IRC). + +## Defaults (ajustáveis por flags) + +| Parâmetro | Default | +|-----------|---------| +| Host IRC | `irc.portalidea.com.br` | +| TLS | ligado (`--tls`; omitir `--no-tls`) | +| Porta | `6697` com TLS, `6667` sem TLS (ou `--port`) | +| Nome do servidor na config | `runv` | +| Autojoin | vazio; `--autojoin '#canal1,#canal2'` | + +Não há SASL/NickServ automático; no código há comentários para extensão futura com **dados seguros** (sem senhas em texto plano). + +## Flags úteis + +- `--dry-run`, `--verbose`, `--force` +- `--skip-launcher`, `--skip-backfill` +- `--users-json`, `--homes-root` +- `--user` **ou** `--all-users` (obrigatório um dos dois) + +## Integração `tools.py` + +Copia **`tools/bin/chat`** → `/usr/local/bin`. O `patch_irc.py` pode reinstalar o mesmo ficheiro se correres o patch sem rerodar `tools.py`. + +## Testes rápidos (Debian 13) + +```bash +sudo python3 admin/patch_irc.py --dry-run --all-users --verbose +sudo python3 admin/patch_irc.py --user "$(logname)" --verbose +command -v chat && ls -l "$(command -v chat)" +command -v weechat-headless +sudo -u USER test -f /home/USER/.config/weechat/irc.conf && grep '^runv\.' /home/USER/.config/weechat/irc.conf +``` + +Substitui `USER` por um utilizador real. diff --git a/terminal/README.md b/terminal/README.md @@ -1,6 +1,6 @@ # terminal — pedido de entrada SSH (`entre@runv.club`) -Módulo **runv.club** para quem se liga por SSH ao utilizador Unix **`entre`**: em vez de shell normal, corre uma experiência **textual guiada** que recolhe username desejado, email e chave pública SSH, grava um JSON na **fila local** e (opcionalmente) notifica o administrador por **sendmail**. +Módulo **runv.club** para quem se liga por SSH ao utilizador Unix **`entre`**: em vez de shell normal, corre uma experiência **textual guiada** que recolhe nome de utilizador, email, **sítios ou perfis online** (onde te possamos ver) e chave pública SSH, grava um JSON na **fila local** e (opcionalmente) notifica o administrador por **sendmail**. **Não cria contas Linux.** O provisionamento continua a ser manual (ou via [`scripts/admin/create_runv_user.py`](../scripts/admin/create_runv_user.md)). @@ -11,7 +11,6 @@ Módulo **runv.club** para quem se liga por SSH ao utilizador Unix **`entre`**: | `entre_app.py` | Programa principal (ForceCommand SSH). | | `entre_core.py` | Validação, fila JSON, log, email. | | `setup_entre.py` | Instalação no servidor (root): utilizador `entre`, shell `/bin/sh`, `--auth-mode` (`shared-password` \| `key-only` \| `empty-password` estilo tilde.town), PAM opcional, drop-in SSH, `sshd -t` + `sshd -T -C`, reload. | -| `del_entre.py` | Remoção (root): retira `runv-entre.conf`, recarrega `sshd`, `userdel` do utilizador `entre`; opções `--purge-install`, `--purge-queue`, `--keep-home`. | | `config.example.toml` | Modelo de configuração → copiar para `config.toml`. | | `templates/*.txt` | Textos da experiência e do email ao admin. | | `docs/USO.md` | **Instalação + uso** (admin, visitante, testes, checklist). | @@ -54,7 +53,7 @@ Usa `terminal/data/queue` e `config.example.toml`. Exige **`ssh-keygen`** no PAT ## Checklist manual de teste -- [ ] `python3 -m py_compile entre_app.py entre_core.py setup_entre.py del_entre.py` +- [ ] `python3 -m py_compile entre_app.py entre_core.py setup_entre.py` - [ ] `./scripts/test_local.sh` — percorrer fluxo até gravar JSON em `data/queue/` - [ ] Confirmar que **não** sobrescreve se repetir o mesmo `request_id` (colisão improvável; o código regera UUID) - [ ] Com `admin_email` preenchido e `mailutils`/`sendmail`: pedido gera tentativa de email (ver log) diff --git a/terminal/docs/ADMIN.md b/terminal/docs/ADMIN.md @@ -15,6 +15,7 @@ Fluxo geral de instalação e utilização: **[USO.md](USO.md)**. | `request_id` | Identificador único. | | `username` | Nome Unix desejado pelo candidato. | | `email` | Contacto. | +| `online_presence` | Texto livre com sítios/perfis indicados pelo candidato. | | `public_key` | Linha OpenSSH normalizada. | | `public_key_fingerprint` | SHA256 (formato OpenSSH). | | `submitted_at` | ISO 8601 UTC. | @@ -33,7 +34,7 @@ sudo jq -r '"\(.submitted_at) \(.username) \(.email) \(.status)"' /var/lib/runv/ ## Revisão manual -1. Abrir o JSON e confirmar que username, email e chave são plausíveis. +1. Abrir o JSON e confirmar que username, email, `online_presence` e chave são plausíveis. 2. Procurar duplicados (mesmo email ou mesma fingerprint com pedidos `pending`). 3. Decidir: aprovar, rejeitar ou pedir mais informação por email **fora** deste sistema. diff --git a/terminal/docs/ARCHITECTURE.md b/terminal/docs/ARCHITECTURE.md @@ -15,9 +15,9 @@ sequenceDiagram C->>S: autentica como entre S->>A: ForceCommand - A->>C: splash, intro/avisos em vários ecrãs - A->>C: formulário (1 campo por ecrã) - C->>A: username, email, pubkey (linha a linha) + A->>C: splash, intro curta + aviso chave + A->>C: formulário (4 passos: user, email, presença online, pubkey) + C->>A: respostas por ecrã (presença online: várias linhas até .) A->>A: validação (entre_core) A->>Q: JSON O_EXCL A->>L: eventos @@ -62,4 +62,4 @@ sequenceDiagram ## Alinhamento com `create_runv_user.py` -Regex de username/email, tipos de chave e normalização da linha pública seguem a mesma filosofia que [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py). O código **não** importa esse ficheiro em runtime (evita dependência de path do repositório em `/opt/runv/terminal`); comentários no código referem a necessidade de manter políticas sincronizadas. +Regex de username/email (inclui checagem explícita de um único `@` antes do regex), tipos de chave e normalização da linha pública seguem a mesma filosofia que [`scripts/admin/create_runv_user.py`](../../scripts/admin/create_runv_user.py). O código **não** importa esse ficheiro em runtime (evita dependência de path do repositório em `/opt/runv/terminal`); comentários no código referem a necessidade de manter políticas sincronizadas. diff --git a/terminal/docs/USO.md b/terminal/docs/USO.md @@ -44,18 +44,19 @@ Este documento resume **como instalar**, **como usar** (visitante e administrado 2. **Opcional:** em **`key-only`**, ou se o admin tiver posto a tua chave em `authorized_keys` (não aplica ao modo `shared-password` por defeito). -3. No início aparece o **logo RUNV em ASCII** (verde, se o terminal suportar cores) e a frase *Aperte qualquer tecla para continuar...*; em todo o fluxo, a cadeia **`runv.club`** é destacada a verde quando o terminal suporta cores (`style_runv_club` em `entre_app.py`); a **história** e o **aviso da chave** vão em **vários ecrãs** (Enter para seguir). A **coleta** é **um campo por ecrã** (utilizador, email, chave pública), com entrada de teclado normal linha a linha. -4. No **aviso da chave**: confirmar que vai colar só a **pública**, nunca a privada (gera um par com `ssh-keygen` no teu PC se ainda não tiveres). -5. **Informar três dados:** - - nome de utilizador **desejado** (regras: minúsculas, letras/dígitos/`_`/`-`, não pode ser nome reservado nem utilizador já existente no servidor); - - **email** de contacto; - - **chave pública** (uma linha inteira). -6. **Rever o resumo** (inclui fingerprint SHA256 da chave pública que colaste): +3. No início aparece o **logo RUNV em ASCII** (verde, se o terminal suportar cores) e a frase *Aperte qualquer tecla para continuar...*; a cadeia **`runv.club`** é destacada a verde onde o terminal suporta. Segue-se uma **intro curta** e um **aviso sobre a chave** (Enter para seguir; `%%PAGE%%` nos `.txt` ainda pode partir em mais do que um ecrã se quiseres). +4. No **aviso da chave**: relembra colar só a **pública**, nunca a privada. +5. **Formulário em quatro passos**, cada um com cabeçalho claro e linha **»** onde escreves: + - **utilizador** desejado (regras: minúsculas, letras/dígitos/`_`/`-`, não reservado nem já existente); + - **email** de contacto — formato `nome@domínio` (com um único `@` e pelo menos um ponto no domínio, ex.: `maria@exemplo.org`); + - **onde apareces online** — links ou perfis (várias linhas; termina com uma linha só com `.` e Enter); + - **chave pública** SSH (uma linha). +6. **Rever o resumo** (inclui fingerprint SHA256 e o texto “online”): - confirmar envio, **editar** de novo ou **cancelar**. 7. Se confirmar: o pedido fica na fila; aparece a **despedida** com a referência `{request_id}`. 8. **Aguardar email** da administração; não repetir o mesmo pedido muitas vezes. -O **splash ASCII** (igual ao da landing em `site/public/index.html`) e o texto *Aperte qualquer tecla...* estão em [`entre_app.py`](../entre_app.py) (`RUNV_ASCII_ART`, `show_opening_splash`). Em `intro.txt` e `warning_public_key.txt`, uma linha só com `%%PAGE%%` **parte o texto em vários ecrãs** (`show_paged_template`). Os restantes textos: `confirm.txt`, `goodbye.txt`. +O **splash ASCII** (igual ao da landing em `site/public/index.html`) e o texto *Aperte qualquer tecla...* estão em [`entre_app.py`](../entre_app.py) (`RUNV_ASCII_ART`, `show_opening_splash`). Em `intro.txt` e `warning_public_key.txt`, `%%PAGE%%` **parte o texto em ecrãs** (`show_paged_template`). Os restantes textos: `confirm.txt`, `goodbye.txt`. --- diff --git a/terminal/entre_app.py b/terminal/entre_app.py @@ -32,6 +32,7 @@ INTRO_PAGE_BREAK: str = "%%PAGE%%" from entre_core import ( APP_VERSION, DEFAULT_MAIL_FROM, + MAX_ONLINE_PRESENCE_LEN, ValidationError, build_request_payload, find_config_path, @@ -46,6 +47,7 @@ from entre_core import ( setup_file_logger, ssh_remote_context, validate_email, + validate_online_presence, validate_public_key_line, validate_username, ) @@ -75,6 +77,47 @@ def read_line(prompt: str, stdin, stdout) -> str: return line.rstrip("\r\n") +def write_data_step_header(stdout, step: int, total: int, title: str) -> None: + """Cabeçalho visível antes de cada campo do formulário.""" + clear_screen(stdout) + g = "\033[92m" if _use_ansi_color(stdout) else "" + c = "\033[96m" if _use_ansi_color(stdout) else "" + b = "\033[1m" if _use_ansi_color(stdout) else "" + r = "\033[0m" if g else "" + bar = "━" * 52 + stdout.write(f"\n {g}{bar}{r}\n") + stdout.write(f" {b}{c}Dados · passo {step}/{total}{r}\n") + stdout.write(f" {b}{g}{title}{r}\n") + stdout.write(f" {g}{bar}{r}\n\n") + + +def read_multiline_until_dot(stdin, stdout, *, max_lines: int = 48) -> str: + """Várias linhas; termina com uma linha só com '.' (como no SMTP clássico).""" + d = "\033[2m" if _use_ansi_color(stdout) else "" + r = "\033[0m" if d else "" + stdout.write( + f"{d} (podes usar várias linhas; para terminar, uma linha só com . e Enter){r}\n\n" + ) + stdout.flush() + lines: list[str] = [] + for _ in range(max_lines): + line = stdin.readline() + if not line: + raise SystemExit(0) + s = line.rstrip("\r\n") + if s == ".": + if lines: + break + continue + lines.append(s) + if len("\n".join(lines)) > MAX_ONLINE_PRESENCE_LEN: + stdout.write( + f"\n {d}(limite de tamanho atingido — campo fechado aqui.){r}\n" + ) + break + return "\n".join(lines).strip() + + def clear_screen(stdout) -> None: stdout.write("\033[2J\033[H") stdout.flush() @@ -160,30 +203,58 @@ def show_paged_template(stdin, stdout, template_path: Path) -> None: pause(stdin, stdout) -def collect_loop(stdin, stdout, templates: Path) -> tuple[str, str, str, str]: - username = email = pubkey = "" +def collect_loop(stdin, stdout, templates: Path) -> tuple[str, str, str, str, str]: + username = email = online_presence = pubkey = "" fp = "" + total = 4 while True: - clear_screen(stdout) - stdout.write(style_runv_club("— runv.club · dados — (1/3)\n\n", stdout)) - stdout.write("Nome de utilizador desejado (minúsculas, letras, dígitos, _ ou -).\n") - stdout.write("Deixe em branco só se ainda não tiver escolhido.\n\n") - u = read_line(f" Utilizador [{username or '(vazio)'}]: ", stdin, stdout).strip() + write_data_step_header(stdout, 1, total, "Nome de utilizador Unix desejado") + stdout.write( + style_runv_club( + "Letras minúsculas, dígitos, _ ou -; começa com letra. " + "Deixa em branco só se ainda não tiveres escolhido.\n", + stdout, + ) + ) + b = "\033[1m" if _use_ansi_color(stdout) else "" + r = "\033[0m" if b else "" + stdout.write(f"\n {b}» Escreve abaixo e prima Enter:{r}\n\n ") + stdout.flush() + u = read_line("", stdin, stdout).strip() if u: username = u - clear_screen(stdout) - stdout.write(style_runv_club("— runv.club · dados — (2/3)\n\n", stdout)) - stdout.write("Email para a administração entrar em contacto consigo.\n\n") - e = read_line(f" Email [{email or '(vazio)'}]: ", stdin, stdout).strip() + write_data_step_header(stdout, 2, total, "Email de contacto") + stdout.write( + "Endereço para a equipa te responder sobre este pedido.\n" + ) + stdout.write(f"\n {b}» Escreve abaixo e prima Enter:{r}\n\n ") + stdout.flush() + e = read_line("", stdin, stdout).strip() if e: email = e - clear_screen(stdout) - stdout.write(style_runv_club("— runv.club · dados — (3/3)\n\n", stdout)) - stdout.write("Cole a sua chave pública SSH (uma linha) e prima Enter.\n") - stdout.write("Só a pública — nunca a chave privada.\n\n") - stdout.write(" Chave pública:\n ") + write_data_step_header(stdout, 3, total, "Onde te encontramos online?") + stdout.write( + style_runv_club( + "Links, perfis ou páginas onde aparece o teu trabalho, código ou participação " + "— por exemplo site, GitHub, Mastodon, itch.io, etc. " + "Uma sugestão por linha.\n", + stdout, + ) + ) + stdout.write(f"\n {b}» A tua resposta (várias linhas):{r}\n") + stdout.flush() + raw_on = read_multiline_until_dot(stdin, stdout) + if raw_on: + online_presence = raw_on + + write_data_step_header(stdout, 4, total, "Chave pública SSH") + stdout.write( + "Uma única linha, a mesma que irias pôr em authorized_keys. " + "Só a pública.\n" + ) + stdout.write(f"\n {b}» Cola a linha abaixo e prima Enter:{r}\n\n ") stdout.flush() pk = stdin.readline() if not pk: @@ -204,6 +275,11 @@ def collect_loop(stdin, stdout, templates: Path) -> tuple[str, str, str, str]: errors.append(str(ex)) ve = "" try: + v_on = validate_online_presence(online_presence) + except ValidationError as ex: + errors.append(str(ex)) + v_on = "" + try: if not pubkey: raise ValidationError("a chave pública é obrigatória.") nkey, fp = validate_public_key_line(pubkey) @@ -213,14 +289,14 @@ def collect_loop(stdin, stdout, templates: Path) -> tuple[str, str, str, str]: if errors: clear_screen(stdout) - stdout.write("— Corrija os dados —\n\n") + stdout.write("— Corrige os dados —\n\n") for err in errors: stdout.write(f" • {err}\n") stdout.write("\n[Enter] para voltar ao início do formulário\n") stdout.flush() stdin.readline() continue - return vu, ve, nkey, fp + return vu, ve, v_on, nkey, fp def confirm_loop( @@ -229,6 +305,7 @@ def confirm_loop( *, username: str, email: str, + online_presence: str, fingerprint: str, templates: Path, ) -> str: @@ -238,6 +315,7 @@ def confirm_loop( { "username": username, "email": email, + "online_presence": online_presence, "fingerprint": fingerprint, "submitted_preview": now, }, @@ -307,13 +385,16 @@ def main() -> int: show_paged_template(stdin, stdout, templates / "warning_public_key.txt") # --- Etapa 3–4: coleta e confirmação (com edição repetível) - username, email, pubkey, fingerprint = collect_loop(stdin, stdout, templates) + username, email, online_presence, pubkey, fingerprint = collect_loop( + stdin, stdout, templates + ) while True: action = confirm_loop( stdin, stdout, username=username, email=email, + online_presence=online_presence, fingerprint=fingerprint, templates=templates, ) @@ -322,7 +403,9 @@ def main() -> int: stdout.write("\nPedido cancelado. Até logo.\n\n") return 0 if action == "edit": - username, email, pubkey, fingerprint = collect_loop(stdin, stdout, templates) + username, email, online_presence, pubkey, fingerprint = collect_loop( + stdin, stdout, templates + ) continue break @@ -334,6 +417,7 @@ def main() -> int: request_id=request_id, username=username, email=email, + online_presence=online_presence, public_key=pubkey, fingerprint=fingerprint, remote_addr=ctx.get("remote_addr"), @@ -364,6 +448,9 @@ def main() -> int: # Aviso em consola ao admin (template curto) try: + oneline = online_presence.replace("\n", " ").strip() + if len(oneline) > 100: + oneline = oneline[:97] + "..." notice = render_template( templates / "admin_console_notice.txt", { @@ -372,6 +459,7 @@ def main() -> int: "email": email, "fingerprint": fingerprint, "submitted_at": submitted_at, + "online_presence_line": oneline, }, ) log_session(logger, "admin_console_notice:\n" + notice.strip()) @@ -391,6 +479,7 @@ def main() -> int: "request_id": request_id, "username": username, "email": email, + "online_presence": online_presence, "public_key": pubkey, "fingerprint": fingerprint, "submitted_at": submitted_at, diff --git a/terminal/entre_core.py b/terminal/entre_core.py @@ -3,7 +3,8 @@ Lógica partilhada do fluxo SSH «entre» (runv.club): validação, fila, log, email. Mantido alinhado com as regras de ``scripts/admin/create_runv_user.py`` (username, -email, tipos de chave). Sem dependências PyPI. +email, tipos de chave). Campo ``online_presence`` é texto livre na fila (não duplicado +em ``create_runv_user``). Sem dependências PyPI. Versão 0.01 — runv.club """ @@ -87,6 +88,8 @@ PRIVATE_KEY_MARKERS: Final[tuple[str, ...]] = ( MAX_USERNAME_LEN: Final[int] = 32 MAX_EMAIL_LEN: Final[int] = 254 MAX_PUBKEY_LEN: Final[int] = 16_384 +MIN_ONLINE_PRESENCE_LEN: Final[int] = 16 +MAX_ONLINE_PRESENCE_LEN: Final[int] = 4000 APP_VERSION: Final[str] = "0.01" SOURCE_TAG: Final[str] = "entre-ssh" @@ -129,12 +132,42 @@ def validate_username(username: str) -> str: return u +def validate_online_presence(raw: str) -> str: + """Texto livre: URLs, perfis, uma linha por sítio — sem mencionar moderação ao utilizador.""" + if raw is None or not str(raw).strip(): + raise ValidationError( + "indica sítios ou perfis onde possamos ver o teu trabalho ou o que publicas online " + f"(mínimo {MIN_ONLINE_PRESENCE_LEN} caracteres). Podes usar várias linhas no passo anterior." + ) + t = str(raw).strip() + if len(t) < MIN_ONLINE_PRESENCE_LEN: + raise ValidationError( + "esse campo ainda é curto demais — adiciona um link, perfil ou página onde apareças online." + ) + if len(t) > MAX_ONLINE_PRESENCE_LEN: + raise ValidationError( + "texto demasiado longo; resume ou escolhe os links mais importantes." + ) + if "\x00" in t: + raise ValidationError("caracteres inválidos no texto.") + return t + + def validate_email(email: str) -> str: if not email or not email.strip(): raise ValidationError("o email é obrigatório.") + if email != email.strip(): + raise ValidationError("o email não pode ter espaços no início ou fim.") e = email.strip() if len(e) > MAX_EMAIL_LEN: raise ValidationError("email demasiado longo.") + at = e.count("@") + if at == 0: + raise ValidationError( + "indica um endereço com @, por exemplo nome@exemplo.org." + ) + if at != 1: + raise ValidationError("o email deve ter um único @.") if not EMAIL_PATTERN.fullmatch(e): raise ValidationError("formato de email inválido.") return e @@ -347,6 +380,7 @@ def build_request_payload( request_id: str, username: str, email: str, + online_presence: str, public_key: str, fingerprint: str, remote_addr: str | None, @@ -356,6 +390,7 @@ def build_request_payload( "request_id": request_id, "username": username, "email": email, + "online_presence": online_presence, "public_key": public_key, "public_key_fingerprint": fingerprint, "submitted_at": datetime.now(timezone.utc).isoformat(), diff --git a/terminal/setup_entre.py b/terminal/setup_entre.py @@ -612,7 +612,6 @@ def copy_module(dest: Path, *, dry_run: bool) -> None: files = [ "entre_app.py", "entre_core.py", - "del_entre.py", "config.example.toml", "README.md", ] diff --git a/terminal/templates/admin_console_notice.txt b/terminal/templates/admin_console_notice.txt @@ -1 +1 @@ -NOVO_PEDIDO_ENTRE request_id={request_id} user={username} email={email} fp={fingerprint} at={submitted_at} +NOVO_PEDIDO_ENTRE request_id={request_id} user={username} email={email} fp={fingerprint} at={submitted_at} online="{online_presence_line}" diff --git a/terminal/templates/admin_mail.txt b/terminal/templates/admin_mail.txt @@ -8,6 +8,11 @@ submitted_at: {submitted_at} remote_addr: {remote_addr} tty : {tty} +Onde aparece online (texto do candidato): +--- +{online_presence} +--- + Chave pública (uma linha): {public_key} diff --git a/terminal/templates/confirm.txt b/terminal/templates/confirm.txt @@ -3,11 +3,13 @@ Revisa os dados antes de enviar. - Nome desejado : {username} - Email : {email} - Fingerprint SHA256: {fingerprint} + Nome desejado : {username} + Email : {email} + Onde apareces online: +{online_presence} + + Fingerprint SHA256 : {fingerprint} (relógio local do servidor ao confirmar): {submitted_preview} Depois de confirmar, o pedido fica na fila para análise manual. Não há criação imediata de conta. - diff --git a/terminal/templates/intro.txt b/terminal/templates/intro.txt @@ -2,61 +2,17 @@ runv.club — pedido de entrada ───────────────────────────── - Você atravessou uma porta simples. + Isto é um pubnix brasileiro: shell Unix partilhado, página em ~/public_html, + gente curiosa e internet mais calma. - Nada de feed infinito. Nada de algoritmo puxando sua manga. Nada de vitrines - barulhentas disputando sua atenção. + A seguir vão surgir **perguntas objetivas** — em cada ecrã, lê o título e escreve + a tua resposta **na linha indicada** (Enter para confirmar; num passo podes usar + várias linhas e terminar com uma linha só com um ponto: .) - Do outro lado, existe apenas uma máquina ligada, algumas pessoas curiosas e a - velha ideia de que a internet também pode ser um lugar de estudo, criação e - encontro. + Vamos pedir: nome de utilizador desejado, email, sítios ou perfis onde apareces + online (blog, código, redes que uses) e a tua chave **pública** SSH. -%%PAGE%% - - A runv.club nasce desse espírito. - - Ela é uma comunidade brasileira inspirada nos antigos ambientes Unix públicos - — os pubnixes, tilde servers e outros cantos da rede onde aprender, publicar - e conversar eram partes da mesma experiência. - - Aqui, o terminal não é uma barreira. É só a porta de entrada. - -%%PAGE%% - - A proposta é simples: oferecer um espaço para brasileiros que queiram explorar - Unix e Linux, criar sua própria página pessoal, estudar, trocar conhecimento, - conhecer gente interessante e redescobrir uma internet mais calma, mais humana - e mais autoral. - - Talvez você já saiba exatamente o que procura. Talvez esteja só começando. - Talvez tenha chegado por curiosidade. - - Tudo bem. - - Você não precisa chegar pronto. - -%%PAGE%% - - Nesta etapa, ainda não vamos criar sua conta imediatamente. Primeiro, vamos - registrar seu interesse e coletar algumas informações básicas para que a - entrada na comunidade aconteça com cuidado. - - Daqui a pouco, vamos pedir apenas três coisas: - - o nome de usuário que você gostaria de usar; - seu email para contato; - sua chave pública SSH. - -%%PAGE%% - - Se tudo estiver certo, seu pedido será registrado e analisado pela - administração. - - Respire fundo. Leia com calma. E, por favor, cole apenas sua chave pública - — nunca a privada. - - Se você veio para aprender, explorar, construir e conviver, já chegou ao - lugar certo. - - Bem-vindo à runv.club. + O pedido vai para uma fila; a equipa trata cada caso manualmente — **não há conta + na hora**. + Pronto? Aperta Enter no ecrã seguinte e começamos. diff --git a/terminal/templates/warning_public_key.txt b/terminal/templates/warning_public_key.txt @@ -1,25 +1,11 @@ - Aviso importante — chave SSH - ─────────────────────────── - Você vai colar sua chave **pública**, a que costuma terminar em .pub ou que - começa por um tipo como: + Antes da chave SSH + ─────────────────── - ssh-ed25519 - ssh-rsa - ecdsa-sha2-nistp256 - ecdsa-sha2-nistp384 - ecdsa-sha2-nistp521 + No último passo vais colar **só a chave pública** — uma linha, começando por algo + como ssh-ed25519 ou ssh-rsa. -%%PAGE%% - - **Nunca** cole aqui a chave **privada** (arquivos sem .pub, blocos que - começam por -----BEGIN … PRIVATE KEY-----, ou texto do PuTTY “Private key”). - Quem tiver a privada controla seu acesso. Trate-a como senha: só em - arquivos locais protegidos. - - Cole **uma única linha**, a mesma que colocaria em authorized_keys. - Sem quebras de linha no meio. - - Se tiver dúvida, pare e gere um par novo no seu computador antes de - continuar. + **Nunca** a chave privada (ficheiros sem .pub, linhas -----BEGIN … PRIVATE KEY-----, + ou export PuTTY “Private key”). + Em dúvida, gera um par novo no teu computador antes de colar. diff --git a/tools/README.md b/tools/README.md @@ -3,9 +3,9 @@ Módulo para **automatizar** no servidor Debian 13 (ou compatível): 1. **Pacotes globais** via `apt` (lista em `manifests/apt_packages.txt`) — para todos os usuários, **sem** passar pelo `/etc/skel`. -2. **Comandos locais** em `/usr/local/bin`: `runv-help`, `runv-links`, `runv-status`. +2. **Comandos locais** em `/usr/local/bin`: `runv-help`, `runv-links`, `runv-status`, **`chat`** (IRC; rede da casa provisionada com **`scripts/admin/patch_irc.py`** — utilizadores usam só `chat`). 3. **MOTD dinâmico** em `/etc/update-motd.d/60-runv` (arte ASCII verde, texto em português). -4. **Arquivos padrão** copiados para `/etc/skel/` (README, `.bash_aliases`, `public_html/index.html`) — **somente modelos de home**, nunca instaladores de sistema. +4. **Arquivos padrão** copiados para `/etc/skel/` (README, `.bash_aliases`, `public_html/index.html`, `public_gopher/gophermap`, `public_gemini/index.gmi`) — **somente modelos de home**, nunca instaladores de sistema. ## Regras diff --git a/tools/bin/chat b/tools/bin/chat @@ -0,0 +1,28 @@ +#!/bin/sh +# runv.club — cliente IRC interactivo; config em ~/.config/weechat (servidor «runv» após patch_irc.py). +# Utilizadores: use só o comando «chat»; não é preciso memorizar outros nomes de binário. + +IRC_UI="" +for c in weechat weechat-curses; do + if command -v "$c" >/dev/null 2>&1; then + IRC_UI=$c + break + fi +done + +if [ -z "$IRC_UI" ]; then + echo "runv: cliente IRC interactivo não encontrado; peça ao admin para correr tools/tools.py (pacotes globais)." >&2 + exit 127 +fi + +CONFIG_DIR="${WEECHAT_HOME:-$HOME/.config/weechat}" + +if [ ! -f "$CONFIG_DIR/irc.conf" ]; then + echo "runv: aviso — $CONFIG_DIR/irc.conf ainda não existe; será criada ao ligar." >&2 + echo "runv: peça ao admin para correr scripts/admin/patch_irc.py (rede IRC da casa)." >&2 +elif ! grep -q '^runv\.' "$CONFIG_DIR/irc.conf" 2>/dev/null; then + echo "runv: aviso — servidor «runv» não está definido em $CONFIG_DIR/irc.conf." >&2 + echo "runv: o admin pode aplicar scripts/admin/patch_irc.py (ex.: irc.portalidea.com.br)." >&2 +fi + +exec "$IRC_UI" -d "$CONFIG_DIR" "$@" diff --git a/tools/bin/runv-help b/tools/bin/runv-help @@ -20,7 +20,6 @@ printf '%bpágina pessoal em %b~/public_html/%b e comunidade em torno de Linux e printf '%b%bComandos runv.club%b\n' "${Y}" "${B}" "${R}" printf ' %brunv-help%b Esta mensagem (ajuda e boas práticas).\n' "${G}" "${R}" printf ' %brunv-links%b Links do projeto, site e parceiros.\n' "${G}" "${R}" -printf ' %brunv-status%b Hostname, uptime, memória, disco, quem está online.\n' "${G}" "${R}" printf '\n' printf '%b%bFerramentas instaladas no servidor%b (exemplos)\n' "${Y}" "${B}" "${R}" @@ -30,7 +29,7 @@ printf ' %bgit%b Controlo de versão.\n' "${G}" "${R}" printf ' %bless%b Paginar ficheiros longos (ex.: less README.md).\n' "${G}" "${R}" printf ' %btmux%b / %bbyobu%b Multiplexadores de terminal (várias sessões).\n' "${G}" "${R}" "${G}" "${R}" printf ' %bmutt%b E-mail no terminal.\n' "${G}" "${R}" -printf ' %bweechat%b IRC no terminal.\n' "${G}" "${R}" +printf ' %bchat%b IRC da rede da casa (após o admin aplicar o patch no servidor).\n' "${G}" "${R}" printf ' %btree%b Árvore de diretórios.\n' "${G}" "${R}" printf ' %badventure%b Jogo de aventura (bsdgames).\n' "${G}" "${R}" printf '\n' diff --git a/tools/bin/runv-status b/tools/bin/runv-status @@ -1,8 +1,15 @@ #!/bin/sh # runv.club — status rápido do servidor # +# Restrito ao utilizador pmurad-admin (informação sensível: disco, sessões, carga). # Usar printf %b para argumentos com sequências ANSI (\033). +RUNV_STATUS_USER=pmurad-admin +if [ "$(id -un)" != "$RUNV_STATUS_USER" ]; then + printf '%s\n' "runv-status: apenas o utilizador «${RUNV_STATUS_USER}» pode executar este comando." >&2 + exit 1 +fi + R='\033[0m' G='\033[0;32m' C='\033[0;36m' @@ -47,5 +54,5 @@ if command -v who >/dev/null 2>&1; then fi fi -printf '\n%bAtalhos:%b runv-help · runv-links · runv-status\n' "${Y}" "${R}" +printf '\n%bAtalhos:%b runv-help · runv-links\n' "${Y}" "${R}" printf '%b\n' "${G}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}" diff --git a/tools/docs/ADMIN.md b/tools/docs/ADMIN.md @@ -28,6 +28,8 @@ sudo python3 tools/tools.py --force --skip-apt ## Editar `runv-help`, `runv-links`, `runv-status` +- **`runv-status`** verifica o nome de login (`id -un`); por omissão só **`pmurad-admin`** passa. Para outro admin, edite a variável **`RUNV_STATUS_USER`** no script em **`tools/bin/runv-status`** antes de reaplicar. + 1. Altere os arquivos em **`tools/bin/`**. 2. Instale de novo: diff --git a/tools/docs/INSTALL.md b/tools/docs/INSTALL.md @@ -16,12 +16,16 @@ Não é necessário Docker, banco de dados nem painel web. 1. Valida execução como **root** (exceto em `--dry-run`, que só simula). 2. Lê **`manifests/apt_packages.txt`** (ignora linhas vazias e `#`). 3. Executa **`apt-get update -qq`** e **`apt-get install -y --no-install-recommends`** com esses pacotes. -4. Copia **`bin/runv-help`**, **`runv-links`**, **`runv-status`** → **`/usr/local/bin/`** com modo **755**. +4. Copia **`bin/runv-help`**, **`runv-links`**, **`runv-status`**, **`bin/chat`** → **`/usr/local/bin/`** com modo **755** (`chat` abre o IRC com config em `~/.config/weechat`; ver **`scripts/docs/irc_patch.md`**). 5. Copia **`motd/60-runv`** → **`/etc/update-motd.d/60-runv`** com modo **755**. 6. Copia o **`skel/`** do repositório para **`/etc/skel/`**: - `README.md` → **644** - `.bash_aliases` → **644** - `public_html/index.html` → diretório **`public_html` 755**, arquivo **644** + - `public_gopher/gophermap` → diretório **`public_gopher` 755**, arquivo **644** + - `public_gemini/index.gmi` → diretório **`public_gemini` 755**, arquivo **644** + +O **`/etc/skel`** só afeta **contas novas** criadas depois da cópia (o Debian copia o skel no `adduser`). Utilizadores **já existentes** não recebem automaticamente estes ficheiros: use **[`scripts/admin/setup_alt_protocols.py`](../../scripts/docs/alt_protocols.md)** (backfill) ou crie `~/public_gopher` e `~/public_gemini` manualmente. Se o destino **já existir** e for **idêntico** (conteúdo byte-a-byte) à origem no repositório, a cópia é **ignorada**. Se o ficheiro no repo **mudou**, o `tools.py` **atualiza** o destino mesmo sem **`--force`**. Use **`--force`** para sobrescrever sempre (útil para repor permissões/mtime ou forçar cópia igual). @@ -50,7 +54,7 @@ sudo python3 tools/tools.py --dry-run --verbose ## Verificar pacotes instalados ```bash -dpkg -l byobu tmux lynx weechat mutt bsdgames tree less curl wget git +dpkg -l byobu tmux lynx weechat weechat-headless mutt bsdgames tree less curl wget git ``` Ou: @@ -64,7 +68,7 @@ apt list --installed 2>/dev/null | grep -E 'byobu|tmux|lynx|weechat|mutt|bsdgame ## Verificar comandos em `/usr/local/bin` ```bash -ls -l /usr/local/bin/runv-help /usr/local/bin/runv-links /usr/local/bin/runv-status +ls -l /usr/local/bin/runv-help /usr/local/bin/runv-links /usr/local/bin/runv-status /usr/local/bin/chat /usr/local/bin/runv-help ``` @@ -85,7 +89,7 @@ Para ver a sequência completa (pode ser longa): run-parts /etc/update-motd.d/ ``` -Em novo login SSH você deve ver o bloco **verde** com arte **RUNV**, a tagline, a lista de comandos úteis e a dica **“digite runv-help para começar”**. Estatísticas (data, uptime, memória, disco, sessões) estão em **`runv-status`**, não no MOTD. +Em novo login SSH você deve ver o bloco **verde** com arte **RUNV**, a tagline, a lista de comandos úteis e a dica **“digite runv-help para começar”**. Estatísticas do servidor (**`runv-status`**) não aparecem no MOTD nem em `runv-help`; só o utilizador **`pmurad-admin`** pode executar `runv-status`. ## Verificar `/etc/skel` @@ -107,7 +111,7 @@ Novas contas criadas com `adduser` **depois** desta instalação recebem esses a 1. **Dry-run:** `sudo python3 tools/tools.py --dry-run --verbose` — revisar saída. 2. **Aplicar:** `sudo python3 tools/tools.py --verbose`. 3. **Segunda execução** sem `--force` com repo **inalterado** — deve **pular** ficheiros já iguais; após **editar** MOTD/bin/skel no repo, a mesma execução deve **copiar de novo**. -4. **`runv-help` / `runv-links` / `runv-status`** — executar manualmente. +4. **`runv-help` / `runv-links`** — qualquer utilizador; **`runv-status`** — apenas como **`pmurad-admin`**. 5. **MOTD:** rodar `/etc/update-motd.d/60-runv` ou novo login SSH. 6. **Skel:** criar usuário de teste com `adduser` e conferir `~usuario/README.md` e `~/public_html/index.html`. diff --git a/tools/docs/USER_EXPERIENCE.md b/tools/docs/USER_EXPERIENCE.md @@ -6,8 +6,9 @@ Visão para **quem entra no servidor** pela primeira vez (e para quem documenta 1. **MOTD** — O Debian executa os scripts em `/etc/update-motd.d/`. O fragmento **`60-runv`** mostra: - logótipo **RUNV** (mesmo desenho UTF-8 da landing) **só nesse bloco** em verde; - - tagline `.club — um computador para compartilhar` (sem bloco de estatísticas no MOTD; use **`runv-status`** para data, uptime, memória, disco, sessões); - - lista curta de comandos (incluindo `lynx`, `tmux`, `byobu`, `mutt`, `weechat`, `adventure`); + - tagline `.club — um computador para compartilhar` (sem estatísticas no MOTD; o comando **`runv-status`** existe mas **não** é listado aqui e só o utilizador **`pmurad-admin`** pode executá-lo); + - **Comandos úteis** em lista, com nome a verde e descrição a cinza (ANSI), alinhada ao texto do `runv-help`; + - grelha **3×3** com os **primeiros campos** das **9** sessões mais recentes de **`last`** (wtmp; ignora linhas `reboot` / `wtmp`); - linha final: **digite `runv-help` para começar**. 2. **Prompt da shell** — Depende do shell padrão (geralmente Bash no Debian). O que o usuário **herda** da home vem do **`/etc/skel`** no momento em que a conta foi criada. @@ -18,7 +19,7 @@ Visão para **quem entra no servidor** pela primeira vez (e para quem documenta |---------|--------| | **`runv-help`** | Texto de ajuda: o que é o runv, comandos úteis, dicas, link do site. | | **`runv-links`** | Links: runv.club, Portal IDEA, etc. | -| **`runv-status`** | Hostname, uptime, memória, disco, `who`, atalhos. | +| **`runv-status`** | (Só **`pmurad-admin`**) hostname, uptime, memória, disco, `who`. Não aparece no MOTD nem em `runv-help`. | Todos são **shell scripts** em **`/usr/local/bin`**, com cores ANSI simples, texto em **português**. Não dependem de Python na sessão do usuário. @@ -34,7 +35,7 @@ Quando um administrador cria a conta com **`adduser`**, o Debian copia **`/etc/s ## Programas globais (apt) -Pacotes como **tmux**, **lynx**, **weechat**, **mutt**, **git**, **tree**, etc. ficam **instalados no sistema**. O usuário **não** precisa de nada no skel para **executá-los**: após o admin rodar `tools.py`, eles passam a existir em `/usr/bin` (ou caminhos padrão). Ou seja: +Pacotes listados em **`manifests/apt_packages.txt`** (incluindo ferramentas de terminal e IRC) ficam **instalados no sistema**. O comando global **`chat`** em `/usr/local/bin` é o único nome que o utilizador precisa para IRC na rede da casa; a config é aplicada pelo admin com **`scripts/admin/patch_irc.py`**. O usuário **não** precisa de nada no skel para **executá-los**: após o admin rodar `tools.py`, eles passam a existir no `PATH`. Ou seja: - **Skel** ≠ instalar programas. - **Skel** = arquivos iniciais na home. @@ -51,6 +52,6 @@ Pacotes como **tmux**, **lynx**, **weechat**, **mutt**, **git**, **tree**, etc. - **MOTD** orienta na hora do login (**`runv-help`**). - **`README.md`** na home repete conceitos com calma (site, permissões). - **`runv-links`** centraliza URLs oficiais. -- **`runv-status`** dá contexto do servidor sem precisar decorar comandos longos. +- Administradores com a conta **`pmurad-admin`** podem usar **`runv-status`** para contexto do servidor (outros utilizadores recebem recusa explícita). Juntos, reduzem fricção para quem nunca usou pubnix ou SSH no dia a dia. diff --git a/tools/manifests/apt_packages.txt b/tools/manifests/apt_packages.txt @@ -5,7 +5,8 @@ byobu tmux lynx -weechat +chat +weechat-headless mutt bsdgames tree diff --git a/tools/motd/60-runv b/tools/motd/60-runv @@ -1,12 +1,17 @@ #!/bin/sh # runv.club — MOTD dinâmico (Debian update-motd.d) # -# RUNV em verde (ANSI); tagline e rodapé em texto normal — alinhado a -# site/public/index.html e terminal/entre_app.py (RUNV_ASCII_ART / ASCII_TAGLINE). -# Estatísticas do sistema ficam em runv-status, não no MOTD. +# RUNV em verde (ANSI); tagline e rodapé — alinhado a site/public/index.html +# e terminal/entre_app.py (RUNV_ASCII_ART / ASCII_TAGLINE). +# Estatísticas gerais: runv-status (só pmurad-admin); aqui: lista de comandos + últimas sessões. # -# Cores: cada sequência ANSI vai em argumento próprio com conversão %b (POSIX). -# Não guardar \033 em variável e depois usar %s — em alguns ambientes aparece literal. +# Cores: printf %b com literais \033 (POSIX). Sem echo -e. + +R='\033[0m' +G='\033[0;32m' +Y='\033[0;33m' +B='\033[1m' +D='\033[2m' # Bloco RUNV (UTF-8) — só este trecho em verde brilhante print_runv_art() { @@ -22,8 +27,57 @@ print_runv_art() { RUNV_ART } +# Últimas 9 sessões em last(1) → grelha 3×3 (primeiro campo = utilizador) +print_last_sessions_3x3() { + if ! command -v last >/dev/null 2>&1; then + printf ' %s\n' "${D}(comando last indisponível)${R}" + return + fi + tf=$(mktemp -t runvmotd.XXXXXX 2>/dev/null) || tf=/tmp/runvmotd.$$ + trap 'rm -f "$tf"' EXIT HUP INT + # Ignora reboot, wtmp e linhas vazias; até 9 utilizadores (sessões recentes) + last -n 200 2>/dev/null | awk ' + /^reboot/ || /^wtmp/ || /^$/ { next } + NF < 1 { next } + { print $1; if (++n >= 9) exit } + ' > "$tf" || true + + if ! [ -s "$tf" ]; then + printf ' %s\n' "${D}(sem registos recentes em wtmp)${R}" + rm -f "$tf" + trap - EXIT HUP INT + return + fi + + printf '\n%bÚltimas sessões%b %b(9 entradas recentes de last)%b\n' "${B}" "${R}" "${D}" "${R}" + row=1 + while [ "$row" -le 3 ]; do + read -r c1 || c1='' + read -r c2 || c2='' + read -r c3 || c3='' + printf ' %b%-12s%b %b%-12s%b %b%-12s%b\n' \ + "${Y}" "$c1" "${R}" \ + "${Y}" "$c2" "${R}" \ + "${Y}" "$c3" "${R}" + row=$((row + 1)) + done < "$tf" + rm -f "$tf" + trap - EXIT HUP INT +} + print_runv_art printf '\n%s\n' '.club — um computador para compartilhar' -printf '\n%s\n' 'Comandos úteis: runv-help · runv-links · runv-status · lynx · tmux · byobu · mutt · weechat · adventure' +printf '\n%bComandos úteis%b\n' "${B}" "${R}" +printf ' %brunv-help%b %b—%b ajuda e boas práticas do runv.club.\n' "${G}" "${R}" "${D}" "${R}" +printf ' %brunv-links%b %b—%b links do site e do mantenedor.\n' "${G}" "${R}" "${D}" "${R}" +printf ' %blynx%b %b—%b navegador web no terminal.\n' "${G}" "${R}" "${D}" "${R}" +printf ' %btmux%b %b—%b multiplexador de terminal (várias sessões).\n' "${G}" "${R}" "${D}" "${R}" +printf ' %bbyobu%b %b—%b barra de estado e atalhos sobre tmux/screen.\n' "${G}" "${R}" "${D}" "${R}" +printf ' %bmutt%b %b—%b cliente de e-mail no terminal.\n' "${G}" "${R}" "${D}" "${R}" +printf ' %bchat%b %b—%b IRC da rede da casa (configuração pelo admin).\n' "${G}" "${R}" "${D}" "${R}" +printf ' %badventure%b %b—%b jogo de aventura em texto (bsdgames).\n' "${G}" "${R}" "${D}" "${R}" + +print_last_sessions_3x3 + printf '\n%s\n' '→ Digite runv-help para começar.' diff --git a/tools/setuptools/tools.txt b/tools/setuptools/tools.txt @@ -6,4 +6,4 @@ # Lista oficial: tools/manifests/apt_packages.txt sudo apt update -sudo apt install -y byobu tmux lynx weechat mutt bsdgames tree less curl wget git -\ No newline at end of file +sudo apt install -y byobu tmux lynx weechat weechat-headless mutt bsdgames tree less curl wget git +\ No newline at end of file diff --git a/tools/skel/README.md b/tools/skel/README.md @@ -44,7 +44,6 @@ Você verá uma lista de **comandos úteis** (navegação no terminal, e-mail, I Outros comandos locais: - **`runv-links`** — links do projeto e do mantenedor. -- **`runv-status`** — hostname, uptime, memória, disco e quem está online. ## Arquivos públicos diff --git a/tools/skel/public_gemini/index.gmi b/tools/skel/public_gemini/index.gmi @@ -0,0 +1,10 @@ +# Capsule Gemini — runv.club + +O teu endereço público segue o padrão `gemini://runv.club/~NOME_UTILIZADOR/` (substitui pelo teu username Unix). + +Edita este ficheiro em `~/public_gemini/index.gmi`. Ficheiros `.gmi` são Texto Gemini; mantém pastas 755 e ficheiros 644 para o servidor conseguir ler. + +## Dicas + +* Podes criar subpáginas com extensão `.gmi`. +* Evita colocar segredos em pastas públicas. diff --git a/tools/skel/public_gopher/gophermap b/tools/skel/public_gopher/gophermap @@ -0,0 +1,3 @@ +iBem-vindo ao teu espaço Gopher no runv.club. fake NULL 0 +iEdita este ficheiro em ~/public_gopher/gophermap para personalizares o menu. fake NULL 0 +iDocumentação: man gophermap (no pacote gophernicus). fake NULL 0 diff --git a/tools/tools.py b/tools/tools.py @@ -20,6 +20,11 @@ from pathlib import Path TOOL_ROOT: Path = Path(__file__).resolve().parent MANIFEST_PATH: Path = TOOL_ROOT / "manifests" / "apt_packages.txt" + +# Nome no manifesto → pacote apt real ("chat" = IRC no terminal; Debian usa o pacote weechat). +_APT_PACKAGE_ALIASES: dict[str, str] = { + "chat": "weechat", +} BIN_DIR: Path = TOOL_ROOT / "bin" MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv" SKEL_DIR: Path = TOOL_ROOT / "skel" @@ -91,7 +96,7 @@ def read_apt_manifest(path: Path, log: logging.Logger) -> list[str]: line = raw.strip() if not line or line.startswith("#"): continue - packages.append(line) + packages.append(_APT_PACKAGE_ALIASES.get(line, line)) return packages @@ -201,7 +206,7 @@ def install_bin_scripts( ) -> None: if not dry_run: DEST_BIN_DIR.mkdir(parents=True, exist_ok=True) - for name in ("runv-help", "runv-links", "runv-status"): + for name in ("runv-help", "runv-links", "runv-status", "chat"): copy_one( BIN_DIR / name, DEST_BIN_DIR / name, @@ -285,6 +290,61 @@ def install_skel( except OSError: pass + # public_gopher / public_gemini (Gopher / Gemini — mesmo critério que public_html) + gopher_dir = DEST_SKEL / "public_gopher" + gopher_src = SKEL_DIR / "public_gopher" / "gophermap" + gopher_dst = gopher_dir / "gophermap" + gemini_dir = DEST_SKEL / "public_gemini" + gemini_src = SKEL_DIR / "public_gemini" / "index.gmi" + gemini_dst = gemini_dir / "index.gmi" + + if not gopher_src.is_file() or not gemini_src.is_file(): + for p in (gopher_src, gemini_src): + if not p.is_file(): + summary.errors.append(f"origem inexistente: {p}") + log.error("Origem inexistente: %s", p) + else: + if not dry_run: + gopher_dir.mkdir(parents=True, exist_ok=True) + os.chmod(gopher_dir, 0o755) + gemini_dir.mkdir(parents=True, exist_ok=True) + os.chmod(gemini_dir, 0o755) + try: + os.chown(gopher_dir, 0, 0) + os.chown(gemini_dir, 0, 0) + except OSError as e: + log.warning("chown em skel gopher/gemini: %s", e) + else: + if not gopher_dir.exists(): + log.info("[dry-run] criaria diretório %s (755)", gopher_dir) + if not gemini_dir.exists(): + log.info("[dry-run] criaria diretório %s (755)", gemini_dir) + + copy_one( + gopher_src, + gopher_dst, + 0o644, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + copy_one( + gemini_src, + gemini_dst, + 0o644, + force=force, + dry_run=dry_run, + log=log, + summary=summary, + ) + + if not dry_run: + if gopher_dir.is_dir(): + os.chmod(gopher_dir, 0o755) + if gemini_dir.is_dir(): + os.chmod(gemini_dir, 0o755) + def print_summary(summary: RunSummary, log: logging.Logger) -> None: print()