commit 27ae2f5decf16d8af7006b6398048df3c3e90d75
parent 969b0e9ba4093ef614e52811b505379bbbcef616
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sat, 21 Mar 2026 13:28:12 -0300
chat, gemini and gopher
Diffstat:
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()