create_runv_user.py (85322B)
1 #!/usr/bin/env python3 2 """ 3 Ferramenta interna de administração: provisiona contas Unix no runv.club (Debian). 4 5 Contrato de provisionamento (ordem garantida após validação): 6 7 1. **Criar o usuário** — ``adduser --disabled-password``. 8 2. **Instalar a chave** — ``~/.ssh/authorized_keys`` com modos ``700`` / ``600``. 9 3. **Preparar public_html** — diretório ``755``, ``index.html`` estático ``644``. 10 4. **Preparar public_gopher / public_gemini** — ``gophermap`` modelo (não sobrescreve sem 11 ``--force-gopher``); ``index.gmi`` só é criado se ainda não existir (nunca substituído); 12 bind mount ``/var/gemini/users/<user>`` <- ``~/public_gemini`` quando o directório global existir 13 (``--force-gemini`` força migração de symlink / remount). 14 5. **Skel Debian** — copiado no passo 1; o skel runv (``tools.py``) **não** inclui ``README.md`` por 15 política. Opcionalmente ``--with-readme`` cria ``~/README.md`` (``--force-readme`` substitui se existir). 16 6. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, sites públicos e, se existir, 17 ``README.md``, antes de quota e verificação final. 18 7. **Jail SSH** — legado/opt-in: use ``--with-jail`` para ``runv-jailed`` + ``/srv/jail/<user>``. 19 Por omissão, membros entram sem chroot para poderem usar os comandos globais do servidor. 20 21 Quota ext4, metadados JSON e logging seguem após estes passos. 22 23 É a **fonte principal** da política de provisionamento — sem depender de ``adduser.local``, 24 ``QUOTAUSER`` ou regras espalhadas em ``/etc/adduser.conf``. 25 26 Garante na criação as permissões para **todos** os serviços runv expostos ao utilizador: 27 **HTTP** (``public_html``), **Gopher** (``public_gopher``) e **Gemini** (``public_gemini``) — 28 home ``755`` (atravessável por Apache, gophernicus e molly-brown), pastas públicas ``755``, 29 ficheiros servidos ``644``, mais ``.ssh``/``authorized_keys`` e bind mount Gemini quando aplicável. 30 Contas criadas **só** com ``adduser`` (sem este script) devem passar pelo backfill 31 ``scripts/admin/setup_alt_protocols.py`` ou por nova execução deste script com as flags de reparo 32 adequadas (``--force-*``). 33 34 Não é signup público: executar manualmente como root/sudo no servidor. 35 Requer Linux (Debian). Quota: ext4 com ``usrquota``/``usrjquota`` via ``setquota`` (não altera fstab). 36 37 Versão 0.02 — desenvolvido por pmurad, 2026. 38 """ 39 40 from __future__ import annotations 41 42 import argparse 43 import fcntl 44 import getpass 45 import json 46 import logging 47 import os 48 import pwd 49 import re 50 import shutil 51 import stat as statmod 52 import subprocess 53 import sys 54 import tempfile 55 from dataclasses import dataclass 56 from datetime import datetime, timezone 57 from pathlib import Path 58 from typing import Any, Final, NoReturn 59 60 # Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script não entra em sys.path; 61 # necessário para «from runv_mount» dentro das funções de quota/mount. 62 _SCRIPT_DIR = Path(__file__).resolve().parent 63 _REPO_ROOT = _SCRIPT_DIR.parent.parent 64 if str(_SCRIPT_DIR) not in sys.path: 65 sys.path.insert(0, str(_SCRIPT_DIR)) 66 67 import runv_jail 68 from runv_landing_sync import try_sync_landing_via_genlanding 69 70 # constantes 71 USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") 72 73 # Email pragmático (não RFC completo) 74 EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile( 75 r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" 76 r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$" 77 ) 78 79 RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( 80 { 81 "root", 82 "daemon", 83 "bin", 84 "sys", 85 "sync", 86 "games", 87 "man", 88 "lp", 89 "mail", 90 "news", 91 "uucp", 92 "proxy", 93 "www-data", 94 "backup", 95 "list", 96 "irc", 97 "_apt", 98 "nobody", 99 "admin", 100 "postmaster", 101 } 102 ) 103 104 ALLOWED_KEY_TYPES: Final[tuple[str, ...]] = ( 105 "ssh-ed25519", 106 "sk-ssh-ed25519@openssh.com", 107 "ecdsa-sha2-nistp256", 108 "ecdsa-sha2-nistp384", 109 "ecdsa-sha2-nistp521", 110 "ssh-rsa", 111 ) 112 113 FINGERPRINT_SHA256_RE: Final[re.Pattern[str]] = re.compile(r"\b(SHA256:[+A-Za-z0-9/_=-]+)\b") 114 115 DEFAULT_METADATA_PATH: Final[Path] = Path("/var/lib/runv/users.json") 116 DEFAULT_LOCK_PATH: Final[Path] = Path("/var/lib/runv/users.lock") 117 DEFAULT_LOG_PATH: Final[Path] = Path("/var/log/runv-user-provision.log") 118 DEFAULT_BASE_URL: Final[str] = "http://runv.club" 119 DEFAULT_ENTRE_QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue") 120 DEFAULT_GEMINI_HOST_PUBLIC: Final[str] = "runv.club" 121 GEMINI_USERS_DIR: Final[Path] = Path("/var/gemini/users") 122 DEFAULT_ALLOWED_ADMIN_USERS: Final[tuple[str, ...]] = ("pmurad-admin",) 123 REQUEST_ID_PATTERN: Final[re.Pattern[str]] = re.compile( 124 r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" 125 ) 126 127 # Quota ext4 (valores padrão runv; limites em MiB = 1024² bytes → setquota usa kiB de 1024 B) 128 DEFAULT_QUOTA_SOFT_MIB: Final[int] = 450 129 DEFAULT_QUOTA_HARD_MIB: Final[int] = 500 130 DEFAULT_QUOTA_INODE_SOFT: Final[int] = 10_000 131 DEFAULT_QUOTA_INODE_HARD: Final[int] = 12_000 132 133 VERSION: Final[str] = "0.02" 134 AUTHOR: Final[str] = "pmurad" 135 COPYRIGHT_YEAR: Final[str] = "2026" 136 137 EXIT_OK: Final[int] = 0 138 EXIT_VALIDATION: Final[int] = 1 139 EXIT_SYSTEM: Final[int] = 2 140 EXIT_INCONSISTENT: Final[int] = 3 141 142 143 class ProvisionError(Exception): 144 """Erro genérico de provisionamento.""" 145 146 147 class ValidationError(ProvisionError): 148 """Entrada ou estado inválido (exit 1).""" 149 150 151 class SystemProvisionError(ProvisionError): 152 """Falha de sistema/subprocess (exit 2).""" 153 154 155 class QuotaNotAvailableError(ValidationError): 156 """Sistema de quotas não preparado (ext4 usrquota ausente, ferramentas, etc.).""" 157 158 159 @dataclass(frozen=True) 160 class QueueApprovalRequest: 161 request_id: str 162 username: str 163 email: str 164 public_key: str 165 fingerprint: str 166 queue_path: Path 167 payload: dict[str, Any] 168 169 170 def resolve_allowed_admin_users() -> set[str]: 171 raw = os.environ.get("RUNV_ADMIN_USERS", "").strip() 172 if not raw: 173 return set(DEFAULT_ALLOWED_ADMIN_USERS) 174 names = {part.strip() for part in raw.split(",") if part.strip()} 175 return names or set(DEFAULT_ALLOWED_ADMIN_USERS) 176 177 178 def resolve_operator_user() -> str: 179 sudo_user = os.environ.get("SUDO_USER", "").strip() 180 if sudo_user: 181 return sudo_user 182 return getpass.getuser().strip() 183 184 185 def require_authorized_admin_operator(*, dry_run: bool) -> str: 186 operator = resolve_operator_user() 187 allowed = resolve_allowed_admin_users() 188 if operator not in allowed: 189 allowed_list = ", ".join(sorted(allowed)) 190 msg = ( 191 f"operação permitida apenas a administrador autorizado. " 192 f"Operador detectado: {operator!r}. Permitidos: {allowed_list}." 193 ) 194 if dry_run: 195 raise ValidationError(msg) 196 raise SystemProvisionError(msg) 197 return operator 198 199 200 # validação username / email 201 def validate_username(username: str) -> str: 202 """ 203 Valida username conservador; rejeita vazio, reservados e contas existentes. 204 Retorna o username normalizado (sem espaços). 205 """ 206 if username is None or username == "": 207 raise ValidationError("username é obrigatório") 208 if username != username.strip(): 209 raise ValidationError("username não pode ter espaços no início ou fim") 210 u = username.strip() 211 if not USERNAME_PATTERN.fullmatch(u): 212 raise ValidationError( 213 "username inválido: use apenas letras minúsculas, dígitos, _ e -; " 214 "comece com letra; comprimento total 2–32 caracteres" 215 ) 216 if u in RESERVED_USERNAMES: 217 raise ValidationError(f"username reservado ou perigoso: {u!r}") 218 try: 219 pwd.getpwnam(u) 220 except KeyError: 221 pass 222 else: 223 raise ValidationError(f"usuário já existe no sistema: {u!r}") 224 return u 225 226 227 def validate_email(email: str) -> str: 228 if email is None or email == "": 229 raise ValidationError("email é obrigatório") 230 if email != email.strip(): 231 raise ValidationError("email não pode ter espaços no início ou fim") 232 e = email.strip() 233 at = e.count("@") 234 if at == 0: 235 raise ValidationError( 236 "indica um endereço com @, por exemplo nome@exemplo.org." 237 ) 238 if at != 1: 239 raise ValidationError("o email deve ter um único @.") 240 if not EMAIL_PATTERN.fullmatch(e): 241 raise ValidationError("formato de email inválido") 242 return e 243 244 245 # chave pública OpenSSH 246 def normalize_public_key(raw: str) -> str: 247 """ 248 Aceita uma única linha OpenSSH authorized_keys. 249 Rejeita newlines internos e normaliza espaços internos de forma segura. 250 """ 251 if raw is None or raw == "": 252 raise ValidationError("public_key é obrigatória") 253 if "\n" in raw or "\r" in raw: 254 raise ValidationError("public_key deve ser uma única linha (sem quebras de linha)") 255 if raw != raw.strip(): 256 raise ValidationError("public_key não pode ter espaços extras no início ou fim") 257 line = raw.strip() 258 if not line or line.isspace(): 259 raise ValidationError("public_key vazia") 260 parts = line.split() 261 if len(parts) < 2: 262 raise ValidationError("public_key malformada (esperado: tipo, dados-base64, [comentário])") 263 key_type = parts[0] 264 if key_type not in ALLOWED_KEY_TYPES: 265 raise ValidationError( 266 f"tipo de chave não permitido: {key_type!r}; permitidos: {', '.join(ALLOWED_KEY_TYPES)}" 267 ) 268 # Uma linha: tipo + blob base64 + comentário opcional (pode conter espaços) 269 blob = parts[1] 270 comment = parts[2:] if len(parts) > 2 else [] 271 if not re.fullmatch(r"[A-Za-z0-9+/]+=*", blob): 272 raise ValidationError("dados da chave pública (base64) inválidos") 273 normalized = key_type + " " + blob 274 if comment: 275 normalized += " " + " ".join(comment) 276 return normalized 277 278 279 def compute_public_key_fingerprint(public_key_line: str, tmp_dir: Path | None = None) -> str: 280 """ 281 Calcula fingerprint no formato OpenSSH SHA256 (ex.: SHA256:...). 282 Usa `ssh-keygen -lf -E sha256` (requer pacote openssh-client no Debian). 283 """ 284 line = normalize_public_key(public_key_line) 285 fd, tmppath = tempfile.mkstemp(prefix="runv-key-", suffix=".pub", dir=tmp_dir) 286 path = Path(tmppath) 287 try: 288 with os.fdopen(fd, "w", encoding="utf-8") as f: 289 f.write(line + "\n") 290 proc = subprocess.run( 291 ["ssh-keygen", "-l", "-E", "sha256", "-f", str(path)], 292 capture_output=True, 293 text=True, 294 timeout=30, 295 ) 296 if proc.returncode != 0: 297 err = (proc.stderr or proc.stdout or "").strip() 298 raise ValidationError(f"chave pública rejeitada pelo ssh-keygen: {err}") 299 out = (proc.stdout or "").strip().splitlines() 300 if not out: 301 raise SystemProvisionError("ssh-keygen não devolveu saída") 302 first = out[0] 303 m = FINGERPRINT_SHA256_RE.search(first) 304 if not m: 305 raise SystemProvisionError(f"não foi possível extrair SHA256 da saída: {first!r}") 306 return m.group(1) 307 finally: 308 try: 309 path.unlink(missing_ok=True) 310 except OSError: 311 pass 312 313 314 def validate_public_key(public_key_line: str, tmp_dir: Path | None = None) -> tuple[str, str]: 315 """ 316 Valida e normaliza a chave; retorna (linha_normalizada, fingerprint_sha256). 317 """ 318 normalized = normalize_public_key(public_key_line) 319 fp = compute_public_key_fingerprint(normalized, tmp_dir=tmp_dir) 320 return normalized, fp 321 322 323 def load_queue_request_by_id(request_id: str, queue_dir: Path) -> QueueApprovalRequest: 324 rid = request_id.strip().lower() 325 if not REQUEST_ID_PATTERN.fullmatch(rid): 326 raise ValidationError("request_id inválido: esperado UUID em minúsculas.") 327 queue_path = queue_dir / f"{rid}.json" 328 if not queue_path.is_file(): 329 raise ValidationError(f"pedido não encontrado na fila: {queue_path}") 330 try: 331 payload = json.loads(queue_path.read_text(encoding="utf-8")) 332 except (OSError, json.JSONDecodeError) as e: 333 raise ValidationError(f"não foi possível ler o pedido {rid!r}: {e}") from e 334 if not isinstance(payload, dict): 335 raise ValidationError(f"pedido {rid!r} inválido: esperado objeto JSON.") 336 337 username = validate_username(str(payload.get("username", ""))) 338 email = validate_email(str(payload.get("email", ""))) 339 normalized_key, computed_fingerprint = validate_public_key(str(payload.get("public_key", ""))) 340 queued_fp = str(payload.get("public_key_fingerprint", "")).strip() 341 if queued_fp and queued_fp != computed_fingerprint: 342 raise ValidationError( 343 f"fingerprint do pedido {rid!r} diverge da chave pública armazenada." 344 ) 345 status = str(payload.get("status", "pending")).strip().lower() 346 if status and status != "pending": 347 raise ValidationError(f"pedido {rid!r} não está pendente (status={status!r}).") 348 349 return QueueApprovalRequest( 350 request_id=rid, 351 username=username, 352 email=email, 353 public_key=normalized_key, 354 fingerprint=computed_fingerprint, 355 queue_path=queue_path, 356 payload=payload, 357 ) 358 359 360 def archive_approved_queue_request( 361 approval: QueueApprovalRequest, 362 *, 363 operator: str, 364 created_username: str, 365 dry_run: bool, 366 log: logging.Logger, 367 ) -> None: 368 approved_dir = approval.queue_path.parent / "approved" 369 archived_payload = dict(approval.payload) 370 archived_payload["status"] = "approved" 371 archived_payload["approved_at"] = datetime.now(timezone.utc).isoformat() 372 archived_payload["approved_by"] = operator 373 archived_payload["provisioned_username"] = created_username 374 375 if dry_run: 376 log.info( 377 "[dry-run] arquivaria pedido aprovado em %s", 378 approved_dir / approval.queue_path.name, 379 ) 380 return 381 382 approved_dir.mkdir(parents=True, exist_ok=True) 383 dest = approved_dir / approval.queue_path.name 384 if dest.exists(): 385 ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") 386 dest = approved_dir / f"{approval.request_id}.{ts}.json" 387 dest.write_text( 388 json.dumps(archived_payload, ensure_ascii=False, indent=2) + "\n", 389 encoding="utf-8", 390 ) 391 approval.queue_path.unlink(missing_ok=True) 392 log.info("pedido %s arquivado em %s", approval.request_id, dest) 393 394 395 def list_pending_queue_request_ids(queue_dir: Path) -> list[str]: 396 if not queue_dir.is_dir(): 397 raise ValidationError(f"fila inexistente: {queue_dir}") 398 items: list[tuple[float, str]] = [] 399 for path in queue_dir.glob("*.json"): 400 if not path.is_file(): 401 continue 402 rid = path.stem.strip().lower() 403 if not REQUEST_ID_PATTERN.fullmatch(rid): 404 continue 405 try: 406 mtime = path.stat().st_mtime 407 except OSError: 408 mtime = 0.0 409 items.append((mtime, rid)) 410 items.sort(key=lambda item: (item[0], item[1])) 411 return [rid for _mtime, rid in items] 412 413 414 def process_all_pending_requests(args: argparse.Namespace) -> int: 415 try: 416 operator_user = require_authorized_admin_operator(dry_run=bool(args.dry_run)) 417 request_ids = list_pending_queue_request_ids(args.queue_dir) 418 except (ValidationError, SystemProvisionError) as e: 419 print(f"Acesso: {e}", file=sys.stderr) 420 return EXIT_VALIDATION if isinstance(e, ValidationError) else EXIT_SYSTEM 421 422 if not request_ids: 423 print(f"Nenhum pedido pendente em {args.queue_dir}.") 424 return EXIT_OK 425 426 print(f"Processando {len(request_ids)} pedido(s) da fila em {args.queue_dir}") 427 print(f"Operador autorizado: {operator_user}") 428 print() 429 430 base_cmd = [sys.executable, str(Path(__file__).resolve())] 431 passthrough_flags: list[str] = [] 432 433 if args.dry_run: 434 passthrough_flags.append("--dry-run") 435 if args.verbose: 436 passthrough_flags.append("--verbose") 437 if args.force_index: 438 passthrough_flags.append("--force-index") 439 if args.with_readme: 440 passthrough_flags.append("--with-readme") 441 if args.force_readme: 442 passthrough_flags.append("--force-readme") 443 if getattr(args, "with_jail", False): 444 passthrough_flags.append("--with-jail") 445 if args.force_gopher: 446 passthrough_flags.append("--force-gopher") 447 if args.force_gemini: 448 passthrough_flags.append("--force-gemini") 449 if args.no_refresh_landing_members: 450 passthrough_flags.append("--no-refresh-landing-members") 451 if args.no_quota: 452 passthrough_flags.append("--no-quota") 453 if args.require_quota: 454 passthrough_flags.append("--require-quota") 455 if args.no_welcome_email: 456 passthrough_flags.append("--no-welcome-email") 457 if args.no_admin_create_email: 458 passthrough_flags.append("--no-admin-create-email") 459 460 value_flags: list[str] = [ 461 "--queue-dir", 462 str(args.queue_dir), 463 "--metadata-file", 464 str(args.metadata_file), 465 "--lock-file", 466 str(args.lock_file), 467 "--log-file", 468 str(args.log_file), 469 "--base-url", 470 str(args.base_url), 471 "--landing-document-root", 472 str(args.landing_document_root), 473 "--quota-soft-mb", 474 str(args.quota_soft_mb), 475 "--quota-hard-mb", 476 str(args.quota_hard_mb), 477 "--quota-inode-soft", 478 str(args.quota_inode_soft), 479 "--quota-inode-hard", 480 str(args.quota_inode_hard), 481 ] 482 if args.members_homes_root is not None: 483 value_flags.extend(["--members-homes-root", str(args.members_homes_root)]) 484 if args.welcome_ssh_host: 485 value_flags.extend(["--welcome-ssh-host", str(args.welcome_ssh_host)]) 486 487 success = 0 488 failures: list[tuple[str, int]] = [] 489 for rid in request_ids: 490 cmd = [*base_cmd, "--request-id", rid, *passthrough_flags, *value_flags] 491 print(f"==> {rid}") 492 proc = subprocess.run(cmd, text=True) 493 if proc.returncode == EXIT_OK: 494 success += 1 495 else: 496 failures.append((rid, proc.returncode)) 497 print() 498 499 print("========== create_runv_user.py — lote ==========") 500 print(f"Pedidos totais: {len(request_ids)}") 501 print(f"Sucessos: {success}") 502 print(f"Falhas: {len(failures)}") 503 if failures: 504 print("Pedidos com falha:") 505 for rid, code in failures: 506 print(f" - {rid} (exit {code})") 507 print("===============================================") 508 return EXIT_OK if not failures else EXIT_INCONSISTENT 509 510 511 def read_public_key_from_args(pub: str | None, pub_file: Path | None) -> str: 512 if pub and pub_file: 513 raise ValidationError("use apenas --public-key ou --public-key-file, não ambos") 514 if pub: 515 return pub 516 if pub_file: 517 text = pub_file.read_text(encoding="utf-8") 518 if len(text.splitlines()) > 1: 519 raise ValidationError("arquivo de chave deve conter uma única linha") 520 line = text.strip() 521 return line 522 raise ValidationError("forneça --public-key ou --public-key-file") 523 524 525 # caminhos sob /home (sem sair da árvore) 526 def home_directory(username: str) -> Path: 527 p = Path(f"/home/{username}").resolve() 528 home_root = Path("/home").resolve() 529 try: 530 p.relative_to(home_root) 531 except ValueError as e: 532 raise ValidationError("caminho home inválido") from e 533 if p.name != username: 534 raise ValidationError("inconsistência no nome do diretório home") 535 return p 536 537 538 # authorized_keys 539 def install_authorized_keys( 540 home: Path, 541 uid: int, 542 gid: int, 543 public_key_line: str, 544 log: logging.Logger, 545 ) -> None: 546 """Cria ~/.ssh/authorized_keys com permissões corretas.""" 547 ssh_dir = home / ".ssh" 548 auth = ssh_dir / "authorized_keys" 549 line = normalize_public_key(public_key_line) 550 551 ssh_dir.mkdir(parents=True, exist_ok=True) 552 os.chmod(ssh_dir, 0o700) 553 try: 554 os.chown(ssh_dir, uid, gid) 555 except PermissionError as e: 556 raise SystemProvisionError(f"não foi possível ajustar dono de {ssh_dir}: {e}") from e 557 558 if auth.exists(): 559 existing = auth.read_text(encoding="utf-8") 560 if line in existing.splitlines(): 561 log.info("authorized_keys já continha esta chave; nada a acrescentar") 562 else: 563 with open(auth, "a", encoding="utf-8") as f: 564 f.write(line + "\n") 565 else: 566 auth.write_text(line + "\n", encoding="utf-8") 567 568 os.chmod(auth, 0o600) 569 try: 570 os.chown(auth, uid, gid) 571 except PermissionError as e: 572 raise SystemProvisionError(f"não foi possível ajustar dono de {auth}: {e}") from e 573 574 575 # public_html 576 def default_index_html(username: str) -> str: 577 """HTML estático: boas-vindas inspiradoras, sem caminhos de sistema nem comandos (só marcação).""" 578 return f"""<!DOCTYPE html> 579 <html lang="pt-BR"> 580 <head> 581 <meta charset="utf-8"> 582 <meta name="viewport" content="width=device-width, initial-scale=1"> 583 <title>~{username} — runv.club</title> 584 <style> 585 :root {{ 586 --bg: #0e0c12; 587 --fg: #e8e4f0; 588 --accent: #c4a1ff; 589 --muted: #9a90b0; 590 }} 591 * {{ box-sizing: border-box; }} 592 body {{ 593 margin: 0; 594 min-height: 100vh; 595 display: flex; 596 align-items: center; 597 justify-content: center; 598 padding: 2rem; 599 font-family: Georgia, "Times New Roman", serif; 600 background: radial-gradient(ellipse 120% 80% at 50% 0%, #1a1428 0%, var(--bg) 55%); 601 color: var(--fg); 602 line-height: 1.65; 603 }} 604 main {{ 605 max-width: 36rem; 606 text-align: center; 607 }} 608 h1 {{ 609 font-weight: 400; 610 font-size: clamp(1.75rem, 4vw, 2.25rem); 611 letter-spacing: 0.02em; 612 margin-bottom: 1.25rem; 613 color: var(--accent); 614 }} 615 p {{ 616 margin: 0 0 1.15rem; 617 font-size: 1.05rem; 618 }} 619 .lead {{ 620 font-size: 1.15rem; 621 color: #f0ecf8; 622 }} 623 .soft {{ 624 color: var(--muted); 625 font-size: 0.98rem; 626 }} 627 </style> 628 </head> 629 <body> 630 <main> 631 <h1>Bem-vindo ao runv.club</h1> 632 <p class="lead">Este é o espaço de <strong>~{username}</strong> na nossa pubnix — um canto da rede para publicar ideias, texto e silêncio com intenção.</p> 633 <p>A web ainda pode ser leve. Aqui vale experimentar, aprender em público e deixar a página crescer com o tempo, sem pressa de plataforma fechada.</p> 634 <p class="soft">Faça deste sítio o que quiser: um blog, um cartão de visitas, um arquivo. O runv.club é o que cada pessoa constrói em conjunto.</p> 635 </main> 636 </body> 637 </html> 638 """ 639 640 641 def default_readme_md(username: str, base_url: str) -> str: 642 """Texto de ajuda inicial em português (política runv.club).""" 643 base = base_url.rstrip("/") 644 user_url = f"{base}/~{username}/" 645 return f"""# Bem-vindo(a) ao runv.club 646 647 O **runv.club** é um servidor partilhado (pubnix): tens acesso por **SSH com chave** 648 e uma **página web pessoal** servida pelo Apache com `mod_userdir`. 649 650 ## A tua página pessoal 651 652 - Ficheiros públicos ficam em **`~/public_html/`**. 653 - A página principal é **`~/public_html/index.html`** (HTML estático; sem PHP obrigatório nesta fase). 654 - A URL pública é: 655 656 **{user_url}** 657 658 Edita o HTML com o teu editor na shell (ex.: `nano ~/public_html/index.html`). 659 660 ## Permissões recomendadas 661 662 | Local | Modo | Notas | 663 |-------|------|--------| 664 | A tua home (`~`) | `755` | O Apache precisa de atravessar a home para chegar a `public_html`. | 665 | `~/public_html` | `755` | Diretório listável pelo servidor web. | 666 | Ficheiros do site | `644` | Ficheiros normais dentro de `public_html`. | 667 | `~/.ssh` | `700` | Só o teu utilizador deve aceder. | 668 | `~/.ssh/authorized_keys` | `600` | Chaves SSH autorizadas. | 669 670 Se alterares permissões e o site deixar de abrir, volta a `755` na home e em `public_html`, 671 e `644` nos ficheiros servidos. 672 673 ## Ficheiros públicos 674 675 Tudo o que colocares em **`public_html`** pode ser lido pelo mundo via HTTP no endereço 676 `~{username}/...`. Não coloques aí segredos, chaves privadas nem dados sensíveis. 677 678 ## Gopher e Gemini (protocolos alternativos) 679 680 - **Gopher:** edita `~/public_gopher/gophermap` (e outros ficheiros nessa pasta). URL típica: 681 `gopher://{DEFAULT_GEMINI_HOST_PUBLIC}/1/~{username}` (o caminho exacto depende do servidor). 682 - **Gemini:** edita `~/public_gemini/index.gmi`. URL canónica: `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~{username}/` (path **`/~{username}/`**, tilde colado ao nome); `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/~/{username}/` redirecciona no servidor (v0.11+). `gemini://{DEFAULT_GEMINI_HOST_PUBLIC}/{username}` **não** é o teu capsule. 683 - Mantém **755** nas pastas públicas e **644** nos ficheiros, para o servidor conseguir ler. 684 685 ## Comandos úteis na shell 686 687 ```bash 688 pwd # diretório atual 689 ls -la # listar com detalhes 690 cd ~/public_html # ir à pasta do site 691 mkdir -p ~/public_html/img # criar subpastas 692 chmod 755 ~ ~/public_html 693 chmod 644 ~/public_html/index.html 694 ``` 695 696 Documentação do projeto (admin): repositório **runv-server**, script `create_runv_user.py`. 697 698 — Equipe runv.club 699 """ 700 701 702 def prepare_public_html( 703 home: Path, 704 username: str, 705 uid: int, 706 gid: int, 707 force_index: bool, 708 log: logging.Logger, 709 ) -> None: 710 pub = home / "public_html" 711 pub.mkdir(parents=True, exist_ok=True) 712 os.chmod(pub, 0o755) 713 try: 714 os.chown(pub, uid, gid) 715 except PermissionError as e: 716 raise SystemProvisionError(f"não foi possível ajustar dono de {pub}: {e}") from e 717 718 index = pub / "index.html" 719 if index.exists() and not force_index: 720 log.info("%s já existe; não sobrescrevendo (use --force-index)", index) 721 return 722 if index.exists() and force_index: 723 log.warning("sobrescrevendo %s (--force-index)", index) 724 index.write_text(default_index_html(username), encoding="utf-8") 725 os.chmod(index, 0o644) 726 try: 727 os.chown(index, uid, gid) 728 except PermissionError as e: 729 raise SystemProvisionError(f"não foi possível ajustar dono de {index}: {e}") from e 730 731 732 def default_gophermap_text(username: str) -> str: 733 return f"""iBem-vindo ao runv.club — espaço Gopher de ~{username}. fake NULL 0 734 iGopher é linha a linha, menu e curiosidade: um protocolo simples para quem gosta de ir devagar. fake NULL 0 735 iExplore, publique texto e deixe este buraco crescer ao seu ritmo. fake NULL 0 736 """ 737 738 739 def default_gemini_index_gmi(username: str) -> str: 740 return f"""# ~{username} — runv.club 741 742 Bem-vindo ao **Gemini**: um espaço em texto puro, sem rastreio nem barulho de anúncios. 743 744 Esta cápsula é sua. Pode contar histórias, listar leituras, partilhar notas — tudo em páginas leves que abrem com calma. 745 746 O runv.club acredita em protocolos abertos e em quem ainda gosta de ler no próprio ritmo. Boa estadia. 747 """ 748 749 750 def prepare_public_gopher( 751 home: Path, 752 username: str, 753 uid: int, 754 gid: int, 755 force_gopher: bool, 756 log: logging.Logger, 757 ) -> None: 758 d = home / "public_gopher" 759 d.mkdir(parents=True, exist_ok=True) 760 os.chmod(d, 0o755) 761 try: 762 os.chown(d, uid, gid) 763 except PermissionError as e: 764 raise SystemProvisionError(f"não foi possível ajustar dono de {d}: {e}") from e 765 gmap = d / "gophermap" 766 if gmap.exists() and not force_gopher: 767 log.info("%s já existe; não sobrescrevendo (use --force-gopher)", gmap) 768 return 769 if gmap.exists() and force_gopher: 770 log.warning("sobrescrevendo %s (--force-gopher)", gmap) 771 gmap.write_text(default_gophermap_text(username), encoding="utf-8") 772 os.chmod(gmap, 0o644) 773 try: 774 os.chown(gmap, uid, gid) 775 except PermissionError as e: 776 raise SystemProvisionError(f"não foi possível ajustar dono de {gmap}: {e}") from e 777 778 779 def prepare_public_gemini( 780 home: Path, 781 username: str, 782 uid: int, 783 gid: int, 784 log: logging.Logger, 785 ) -> None: 786 d = home / "public_gemini" 787 d.mkdir(parents=True, exist_ok=True) 788 os.chmod(d, 0o755) 789 try: 790 os.chown(d, uid, gid) 791 except PermissionError as e: 792 raise SystemProvisionError(f"não foi possível ajustar dono de {d}: {e}") from e 793 idx = d / "index.gmi" 794 if idx.exists(): 795 log.info("%s já existe; modelo não aplicado", idx) 796 return 797 idx.write_text(default_gemini_index_gmi(username), encoding="utf-8") 798 os.chmod(idx, 0o644) 799 try: 800 os.chown(idx, uid, gid) 801 except PermissionError as e: 802 raise SystemProvisionError(f"não foi possível ajustar dono de {idx}: {e}") from e 803 804 805 def ensure_gemini_user_symlink( 806 username: str, 807 home: Path, 808 log: logging.Logger, 809 *, 810 force: bool, 811 ) -> None: 812 """ 813 Garante bind mount /var/gemini/users/<user> <- <home>/public_gemini (Molly Debian; 814 symlinks fora do DocBase são rejeitados). Delega em setup_alt_protocols. 815 """ 816 import setup_alt_protocols as alt 817 818 if not GEMINI_USERS_DIR.is_dir(): 819 log.warning( 820 "diretório %s inexistente — bind Gemini não aplicado. " 821 "Execute scripts/admin/setup_alt_protocols.py no servidor.", 822 GEMINI_USERS_DIR, 823 ) 824 return 825 if username in alt.irc_patch_skip_users(log): 826 log.info("bind Gemini omitido (IRC_PATCH_SKIP_USERS): %s", username) 827 return 828 alt.ensure_gemini_bind_mount( 829 username, 830 home.parent, 831 force=force, 832 dry_run=False, 833 log=log, 834 ) 835 836 837 def prepare_user_readme( 838 home: Path, 839 username: str, 840 uid: int, 841 gid: int, 842 base_url: str, 843 force_readme: bool, 844 log: logging.Logger, 845 ) -> None: 846 """Garante ~/README.md com texto de ajuda em português (não sobrescreve sem --force-readme).""" 847 readme = home / "README.md" 848 if readme.exists() and not force_readme: 849 log.info("%s já existe; não sobrescrevendo (use --force-readme)", readme) 850 return 851 if readme.exists() and force_readme: 852 log.warning("sobrescrevendo %s (--force-readme)", readme) 853 readme.write_text(default_readme_md(username, base_url), encoding="utf-8") 854 os.chmod(readme, 0o644) 855 try: 856 os.chown(readme, uid, gid) 857 except PermissionError as e: 858 raise SystemProvisionError(f"não foi possível ajustar dono de {readme}: {e}") from e 859 860 861 # metadados JSON 862 @dataclass 863 class UserRecord: 864 username: str 865 email: str 866 public_key_fingerprint: str 867 created_at: str 868 created_by: str 869 home_directory: str 870 status: str 871 quota_enabled: bool 872 quota_soft_mb: int | None 873 quota_hard_mb: int | None 874 quota_inode_soft: int | None 875 quota_inode_hard: int | None 876 quota_filesystem: str | None 877 quota_mountpoint: str | None 878 quota_applied_at: str | None 879 quota_status: str 880 881 def to_dict(self) -> dict[str, Any]: 882 return { 883 "username": self.username, 884 "email": self.email, 885 "public_key_fingerprint": self.public_key_fingerprint, 886 "created_at": self.created_at, 887 "created_by": self.created_by, 888 "home_directory": self.home_directory, 889 "status": self.status, 890 "quota_enabled": self.quota_enabled, 891 "quota_soft_mb": self.quota_soft_mb, 892 "quota_hard_mb": self.quota_hard_mb, 893 "quota_inode_soft": self.quota_inode_soft, 894 "quota_inode_hard": self.quota_inode_hard, 895 "quota_filesystem": self.quota_filesystem, 896 "quota_mountpoint": self.quota_mountpoint, 897 "quota_applied_at": self.quota_applied_at, 898 "quota_status": self.quota_status, 899 } 900 901 902 def append_user_metadata( 903 metadata_path: Path, 904 lock_path: Path, 905 record: UserRecord, 906 log: logging.Logger, 907 ) -> None: 908 """ 909 Acrescenta registro a uma lista JSON com lock (flock) e escrita atômica. 910 """ 911 metadata_path.parent.mkdir(parents=True, exist_ok=True) 912 lock_path.parent.mkdir(parents=True, exist_ok=True) 913 914 lock_f = open(lock_path, "a+", encoding="utf-8") 915 try: 916 fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) 917 data: list[dict[str, Any]] 918 if metadata_path.exists(): 919 raw = metadata_path.read_text(encoding="utf-8").strip() 920 if not raw: 921 data = [] 922 else: 923 parsed = json.loads(raw) 924 if not isinstance(parsed, list): 925 raise SystemProvisionError(f"formato inválido em {metadata_path}: esperado lista JSON") 926 data = parsed 927 else: 928 data = [] 929 for item in data: 930 if isinstance(item, dict) and item.get("username") == record.username: 931 raise ValidationError(f"username já registrado em metadados: {record.username!r}") 932 data.append(record.to_dict()) 933 tmp_fd, tmp_name = tempfile.mkstemp( 934 prefix="users.", 935 suffix=".tmp", 936 dir=str(metadata_path.parent), 937 ) 938 tmp_path = Path(tmp_name) 939 try: 940 with os.fdopen(tmp_fd, "w", encoding="utf-8") as out: 941 json.dump(data, out, indent=2, ensure_ascii=False) 942 out.flush() 943 os.fsync(out.fileno()) 944 os.replace(tmp_path, metadata_path) 945 except Exception: 946 tmp_path.unlink(missing_ok=True) 947 raise 948 log.info("metadados gravados em %s", metadata_path) 949 finally: 950 fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) 951 lock_f.close() 952 953 954 # adduser e rollback 955 def run_adduser(username: str, log: logging.Logger) -> None: 956 env = os.environ.copy() 957 env["DEBIAN_FRONTEND"] = "noninteractive" 958 env["LC_ALL"] = "C" 959 log.info("executando adduser --disabled-password para %r", username) 960 try: 961 proc = subprocess.run( 962 ["adduser", "--disabled-password", "--gecos", "", username], 963 capture_output=True, 964 text=True, 965 env=env, 966 timeout=120, 967 ) 968 except FileNotFoundError as e: 969 raise SystemProvisionError("comando adduser não encontrado (instale o pacote adduser)") from e 970 if proc.returncode != 0: 971 err = (proc.stderr or proc.stdout or "").strip() 972 detail = f": {err}" if err else "" 973 log.error("adduser stderr/stdout: %s", err or "(vazio)") 974 raise SystemProvisionError(f"adduser falhou (código {proc.returncode}){detail}") 975 976 977 def run_deluser_remove_home(username: str, log: logging.Logger) -> bool: 978 """Remove usuário e home. Retorna True se sucesso.""" 979 log.warning("rollback: removendo usuário %r com deluser --remove-home", username) 980 try: 981 r = subprocess.run( 982 ["deluser", "--remove-home", username], 983 capture_output=True, 984 text=True, 985 timeout=120, 986 ) 987 if r.returncode != 0: 988 log.error("deluser stderr: %s", r.stderr) 989 return False 990 return True 991 except FileNotFoundError: 992 log.error("deluser não encontrado") 993 return False 994 995 996 def apply_runv_permissions(home: Path, uid: int, gid: int) -> None: 997 """ 998 Aplica modos e donos esperados na home e nos artefactos runv. 999 1000 Deve ser chamado após criar o utilizador, chave SSH, ``public_html`` e opcionalmente ``README.md``, 1001 para garantir home ``755`` (Apache, Gophernicus e Molly-Brown atravessam até 1002 ``public_html`` / ``public_gopher`` / ``public_gemini``), ``.ssh`` ``700``, 1003 ``authorized_keys`` ``600``, site ``755``/``644``. 1004 """ 1005 try: 1006 os.chmod(home, 0o755) 1007 os.chown(home, uid, gid) 1008 except PermissionError as e: 1009 raise SystemProvisionError(f"não foi possível ajustar permissões de {home}: {e}") from e 1010 1011 ssh_dir = home / ".ssh" 1012 if ssh_dir.is_dir(): 1013 try: 1014 os.chmod(ssh_dir, 0o700) 1015 os.chown(ssh_dir, uid, gid) 1016 except PermissionError as e: 1017 raise SystemProvisionError(f"não foi possível ajustar permissões de {ssh_dir}: {e}") from e 1018 auth = ssh_dir / "authorized_keys" 1019 if auth.is_file(): 1020 try: 1021 os.chmod(auth, 0o600) 1022 os.chown(auth, uid, gid) 1023 except PermissionError as e: 1024 raise SystemProvisionError(f"não foi possível ajustar permissões de {auth}: {e}") from e 1025 1026 pub = home / "public_html" 1027 if pub.is_dir(): 1028 try: 1029 os.chmod(pub, 0o755) 1030 os.chown(pub, uid, gid) 1031 except PermissionError as e: 1032 raise SystemProvisionError(f"não foi possível ajustar permissões de {pub}: {e}") from e 1033 index = pub / "index.html" 1034 if index.is_file(): 1035 try: 1036 os.chmod(index, 0o644) 1037 os.chown(index, uid, gid) 1038 except PermissionError as e: 1039 raise SystemProvisionError(f"não foi possível ajustar permissões de {index}: {e}") from e 1040 1041 readme = home / "README.md" 1042 if readme.is_file(): 1043 try: 1044 os.chmod(readme, 0o644) 1045 os.chown(readme, uid, gid) 1046 except PermissionError as e: 1047 raise SystemProvisionError(f"não foi possível ajustar permissões de {readme}: {e}") from e 1048 1049 for label, path in ( 1050 ("public_gopher", home / "public_gopher"), 1051 ("public_gemini", home / "public_gemini"), 1052 ): 1053 if path.is_dir(): 1054 try: 1055 os.chmod(path, 0o755) 1056 os.chown(path, uid, gid) 1057 except PermissionError as e: 1058 raise SystemProvisionError(f"não foi possível ajustar permissões de {path}: {e}") from e 1059 gmap = home / "public_gopher" / "gophermap" 1060 if gmap.is_file(): 1061 try: 1062 os.chmod(gmap, 0o644) 1063 os.chown(gmap, uid, gid) 1064 except PermissionError as e: 1065 raise SystemProvisionError(f"não foi possível ajustar permissões de {gmap}: {e}") from e 1066 gmi = home / "public_gemini" / "index.gmi" 1067 if gmi.is_file(): 1068 try: 1069 os.chmod(gmi, 0o644) 1070 os.chown(gmi, uid, gid) 1071 except PermissionError as e: 1072 raise SystemProvisionError(f"não foi possível ajustar permissões de {gmi}: {e}") from e 1073 1074 1075 def verify_user_artifact_permissions( 1076 home: Path, 1077 uid: int, 1078 gid: int, 1079 *, 1080 expect_readme: bool, 1081 ) -> None: 1082 checks: list[tuple[Path, int, str]] = [ 1083 (home, 0o755, "home"), 1084 (home / ".ssh", 0o700, ".ssh"), 1085 (home / ".ssh" / "authorized_keys", 0o600, "authorized_keys"), 1086 (home / "public_html", 0o755, "public_html"), 1087 (home / "public_html" / "index.html", 0o644, "index.html"), 1088 (home / "public_gopher", 0o755, "public_gopher"), 1089 (home / "public_gopher" / "gophermap", 0o644, "gophermap"), 1090 (home / "public_gemini", 0o755, "public_gemini"), 1091 (home / "public_gemini" / "index.gmi", 0o644, "index.gmi"), 1092 ] 1093 if expect_readme: 1094 checks.append((home / "README.md", 0o644, "README.md")) 1095 for path, want_mode, label in checks: 1096 if not path.exists(): 1097 raise SystemProvisionError(f"em falta após provisionamento ({label}): {path}") 1098 st = path.stat() 1099 if st.st_uid != uid or st.st_gid != gid: 1100 raise SystemProvisionError( 1101 f"donos incorretos em {path} ({label}): esperado uid/gid {uid}/{gid}, " 1102 f"obtido {st.st_uid}/{st.st_gid}" 1103 ) 1104 got = statmod.S_IMODE(st.st_mode) 1105 if got != want_mode: 1106 raise SystemProvisionError( 1107 f"permissões incorretas em {path} ({label}): {oct(got)} (esperado {oct(want_mode)})" 1108 ) 1109 1110 1111 # quota ext4 (setquota / usrquota) 1112 def quota_probe_path(home: Path) -> Path: 1113 """ 1114 Caminho existente no disco para descobrir o mount (antes de adduser, /home/user pode não existir). 1115 Sobe até encontrar um diretório existente (tipicamente /home ou /). 1116 """ 1117 p = home 1118 while True: 1119 try: 1120 if p.exists(): 1121 return p.resolve() 1122 except OSError: 1123 pass 1124 if p == p.parent: 1125 return Path("/").resolve() 1126 p = p.parent 1127 1128 1129 def mib_to_setquota_kib(mib: int) -> int: 1130 """ 1131 Converte **MiB** (mebibytes = 1024² bytes) para as unidades de **blocos** do setquota 1132 em filesystems ext4 (vfsv0): cada unidade conta **1024 bytes** (1 KiB). 1133 1134 Ex.: 450 MiB → 450 × 1024 = 460_800 (KiB de espaço contabilizado pelo quota). 1135 """ 1136 if mib < 0: 1137 raise ValidationError("quota em MiB não pode ser negativa") 1138 return mib * 1024 1139 1140 1141 def validate_quota_limits( 1142 soft_mib: int, 1143 hard_mib: int, 1144 inode_soft: int, 1145 inode_hard: int, 1146 ) -> None: 1147 if soft_mib > hard_mib: 1148 raise ValidationError( 1149 f"quota blocos: soft ({soft_mib} MiB) não pode exceder hard ({hard_mib} MiB)" 1150 ) 1151 if inode_soft > inode_hard: 1152 raise ValidationError( 1153 f"quota inodes: soft ({inode_soft}) não pode exceder hard ({inode_hard})" 1154 ) 1155 1156 1157 def find_mount_for_path(path: Path) -> tuple[str, str, str]: 1158 """ 1159 Retorna (target_canonical, fstype, options_csv) para o filesystem que contém path. 1160 Implementação partilhada: ``runv_mount.find_mount_triple``. 1161 """ 1162 from runv_mount import MountLookupError, find_mount_triple 1163 1164 try: 1165 return find_mount_triple(path) 1166 except MountLookupError as e: 1167 raise SystemProvisionError(str(e)) from e 1168 1169 1170 def mount_options_allow_user_quota(options: str) -> bool: 1171 """True se usrquota ou usrjquota= (ext4 com quota em journal) está ativo.""" 1172 from runv_mount import quota_opts_allow_user 1173 1174 return quota_opts_allow_user(options) 1175 1176 1177 def ensure_setquota_available() -> str: 1178 """Caminho do executável setquota ou levanta SystemProvisionError.""" 1179 p = shutil.which("setquota") 1180 if not p: 1181 raise QuotaNotAvailableError( 1182 "comando 'setquota' não encontrado — instale o pacote Debian 'quota' " 1183 "(ex.: apt install quota)" 1184 ) 1185 return p 1186 1187 1188 def preflight_quota_for_home( 1189 home: Path, 1190 log: logging.Logger, 1191 ) -> tuple[str, str, str]: 1192 """ 1193 Verifica ext4 + usrquota no mount da home (ou ascendente). 1194 Retorna (mountpoint, fstype, options). 1195 """ 1196 log.info("quota: início da verificação (pré-voo)") 1197 probe = quota_probe_path(home) 1198 log.info("quota: path de sonda para findmnt: %s", probe) 1199 target, fstype, opts = find_mount_for_path(probe) 1200 log.info("quota: mountpoint=%s fstype=%s options=%s", target, fstype, opts) 1201 if fstype != "ext4": 1202 raise QuotaNotAvailableError( 1203 f"quota runv: só ext4 com quota tradicional é suportado neste script; " 1204 f"encontrado fstype={fstype!r} em {target!r}" 1205 ) 1206 if not mount_options_allow_user_quota(opts): 1207 raise QuotaNotAvailableError( 1208 f"quota de utilizador não está ativa no mount {target!r}: " 1209 f"opções atuais não incluem usrquota nem usrjquota=. " 1210 f"Ajuste /etc/fstab (usrquota), remonte, quotacheck e quotaon — " 1211 f"o script não altera fstab nem montagens." 1212 ) 1213 ensure_setquota_available() 1214 log.info("quota: pré-voo OK (ext4 + usrquota/usrjquota + setquota)") 1215 return target, fstype, opts 1216 1217 1218 def run_setquota_user( 1219 username: str, 1220 mountpoint: str, 1221 block_soft_kib: int, 1222 block_hard_kib: int, 1223 inode_soft: int, 1224 inode_hard: int, 1225 log: logging.Logger, 1226 ) -> None: 1227 """Aplica limites com setquota -u (lista de argumentos, sem shell).""" 1228 cmd = [ 1229 "setquota", 1230 "-u", 1231 username, 1232 str(block_soft_kib), 1233 str(block_hard_kib), 1234 str(inode_soft), 1235 str(inode_hard), 1236 mountpoint, 1237 ] 1238 log.info("quota: executando %s", " ".join(cmd)) 1239 try: 1240 r = subprocess.run( 1241 cmd, 1242 capture_output=True, 1243 text=True, 1244 timeout=120, 1245 ) 1246 except FileNotFoundError as e: 1247 raise SystemProvisionError("setquota desapareceu do PATH durante a execução") from e 1248 1249 if r.returncode != 0: 1250 err = (r.stderr or r.stdout or "").strip() 1251 raise SystemProvisionError( 1252 f"setquota falhou (código {r.returncode})" + (f": {err}" if err else "") 1253 ) 1254 log.info("quota: setquota concluído com sucesso para %r em %r", username, mountpoint) 1255 1256 1257 @dataclass 1258 class QuotaResult: 1259 """Estado da etapa de quota para metadados e saída.""" 1260 1261 enabled: bool 1262 soft_mib: int | None 1263 hard_mib: int | None 1264 inode_soft: int | None 1265 inode_hard: int | None 1266 filesystem: str | None 1267 mountpoint: str | None 1268 applied_at: str | None 1269 status: str # skipped | applied | failed | not_configured 1270 1271 1272 def try_apply_quota( 1273 username: str, 1274 home: Path, 1275 soft_mib: int, 1276 hard_mib: int, 1277 inode_soft: int, 1278 inode_hard: int, 1279 log: logging.Logger, 1280 ) -> QuotaResult: 1281 """ 1282 Tenta aplicar quota após o utilizador existir. Não remove o utilizador em caso de falha. 1283 """ 1284 try: 1285 target, fstype, _opts = preflight_quota_for_home(home, log) 1286 except QuotaNotAvailableError as e: 1287 log.error("quota indisponível: %s", e) 1288 return QuotaResult( 1289 enabled=True, 1290 soft_mib=soft_mib, 1291 hard_mib=hard_mib, 1292 inode_soft=inode_soft, 1293 inode_hard=inode_hard, 1294 filesystem=None, 1295 mountpoint=None, 1296 applied_at=None, 1297 status="not_configured", 1298 ) 1299 1300 try: 1301 bs = mib_to_setquota_kib(soft_mib) 1302 bh = mib_to_setquota_kib(hard_mib) 1303 run_setquota_user(username, target, bs, bh, inode_soft, inode_hard, log) 1304 except (SystemProvisionError, ValidationError) as e: 1305 log.error("quota falhou ao aplicar: %s", e) 1306 return QuotaResult( 1307 enabled=True, 1308 soft_mib=soft_mib, 1309 hard_mib=hard_mib, 1310 inode_soft=inode_soft, 1311 inode_hard=inode_hard, 1312 filesystem=fstype, 1313 mountpoint=target, 1314 applied_at=None, 1315 status="failed", 1316 ) 1317 1318 now = datetime.now(timezone.utc).isoformat() 1319 return QuotaResult( 1320 enabled=True, 1321 soft_mib=soft_mib, 1322 hard_mib=hard_mib, 1323 inode_soft=inode_soft, 1324 inode_hard=inode_hard, 1325 filesystem=fstype, 1326 mountpoint=target, 1327 applied_at=now, 1328 status="applied", 1329 ) 1330 1331 1332 # CLI 1333 def print_banner() -> None: 1334 print() 1335 print(" create_runv_user — provisionamento runv.club") 1336 print(f" versão {VERSION}") 1337 print(f" desenvolvido por {AUTHOR} — {COPYRIGHT_YEAR}") 1338 print() 1339 1340 1341 def prompt_yes_no(pergunta: str, default_no: bool = True) -> bool: 1342 suf = " [s/N]: " if default_no else " [S/n]: " 1343 r = input(pergunta + suf).strip().lower() 1344 if not r: 1345 return not default_no 1346 return r in ("s", "sim", "y", "yes") 1347 1348 1349 def interactive_fill(args: argparse.Namespace) -> None: 1350 """Preenche args a partir de perguntas no terminal.""" 1351 print_banner() 1352 print("Modo interativo — responda às perguntas (Ctrl+C para cancelar).\n") 1353 1354 while True: 1355 u = input("Nome de usuário Unix (minúsculas, ex.: maria): ").strip() 1356 if u: 1357 args.username = u 1358 break 1359 print(" (obrigatório)") 1360 1361 while True: 1362 e = input("Email do utilizador (ex.: maria@example.com): ").strip() 1363 if e: 1364 args.email = e 1365 break 1366 print(" (obrigatório)") 1367 1368 print() 1369 print("Chave pública SSH (OpenSSH, uma linha).") 1370 modo = input(" (1) colar a linha agora (2) ler de arquivo .pub [1]: ").strip() or "1" 1371 if modo == "2": 1372 while True: 1373 caminho = input(" Caminho absoluto do arquivo .pub: ").strip() 1374 if not caminho: 1375 print(" (obrigatório)") 1376 continue 1377 p = Path(caminho).expanduser() 1378 if not p.is_file(): 1379 print(f" Arquivo não encontrado: {p}") 1380 continue 1381 args.public_key = None 1382 args.public_key_file = p 1383 break 1384 else: 1385 while True: 1386 print(" Cole a linha completa (ssh-ed25519 AAAA... ou ssh-rsa ...):") 1387 linha = input(" > ").strip() 1388 if linha: 1389 args.public_key = linha 1390 args.public_key_file = None 1391 break 1392 print(" (obrigatório)") 1393 1394 print() 1395 args.dry_run = prompt_yes_no("Apenas validar (dry-run), sem criar usuário?", default_no=True) 1396 if not args.dry_run: 1397 args.force_index = prompt_yes_no( 1398 "Se já existir ~/public_html/index.html, sobrescrever (--force-index)?", 1399 default_no=True, 1400 ) 1401 args.force_gopher = prompt_yes_no( 1402 "Se já existir ~/public_gopher/gophermap, sobrescrever (--force-gopher)?", 1403 default_no=True, 1404 ) 1405 args.force_gemini = prompt_yes_no( 1406 "Forçar correção do bind mount Gemini (/var/gemini/users) se estiver errado ou em conflito (--force-gemini)?", 1407 default_no=True, 1408 ) 1409 args.with_readme = prompt_yes_no( 1410 "Criar ~/README.md com texto runv (--with-readme)?", 1411 default_no=True, 1412 ) 1413 if args.with_readme: 1414 args.force_readme = prompt_yes_no( 1415 "Se já existir ~/README.md, sobrescrever (--force-readme)?", 1416 default_no=True, 1417 ) 1418 else: 1419 args.force_readme = False 1420 args.with_jail = prompt_yes_no( 1421 "Criar jail SSH legada (runv-jailed /srv/jail) (--with-jail)?", 1422 default_no=True, 1423 ) 1424 args.no_jail = not args.with_jail 1425 else: 1426 args.force_index = False 1427 args.force_gopher = False 1428 args.force_gemini = False 1429 args.force_readme = False 1430 args.with_jail = False 1431 args.no_jail = True 1432 1433 args.verbose = prompt_yes_no("Log verboso no terminal?", default_no=True) 1434 1435 if not args.dry_run: 1436 if prompt_yes_no("Criar utilizador sem quota de disco (--no-quota)?", default_no=True): 1437 args.no_quota = True 1438 if not args.no_quota: 1439 if prompt_yes_no( 1440 "Abortar se quota ext4 não estiver pronta antes de criar (--require-quota)?", 1441 default_no=True, 1442 ): 1443 args.require_quota = True 1444 1445 print() 1446 conf = input("Confirmar e continuar? [S/n]: ").strip().lower() 1447 if conf in ("n", "nao", "não", "no"): 1448 print("Cancelado.") 1449 raise SystemExit(EXIT_VALIDATION) 1450 1451 1452 def setup_logging(log_path: Path, verbose: bool) -> logging.Logger: 1453 logger = logging.getLogger("runv") 1454 logger.setLevel(logging.DEBUG if verbose else logging.INFO) 1455 logger.handlers.clear() 1456 fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") 1457 try: 1458 log_path.parent.mkdir(parents=True, exist_ok=True) 1459 fh = logging.FileHandler(log_path, encoding="utf-8") 1460 fh.setLevel(logging.DEBUG) 1461 fh.setFormatter(fmt) 1462 logger.addHandler(fh) 1463 except OSError as e: 1464 print(f"Aviso: não foi possível gravar log em {log_path}: {e}", file=sys.stderr) 1465 sh = logging.StreamHandler(sys.stderr) 1466 sh.setLevel(logging.DEBUG if verbose else logging.WARNING) 1467 sh.setFormatter(fmt) 1468 logger.addHandler(sh) 1469 return logger 1470 1471 1472 def _resolve_email_package_root(state: dict[str, Any] | None) -> Path | None: 1473 """Pasta ``email/`` do repositório para importar ``lib.mailer``.""" 1474 env = os.environ.get("RUNV_EMAIL_ROOT", "").strip() 1475 if env: 1476 p = Path(env) 1477 return p if p.is_dir() else None 1478 if state: 1479 er = str(state.get("email_package_root", "")).strip() 1480 if er: 1481 p = Path(er) 1482 if p.is_dir(): 1483 return p 1484 cand = _REPO_ROOT / "email" 1485 return cand if cand.is_dir() else None 1486 1487 1488 def try_patch_irc_for_new_user( 1489 username: str, 1490 *, 1491 dry_run: bool, 1492 log: logging.Logger, 1493 ) -> None: 1494 """ 1495 Executa ``patches/patch_irc.py --user`` (WeeChat headless: servidor «runv», irc.tilde.chat, TLS, #runv). 1496 Não aborta o provisionamento se o patch falhar; contas em ``IRC_PATCH_SKIP_USERS`` são ignoradas. 1497 """ 1498 if dry_run: 1499 return 1500 patch_path = _REPO_ROOT / "patches" / "patch_irc.py" 1501 if not patch_path.is_file(): 1502 log.warning("patch IRC: ficheiro ausente %s — corra o patch manualmente no servidor.", patch_path) 1503 return 1504 try: 1505 import importlib.util 1506 1507 spec = importlib.util.spec_from_file_location("patch_irc_embed", patch_path) 1508 if spec is None or spec.loader is None: 1509 log.warning("patch IRC: não foi possível carregar %s", patch_path) 1510 return 1511 pim = importlib.util.module_from_spec(spec) 1512 spec.loader.exec_module(pim) 1513 if username in pim.IRC_PATCH_SKIP_USERS: 1514 log.info("patch IRC omitido (lista reservada / serviço): %s", username) 1515 return 1516 except Exception as e: 1517 log.warning("patch IRC: verificação de skip falhou (%s); tento subprocess mesmo assim.", e) 1518 cmd = [sys.executable, str(patch_path), "--user", username] 1519 log.info("patch IRC: %s", " ".join(cmd)) 1520 try: 1521 r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) 1522 except (OSError, subprocess.TimeoutExpired) as e: 1523 log.warning("patch IRC: execução falhou: %s", e) 1524 return 1525 if r.returncode != 0: 1526 log.warning( 1527 "patch IRC terminou com código %s para %s: %s", 1528 r.returncode, 1529 username, 1530 ((r.stderr or "") + (r.stdout or "")).strip()[:2000] or "(sem saída)", 1531 ) 1532 else: 1533 log.info("patch IRC concluído para %s (comando «chat», rede runv / #runv).", username) 1534 1535 1536 def try_send_welcome_email( 1537 *, 1538 username: str, 1539 user_email: str, 1540 fingerprint: str, 1541 request_id: str | None, 1542 base_url: str, 1543 welcome_ssh_host: str | None, 1544 no_welcome_email: bool, 1545 dry_run: bool, 1546 log: logging.Logger, 1547 ) -> None: 1548 """ 1549 Envia ``user_account_created`` ao email do utilizador se existir configuração global 1550 (``/etc/runv-email.json``) e módulo ``email/`` acessível. Falhas são só registadas 1551 em log — a conta já foi criada. 1552 """ 1553 if no_welcome_email: 1554 log.info("email de boas-vindas: omitido (--no-welcome-email)") 1555 return 1556 if dry_run: 1557 log.info("email de boas-vindas: omitido (--dry-run)") 1558 return 1559 1560 state_file = Path("/etc/runv-email.json") 1561 if not state_file.is_file(): 1562 log.info( 1563 "email de boas-vindas: %s ausente — defina email ou use --no-welcome-email", 1564 state_file, 1565 ) 1566 return 1567 try: 1568 state = json.loads(state_file.read_text(encoding="utf-8")) 1569 except (OSError, json.JSONDecodeError) as e: 1570 log.warning("email de boas-vindas: estado inválido (%s): %s", state_file, e) 1571 return 1572 1573 email_root = _resolve_email_package_root(state) 1574 if email_root is None: 1575 log.warning( 1576 "email de boas-vindas: pasta email/ não encontrada " 1577 "(RUNV_EMAIL_ROOT, email_package_root no JSON ou repositório em %s)", 1578 _REPO_ROOT / "email", 1579 ) 1580 return 1581 1582 root_s = str(email_root.resolve()) 1583 if root_s not in sys.path: 1584 sys.path.insert(0, root_s) 1585 1586 try: 1587 from lib.mailer import send_user_notice 1588 from lib.templates import USER_ACCOUNT_CREATED 1589 except ImportError as e: 1590 log.warning("email de boas-vindas: import lib.mailer falhou: %s", e) 1591 return 1592 1593 from_addr = str(state.get("default_from", "")).strip() 1594 if not from_addr: 1595 log.warning("email de boas-vindas: default_from ausente em %s", state_file) 1596 return 1597 1598 member_url = f"{base_url.rstrip('/')}/~{username}/" 1599 host = (welcome_ssh_host or "").strip() 1600 if host: 1601 ssh_instructions = ( 1602 f"Comando sugerido: ssh {username}@{host}\n" 1603 "Confirme no cliente SSH que está a usar a chave privada correta " 1604 "(a que corresponde à impressão digital acima)." 1605 ) 1606 else: 1607 ssh_instructions = ( 1608 f"Comando típico: ssh {username}@<hostname>\n" 1609 "Substitua <hostname> pelo endereço do servidor que o administrador lhe indicar. " 1610 "No cliente SSH, seleccione a **chave privada** que corresponde à chave pública registada." 1611 ) 1612 1613 try: 1614 send_user_notice( 1615 USER_ACCOUNT_CREATED, 1616 user_email, 1617 subject="[runv.club] Bem-vindo(a) — a sua conta foi criada", 1618 from_addr=from_addr, 1619 _state=state, 1620 username=username, 1621 email=user_email, 1622 fingerprint=fingerprint, 1623 request_reference=( 1624 f"Referência do seu pedido: {request_id}" 1625 if request_id 1626 else "Referência do seu pedido: não aplicável" 1627 ), 1628 member_url=member_url, 1629 ssh_instructions=ssh_instructions, 1630 ) 1631 log.info("email de boas-vindas enviado para %s", user_email) 1632 print(f" boas-vindas: email enviado para {user_email}") 1633 except Exception as e: 1634 log.warning("email de boas-vindas falhou (conta já criada): %s", e) 1635 1636 1637 def try_send_admin_user_created_email( 1638 *, 1639 username: str, 1640 user_email: str, 1641 operator_info: str, 1642 timestamp: str, 1643 request_id: str | None, 1644 no_admin_create_email: bool, 1645 dry_run: bool, 1646 log: logging.Logger, 1647 ) -> None: 1648 """ 1649 Envia ``admin_user_created`` para ``admin_email`` em ``/etc/runv-email.json``. 1650 Falhas só em log — a conta já foi criada. 1651 """ 1652 if no_admin_create_email: 1653 log.info("email admin (conta criada): omitido (--no-admin-create-email)") 1654 return 1655 if dry_run: 1656 log.info("email admin (conta criada): omitido (--dry-run)") 1657 return 1658 1659 state_file = Path("/etc/runv-email.json") 1660 if not state_file.is_file(): 1661 log.info( 1662 "email admin (conta criada): %s ausente — omitido", 1663 state_file, 1664 ) 1665 return 1666 try: 1667 state = json.loads(state_file.read_text(encoding="utf-8")) 1668 except (OSError, json.JSONDecodeError) as e: 1669 log.warning("email admin (conta criada): estado inválido (%s): %s", state_file, e) 1670 return 1671 1672 admin = str(state.get("admin_email", "")).strip() 1673 if not admin: 1674 log.info( 1675 "email admin (conta criada): admin_email vazio em %s — omitido", 1676 state_file, 1677 ) 1678 return 1679 1680 email_root = _resolve_email_package_root(state) 1681 if email_root is None: 1682 log.warning( 1683 "email admin (conta criada): pasta email/ não encontrada " 1684 "(RUNV_EMAIL_ROOT, email_package_root no JSON ou repositório em %s)", 1685 _REPO_ROOT / "email", 1686 ) 1687 return 1688 1689 root_s = str(email_root.resolve()) 1690 if root_s not in sys.path: 1691 sys.path.insert(0, root_s) 1692 1693 try: 1694 from lib.mailer import send_admin_notice 1695 from lib.templates import ADMIN_USER_CREATED 1696 except ImportError as e: 1697 log.warning("email admin (conta criada): import lib.mailer falhou: %s", e) 1698 return 1699 1700 from_addr = str(state.get("default_from", "")).strip() 1701 if not from_addr: 1702 log.warning("email admin (conta criada): default_from ausente em %s", state_file) 1703 return 1704 1705 try: 1706 send_admin_notice( 1707 ADMIN_USER_CREATED, 1708 admin, 1709 subject=f"[runv.club] Conta criada — {username}", 1710 from_addr=from_addr, 1711 _state=state, 1712 username=username, 1713 email=user_email, 1714 operator_info=operator_info, 1715 timestamp=timestamp, 1716 request_reference=request_id or "manual", 1717 ) 1718 log.info("email admin (conta criada) enviado para %s", admin) 1719 print(f" admin (conta): email enviado para {admin}") 1720 except Exception as e: 1721 log.warning("email admin (conta criada) falhou (conta já criada): %s", e) 1722 1723 1724 def parse_args(argv: list[str] | None = None) -> argparse.Namespace: 1725 p = argparse.ArgumentParser( 1726 description=( 1727 "Provisiona conta Unix interna (runv.club). Executar como root no servidor. " 1728 "Aplica permissões completas para HTTP, Gopher e Gemini (home e public_*); " 1729 "contas só adduser precisam de setup_alt_protocols ou reparo aqui. " 1730 f"Versão {VERSION} — {AUTHOR} {COPYRIGHT_YEAR}." 1731 ), 1732 ) 1733 p.add_argument( 1734 "-i", 1735 "--interactive", 1736 action="store_true", 1737 help="modo interativo (perguntas no terminal); também é o padrão se não passar nenhum argumento", 1738 ) 1739 p.add_argument( 1740 "--request-id", 1741 "--user", 1742 dest="request_id", 1743 default=None, 1744 help="aprova automaticamente um pedido pendente da fila entre-queue pelo UUID", 1745 ) 1746 p.add_argument( 1747 "--all-pending", 1748 action="store_true", 1749 help="aprova e processa todos os pedidos pendentes da entre-queue, em sequência", 1750 ) 1751 p.add_argument("--username", default=None, help="nome de usuário Unix (minúsculas)") 1752 p.add_argument("--email", default=None, help="email do utilizador (também em users.json)") 1753 g = p.add_mutually_exclusive_group(required=False) 1754 g.add_argument("--public-key", dest="public_key", default=None, help="linha authorized_keys (OpenSSH)") 1755 g.add_argument( 1756 "--public-key-file", 1757 type=Path, 1758 dest="public_key_file", 1759 default=None, 1760 help="arquivo com uma linha .pub", 1761 ) 1762 p.add_argument("--dry-run", action="store_true", help="valida e mostra o plano sem alterar o sistema") 1763 p.add_argument("--verbose", action="store_true", help="log detalhado no stderr") 1764 p.add_argument( 1765 "--force-index", 1766 action="store_true", 1767 help="sobrescrever ~/public_html/index.html se já existir", 1768 ) 1769 p.add_argument( 1770 "--with-readme", 1771 action="store_true", 1772 help="criar ~/README.md com texto runv (por omissão não cria)", 1773 ) 1774 p.add_argument( 1775 "--force-readme", 1776 action="store_true", 1777 help="com --with-readme: sobrescrever ~/README.md se já existir", 1778 ) 1779 p.add_argument( 1780 "--no-jail", 1781 action="store_true", 1782 help="compatibilidade: não adicionar a runv-jailed nem criar jail em /srv/jail (padrão atual)", 1783 ) 1784 p.add_argument( 1785 "--with-jail", 1786 action="store_true", 1787 help="legado/opt-in: adicionar a runv-jailed e criar jail em /srv/jail", 1788 ) 1789 p.add_argument( 1790 "--force-gopher", 1791 action="store_true", 1792 help="sobrescrever ~/public_gopher/gophermap se já existir", 1793 ) 1794 p.add_argument( 1795 "--force-gemini", 1796 action="store_true", 1797 help="corrigir bind mount em /var/gemini/users (migra symlink; remount se necessário); não sobrescreve index.gmi existente", 1798 ) 1799 p.add_argument( 1800 "--metadata-file", 1801 type=Path, 1802 default=DEFAULT_METADATA_PATH, 1803 help=f"caminho do JSON de metadados (padrão: {DEFAULT_METADATA_PATH})", 1804 ) 1805 p.add_argument( 1806 "--lock-file", 1807 type=Path, 1808 default=DEFAULT_LOCK_PATH, 1809 help=f"arquivo de lock flock (padrão: {DEFAULT_LOCK_PATH})", 1810 ) 1811 p.add_argument( 1812 "--log-file", 1813 type=Path, 1814 default=DEFAULT_LOG_PATH, 1815 help=f"log local (padrão: {DEFAULT_LOG_PATH})", 1816 ) 1817 p.add_argument( 1818 "--queue-dir", 1819 type=Path, 1820 default=DEFAULT_ENTRE_QUEUE_DIR, 1821 help=f"fila do entre para aprovar por request_id (padrão: {DEFAULT_ENTRE_QUEUE_DIR})", 1822 ) 1823 p.add_argument( 1824 "--base-url", 1825 default=DEFAULT_BASE_URL, 1826 help=f"URL base para o resumo (padrão: {DEFAULT_BASE_URL})", 1827 ) 1828 p.add_argument( 1829 "--landing-document-root", 1830 type=Path, 1831 default=Path("/var/www/runv.club/html"), 1832 help=( 1833 "DocumentRoot da landing Apache (directório existente); após criar o utilizador, " 1834 "executa site/genlanding.py --sync-public-only (copia site/public + data/members.json). " 1835 "Se não existir, a sincronização é omitida e é impresso um AVISO com o comando sugerido." 1836 ), 1837 ) 1838 p.add_argument( 1839 "--no-refresh-landing-members", 1840 action="store_true", 1841 help=( 1842 "não sincronizar site/public → DocumentRoot nem regenerar data/members.json após gravar metadados" 1843 ), 1844 ) 1845 p.add_argument( 1846 "--members-homes-root", 1847 type=Path, 1848 default=None, 1849 help="se definido (ex. /home), passa --members-homes-root a genlanding (homepage_mtime em members.json)", 1850 ) 1851 p.add_argument( 1852 "--no-quota", 1853 action="store_true", 1854 help="não aplica quota de disco (ignora setquota)", 1855 ) 1856 p.add_argument( 1857 "--require-quota", 1858 action="store_true", 1859 help=( 1860 "exige sistema de quotas pronto (ext4 + usrquota + setquota) antes de criar o utilizador; " 1861 "aborta sem adduser se não estiver configurado" 1862 ), 1863 ) 1864 p.add_argument( 1865 "--quota-soft-mb", 1866 type=int, 1867 default=DEFAULT_QUOTA_SOFT_MIB, 1868 metavar="MiB", 1869 help=f"limite soft de blocos em MiB (1024² B); padrão {DEFAULT_QUOTA_SOFT_MIB}", 1870 ) 1871 p.add_argument( 1872 "--quota-hard-mb", 1873 type=int, 1874 default=DEFAULT_QUOTA_HARD_MIB, 1875 metavar="MiB", 1876 help=f"limite hard de blocos em MiB; padrão {DEFAULT_QUOTA_HARD_MIB}", 1877 ) 1878 p.add_argument( 1879 "--quota-inode-soft", 1880 type=int, 1881 default=DEFAULT_QUOTA_INODE_SOFT, 1882 metavar="N", 1883 help=f"limite soft de inodes; padrão {DEFAULT_QUOTA_INODE_SOFT}", 1884 ) 1885 p.add_argument( 1886 "--quota-inode-hard", 1887 type=int, 1888 default=DEFAULT_QUOTA_INODE_HARD, 1889 metavar="N", 1890 help=f"limite hard de inodes; padrão {DEFAULT_QUOTA_INODE_HARD}", 1891 ) 1892 p.add_argument( 1893 "--version", 1894 action="version", 1895 version=f"%(prog)s {VERSION} — desenvolvido por {AUTHOR}, {COPYRIGHT_YEAR}", 1896 ) 1897 p.add_argument( 1898 "--no-welcome-email", 1899 action="store_true", 1900 help="não enviar email de boas-vindas ao utilizador após criar a conta", 1901 ) 1902 p.add_argument( 1903 "--no-admin-create-email", 1904 action="store_true", 1905 help="não enviar email ao admin (template admin_user_created) após criar a conta", 1906 ) 1907 p.add_argument( 1908 "--welcome-ssh-host", 1909 default=None, 1910 metavar="HOST", 1911 help=( 1912 "hostname SSH para incluir no email de boas-vindas (ex.: runv.club); " 1913 "alternativa: variável de ambiente RUNV_WELCOME_SSH_HOST" 1914 ), 1915 ) 1916 return p.parse_args(argv) 1917 1918 1919 def main(argv: list[str] | None = None) -> int: 1920 if argv is None: 1921 argv = sys.argv[1:] 1922 if not argv: 1923 argv = ["--interactive"] 1924 1925 args = parse_args(argv) 1926 args.no_jail = not getattr(args, "with_jail", False) 1927 if args.interactive: 1928 try: 1929 interactive_fill(args) 1930 except KeyboardInterrupt: 1931 print("\nInterrompido (Ctrl+C).", file=sys.stderr) 1932 return EXIT_VALIDATION 1933 except SystemExit as e: 1934 code = e.code 1935 if code is None: 1936 return EXIT_VALIDATION 1937 if isinstance(code, int): 1938 return code 1939 return EXIT_VALIDATION 1940 1941 if args.all_pending: 1942 if args.request_id or args.username or args.email or args.public_key or args.public_key_file: 1943 print( 1944 "Erro: --all-pending não deve ser combinado com --request-id/--user, --username, --email ou chave manual.", 1945 file=sys.stderr, 1946 ) 1947 return EXIT_VALIDATION 1948 return process_all_pending_requests(args) 1949 1950 queue_request: QueueApprovalRequest | None = None 1951 if args.request_id: 1952 if args.username or args.email or args.public_key or args.public_key_file: 1953 print( 1954 "Erro: --request-id/--user não deve ser combinado com --username, --email, --public-key ou --public-key-file.", 1955 file=sys.stderr, 1956 ) 1957 return EXIT_VALIDATION 1958 try: 1959 queue_request = load_queue_request_by_id(args.request_id, args.queue_dir) 1960 except ValidationError as e: 1961 print(f"Validação: {e}", file=sys.stderr) 1962 return EXIT_VALIDATION 1963 args.username = queue_request.username 1964 args.email = queue_request.email 1965 args.public_key = queue_request.public_key 1966 args.public_key_file = None 1967 1968 if not args.username or not args.email: 1969 print( 1970 "Erro: informe --username e --email, ou use --interactive / execute sem argumentos.", 1971 file=sys.stderr, 1972 ) 1973 return EXIT_VALIDATION 1974 if not args.public_key and not args.public_key_file: 1975 print( 1976 "Erro: informe --public-key ou --public-key-file, ou use modo interativo.", 1977 file=sys.stderr, 1978 ) 1979 return EXIT_VALIDATION 1980 1981 log = setup_logging(args.log_file, args.verbose) 1982 log.info( 1983 "=== início operação create_runv_user (versão %s) dry_run=%s interactive=%s", 1984 VERSION, 1985 args.dry_run, 1986 args.interactive, 1987 ) 1988 1989 try: 1990 operator_user = require_authorized_admin_operator(dry_run=bool(args.dry_run)) 1991 except (ValidationError, SystemProvisionError) as e: 1992 print(f"Acesso: {e}", file=sys.stderr) 1993 return EXIT_VALIDATION if isinstance(e, ValidationError) else EXIT_SYSTEM 1994 1995 if os.geteuid() != 0 and not args.dry_run: 1996 print("Erro: execute como root (ou sudo) para criar usuários.", file=sys.stderr) 1997 log.error("recusado: euid != 0 e não é dry-run") 1998 return EXIT_SYSTEM 1999 2000 try: 2001 log.info("=== fase: validação de entrada (username, email, chave SSH)") 2002 raw_key = read_public_key_from_args(args.public_key, args.public_key_file) 2003 user = validate_username(args.username) 2004 email = validate_email(args.email) 2005 normalized_key, fingerprint = validate_public_key(raw_key) 2006 log.info( 2007 "=== validação OK: user=%s email=%s fingerprint=%s", 2008 user, 2009 email, 2010 fingerprint, 2011 ) 2012 except ValidationError as e: 2013 log.error("validação falhou: %s", e) 2014 print(f"Validação: {e}", file=sys.stderr) 2015 return EXIT_VALIDATION 2016 2017 home = home_directory(user) 2018 2019 if args.no_quota and args.require_quota: 2020 print( 2021 "Erro: --no-quota e --require-quota não podem ser usados em conjunto.", 2022 file=sys.stderr, 2023 ) 2024 return EXIT_VALIDATION 2025 2026 if not args.no_quota: 2027 try: 2028 validate_quota_limits( 2029 args.quota_soft_mb, 2030 args.quota_hard_mb, 2031 args.quota_inode_soft, 2032 args.quota_inode_hard, 2033 ) 2034 except ValidationError as e: 2035 print(f"Validação: {e}", file=sys.stderr) 2036 return EXIT_VALIDATION 2037 2038 if args.dry_run: 2039 print("[dry-run] Nenhuma alteração será feita.") 2040 print(f" username: {user}") 2041 print(f" email: {email}") 2042 print(f" home: {home}") 2043 print(f" fingerprint: {fingerprint}") 2044 print(f" operador: {operator_user}") 2045 if queue_request is not None: 2046 print(f" pedido fila: {queue_request.request_id} ({queue_request.queue_path})") 2047 print( 2048 " ações: (1) adduser + skel (2) authorized_keys (3) public_html " 2049 "(4) public_gopher + public_gemini + bind Gemini (5) README só com --with-readme " 2050 "(6) permissões (7) jail SSH só com --with-jail " 2051 "(8) quota (9) verificação + patch IRC (chat) (10) metadados JSON" 2052 ) 2053 print( 2054 f" with-readme: {getattr(args, 'with_readme', False)} " 2055 f"with-jail: {getattr(args, 'with_jail', False)}" 2056 ) 2057 if args.no_quota: 2058 print(" quota: desativada (--no-quota)") 2059 else: 2060 print( 2061 f" quota: MiB soft/hard {args.quota_soft_mb}/{args.quota_hard_mb}; " 2062 f"inodes {args.quota_inode_soft}/{args.quota_inode_hard}" 2063 ) 2064 print( 2065 " quota: tentará setquota após criar utilizador (ext4 + usrquota/usrjquota + pacote quota)" 2066 ) 2067 if args.require_quota and not args.no_quota: 2068 print( 2069 " quota: --require-quota — aborta antes de adduser se o sistema de quotas não estiver pronto" 2070 ) 2071 return EXIT_OK 2072 2073 created_user = False 2074 try: 2075 if args.require_quota and not args.no_quota: 2076 log.info("=== fase: pré-voo de quota (require-quota)") 2077 preflight_quota_for_home(home, log) 2078 2079 log.info("=== fase 1: criação de conta Unix (adduser; /etc/skel copiado pelo Debian)") 2080 run_adduser(user, log) 2081 created_user = True 2082 pw = pwd.getpwnam(user) 2083 uid, gid = pw.pw_uid, pw.pw_gid 2084 log.info("=== adduser concluído: uid=%s gid=%s home=%s", uid, gid, home) 2085 2086 log.info("=== fase 2: SSH authorized_keys (~/.ssh 700, arquivo 600)") 2087 install_authorized_keys(home, uid, gid, normalized_key, log) 2088 2089 log.info("=== fase 3: public_html e index.html estático") 2090 prepare_public_html(home, user, uid, gid, args.force_index, log) 2091 2092 log.info("=== fase 3b: public_gopher (gophermap) e public_gemini (index.gmi)") 2093 prepare_public_gopher(home, user, uid, gid, args.force_gopher, log) 2094 prepare_public_gemini(home, user, uid, gid, log) 2095 ensure_gemini_user_symlink(user, home, log, force=args.force_gemini) 2096 2097 if args.with_readme: 2098 log.info("=== fase 4: README.md runv (--with-readme)") 2099 prepare_user_readme(home, user, uid, gid, args.base_url, args.force_readme, log) 2100 else: 2101 log.info("=== fase 4: README.md omitido (use --with-readme para criar)") 2102 2103 log.info("=== fase 5: permissões consolidadas (home, .ssh, sites públicos, README se existir)") 2104 apply_runv_permissions(home, uid, gid) 2105 2106 log.info("=== fase 6: jail SSH legada (só com --with-jail)") 2107 try: 2108 runv_jail.ensure_runv_jail_for_user( 2109 user, 2110 home, 2111 no_jail=bool(args.no_jail), 2112 log=log, 2113 ) 2114 except RuntimeError as e: 2115 raise SystemProvisionError(str(e)) from e 2116 2117 log.info("=== fase: quota (setquota em ext4 com usrquota)") 2118 if args.no_quota: 2119 qr = QuotaResult( 2120 enabled=False, 2121 soft_mib=None, 2122 hard_mib=None, 2123 inode_soft=None, 2124 inode_hard=None, 2125 filesystem=None, 2126 mountpoint=None, 2127 applied_at=None, 2128 status="skipped", 2129 ) 2130 log.info("quota: ignorada (--no-quota)") 2131 else: 2132 qr = try_apply_quota( 2133 user, 2134 home, 2135 args.quota_soft_mb, 2136 args.quota_hard_mb, 2137 args.quota_inode_soft, 2138 args.quota_inode_hard, 2139 log, 2140 ) 2141 log.info( 2142 "quota: estado final status=%s mount=%s fs=%s", 2143 qr.status, 2144 qr.mountpoint, 2145 qr.filesystem, 2146 ) 2147 2148 overall_status = "active" 2149 if not args.no_quota and qr.status in ("failed", "not_configured"): 2150 overall_status = "partial_quota" 2151 2152 log.info("=== fase: verificação final de permissões e artefactos") 2153 verify_user_artifact_permissions( 2154 home, 2155 uid, 2156 gid, 2157 expect_readme=bool(args.with_readme), 2158 ) 2159 2160 log.info("=== fase: IRC WeeChat (patches/patch_irc.py — comando chat, runv / #runv)") 2161 try_patch_irc_for_new_user(user, dry_run=False, log=log) 2162 2163 record = UserRecord( 2164 username=user, 2165 email=email, 2166 public_key_fingerprint=fingerprint, 2167 created_at=datetime.now(timezone.utc).isoformat(), 2168 created_by=operator_user, 2169 home_directory=str(home), 2170 status=overall_status, 2171 quota_enabled=qr.enabled, 2172 quota_soft_mb=qr.soft_mib, 2173 quota_hard_mb=qr.hard_mib, 2174 quota_inode_soft=qr.inode_soft, 2175 quota_inode_hard=qr.inode_hard, 2176 quota_filesystem=qr.filesystem, 2177 quota_mountpoint=qr.mountpoint, 2178 quota_applied_at=qr.applied_at, 2179 quota_status=qr.status, 2180 ) 2181 log.info("=== fase: gravação de metadados JSON (%s)", args.metadata_file) 2182 append_user_metadata(args.metadata_file, args.lock_file, record, log) 2183 2184 members_refreshed = False 2185 members_public_count: int | None = None 2186 if not args.no_refresh_landing_members and args.landing_document_root: 2187 root = args.landing_document_root.resolve() 2188 if root.is_dir(): 2189 log.info("=== fase: sincronizar landing (public + members) (%s)", root) 2190 members_refreshed, members_public_count = try_sync_landing_via_genlanding( 2191 document_root=root, 2192 users_json=args.metadata_file, 2193 homes_root=args.members_homes_root.resolve() 2194 if args.members_homes_root 2195 else None, 2196 log=log, 2197 ) 2198 else: 2199 log.warning( 2200 "DocumentRoot da landing inexistente (%s); constelação/bolhas não actualizadas " 2201 "(corra site/genlanding.py antes ou aponte --landing-document-root para o DocumentRoot real).", 2202 root, 2203 ) 2204 2205 log.info( 2206 "=== resultado final: status=%s quota_status=%s (operação concluída)", 2207 overall_status, 2208 qr.status, 2209 ) 2210 print("Usuário criado com sucesso.") 2211 print(f" home: {home}") 2212 print(" ssh: authorized_keys instalado") 2213 print(" public_html: pronto (index.html estático)") 2214 print(" public_gopher: pronto (gophermap)") 2215 print(" public_gemini: pronto (index.gmi)") 2216 print(" bind Gemini: /var/gemini/users/<user> <- ~/public_gemini (se o diretório existir)") 2217 print(" IRC: comando «chat» → irc.tilde.chat (TLS) #runv (patch_irc.py)") 2218 if args.with_readme: 2219 print(" README.md: criado em ~/README.md (pt-BR)") 2220 else: 2221 print(" README.md: omitido (use --with-readme para criar)") 2222 if args.no_jail: 2223 print(" jail SSH: omitido (padrão; use --with-jail para legado)") 2224 else: 2225 print(" jail SSH: runv-jailed + /srv/jail/<user> (bind home)") 2226 print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") 2227 print(f" fingerprint: {fingerprint}") 2228 print(f" metadados: {args.metadata_file}") 2229 if queue_request is not None: 2230 print(f" pedido aprovado: {queue_request.request_id}") 2231 dr_resolved = ( 2232 args.landing_document_root.resolve() if args.landing_document_root else None 2233 ) 2234 out_members = (dr_resolved / "data" / "members.json") if dr_resolved else None 2235 homes_opt = "" 2236 if args.members_homes_root: 2237 homes_opt = f" --members-homes-root {args.members_homes_root.resolve()}" 2238 if args.no_refresh_landing_members: 2239 print(" landing (public + bolhas): omitida (--no-refresh-landing-members)") 2240 elif dr_resolved is not None: 2241 if not dr_resolved.is_dir(): 2242 print( 2243 f" AVISO landing: DocumentRoot inexistente ({dr_resolved}) — " 2244 "public/members não actualizados. Primeiro: site/genlanding.py (Apache); depois: " 2245 f"python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only " 2246 f"--document-root {dr_resolved} --members-users-json {args.metadata_file}" 2247 f"{homes_opt}", 2248 file=sys.stderr, 2249 ) 2250 elif members_refreshed: 2251 cnt = ( 2252 f", {members_public_count} membro(s) público(s)" 2253 if members_public_count is not None 2254 else "" 2255 ) 2256 print(f" landing (public + bolhas): sincronizado{cnt} → {out_members}") 2257 else: 2258 print( 2259 f" AVISO landing: falha ao sincronizar (ver log). " 2260 f"Manual: python3 {_REPO_ROOT / 'site' / 'genlanding.py'} --sync-public-only " 2261 f"--document-root {dr_resolved} --members-users-json {args.metadata_file}" 2262 f"{homes_opt}", 2263 file=sys.stderr, 2264 ) 2265 if args.no_quota: 2266 print(" quota: omitida (--no-quota)") 2267 else: 2268 print( 2269 f" quota: status={qr.status} " 2270 f"(MiB {args.quota_soft_mb}/{args.quota_hard_mb}, " 2271 f"inodes {args.quota_inode_soft}/{args.quota_inode_hard})" 2272 ) 2273 if qr.mountpoint: 2274 print(f" quota mount: {qr.mountpoint} ({qr.filesystem or '?'})") 2275 2276 welcome_host = (args.welcome_ssh_host or os.environ.get("RUNV_WELCOME_SSH_HOST") or "").strip() 2277 welcome_host_opt: str | None = welcome_host if welcome_host else None 2278 try_send_welcome_email( 2279 username=user, 2280 user_email=email, 2281 fingerprint=fingerprint, 2282 request_id=queue_request.request_id if queue_request else None, 2283 base_url=args.base_url, 2284 welcome_ssh_host=welcome_host_opt, 2285 no_welcome_email=bool(args.no_welcome_email), 2286 dry_run=bool(args.dry_run), 2287 log=log, 2288 ) 2289 try_send_admin_user_created_email( 2290 username=user, 2291 user_email=email, 2292 operator_info=record.created_by, 2293 timestamp=record.created_at, 2294 request_id=queue_request.request_id if queue_request else None, 2295 no_admin_create_email=bool(args.no_admin_create_email), 2296 dry_run=bool(args.dry_run), 2297 log=log, 2298 ) 2299 if queue_request is not None: 2300 archive_approved_queue_request( 2301 queue_request, 2302 operator=operator_user, 2303 created_username=user, 2304 dry_run=bool(args.dry_run), 2305 log=log, 2306 ) 2307 2308 if not args.no_quota and qr.status in ("failed", "not_configured"): 2309 print( 2310 "\n*** AVISO: conta criada mas quota NÃO aplicada ou sistema não configurado. " 2311 "Estado em metadados: partial_quota / quota_status. " 2312 "Corrija usrquota+quotaon e aplique setquota manualmente ou remova o utilizador se foi engano.", 2313 file=sys.stderr, 2314 ) 2315 return EXIT_INCONSISTENT 2316 2317 return EXIT_OK 2318 2319 except ValidationError as e: 2320 log.error("validação: %s", e) 2321 print(f"Validação: {e}", file=sys.stderr) 2322 if created_user: 2323 if run_deluser_remove_home(user, log): 2324 print("Rollback: usuário removido após falha de validação tardia.", file=sys.stderr) 2325 else: 2326 print( 2327 f"ERRO: estado parcial — usuário {user!r} pode existir; remova manualmente se necessário.", 2328 file=sys.stderr, 2329 ) 2330 return EXIT_INCONSISTENT 2331 return EXIT_VALIDATION 2332 2333 except SystemProvisionError as e: 2334 log.exception("falha de sistema: %s", e) 2335 print(f"Erro de sistema: {e}", file=sys.stderr) 2336 if created_user: 2337 if run_deluser_remove_home(user, log): 2338 print("Rollback: usuário e home removidos.", file=sys.stderr) 2339 else: 2340 print( 2341 f"FALHA NO ROLLBACK: revise o usuário {user!r} e o home em {home} manualmente.", 2342 file=sys.stderr, 2343 ) 2344 return EXIT_INCONSISTENT 2345 return EXIT_SYSTEM 2346 2347 except Exception as e: 2348 log.exception("erro inesperado: %s", e) 2349 print(f"Erro inesperado: {e}", file=sys.stderr) 2350 if created_user: 2351 if run_deluser_remove_home(user, log): 2352 print("Rollback: usuário removido.", file=sys.stderr) 2353 else: 2354 print( 2355 f"FALHA NO ROLLBACK: revise o usuário {user!r} manualmente.", 2356 file=sys.stderr, 2357 ) 2358 return EXIT_INCONSISTENT 2359 return EXIT_SYSTEM 2360 2361 2362 def run() -> NoReturn: 2363 raise SystemExit(main()) 2364 2365 2366 if __name__ == "__main__": 2367 run()