setup_entre.py (36234B)
1 #!/usr/bin/env python3 2 """ 3 Prepara infraestrutura do utilizador «entre» e instala o módulo terminal em 4 /opt/runv/terminal. 5 6 Onboarding estilo tilde.town (join@tilde.town): 7 O padrão documentado por tilde.town usa utilizador especial + Match User + SSH com 8 PasswordAuthentication, PermitEmptyPasswords yes, PubkeyAuthentication no, e muitas vezes 9 uma linha em /etc/pam.d/sshd com pam_succeed_if (ex.: user ingroup join) para que a 10 autenticação PAM não exija palavra-passe para esse grupo. Não é «sem autenticação» no 11 protocolo: é aceitar palavra-passe vazia / sucesso PAM antecipado só para essa conta 12 e políticas explícitas. Deliberadamente menos seguro — usar só para onboarding público, 13 não para contas normais. 14 15 Modo recomendado neste projecto (default): --auth-mode empty-password 16 Onboarding público estilo tilde.town, com PAM pam_succeed_if por omissão. 17 18 Modo --auth-mode empty-password (primeira classe): 19 Replica o espírito tilde.town para «entre»: senha vazia (passwd -d), grupo suplementar 20 (omissão: entre-open), e por omissão drop-in com AuthenticationMethods keyboard-interactive 21 + KbdInteractiveAuthentication yes (PAM pam_succeed_if sem prompts) — compatível com 22 OpenSSH do Windows, que em geral não envia palavra-passe vazia no método password. 23 Por omissão altera /etc/pam.d/sshd (pam_succeed_if user ingroup …) com backup — no Debian, 24 sem isto o PAM recusa o fluxo e a sessão pode fechar. Use --skip-pam-empty-password-rule 25 só se configurar PAM à mão. 26 Para o esquema README tilde (password + PermitEmptyPasswords yes), use 27 --empty-password-tilde-password-auth (Linux/Git Bash). 28 29 Porque /bin/sh e não nologin: 30 O OpenSSH usa o shell de passwd no contexto do login; nologin impede o fluxo até ao 31 ForceCommand. Use /bin/sh; o visitante não fica com shell interactivo normal. 32 33 Por defeito (sem --skip-sshd): 34 - cria «entre» com /bin/sh; chsh se já existir com outro shell; 35 - em empty-password: grupo onboarding, membro, passwd -d, validação NP, regra PAM (por omissão); 36 - escreve runv-entre.conf; sshd -t; sshd -T -C …; reload ssh. 37 38 Use --skip-sshd / --no-reload / --dry-run conforme necessário. 39 40 Executar como root no servidor Debian. 41 42 Reexecução: com instalação existente, em TTY pede confirmação antes de actualizar o módulo 43 e (em separado) antes de substituir config.toml; use --yes / --force-config para automatizar. 44 45 Versão 0.11 — runv.club 46 """ 47 48 from __future__ import annotations 49 50 import argparse 51 import grp 52 import os 53 import pwd 54 import re 55 import shutil 56 import subprocess 57 import sys 58 import time 59 from pathlib import Path 60 from typing import Final 61 62 _ADMIN_DIR = Path(__file__).resolve().parent.parent / "scripts" / "admin" 63 if str(_ADMIN_DIR) not in sys.path: 64 sys.path.insert(0, str(_ADMIN_DIR)) 65 66 from admin_guard import ensure_admin_cli 67 from gen_config_toml import write_terminal_config_toml # type: ignore 68 69 VERSION: Final[str] = "0.11" 70 ENTRE_USER: Final[str] = "entre" 71 INSTALL_ROOT: Final[Path] = Path("/opt/runv/terminal") 72 QUEUE_DIR: Final[Path] = Path("/var/lib/runv/entre-queue") 73 LOG_DIR: Final[Path] = Path("/var/log/runv") 74 SSHD_DROPIN: Final[Path] = Path("/etc/ssh/sshd_config.d/runv-entre.conf") 75 PAM_SSHD: Final[Path] = Path("/etc/pam.d/sshd") 76 MODULE_SRC: Final[Path] = Path(__file__).resolve().parent 77 78 AUTH_SHARED: Final[str] = "shared-password" 79 AUTH_KEY: Final[str] = "key-only" 80 AUTH_EMPTY: Final[str] = "empty-password" 81 82 # Grupo suplementar para PAM pam_succeed_if (tilde.town usa «join»; aqui «entre-open»). 83 ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT: Final[str] = "entre-open" 84 85 INSECURE_EMPTY_BANNER: Final[str] = """ 86 ****************************************************************************** 87 * AVISO: modo empty-password — onboarding estilo tilde.town / join@tilde.town * 88 * Não é «SSH sem autenticação»: é palavra-passe vazia + políticas só para «entre». * 89 * Qualquer cliente que alcance o porto SSH pode entrar nesta conta. * 90 * Não use para contas normais nem exponha sem firewall / política consciente. * 91 ****************************************************************************** 92 """ 93 94 95 def eprint(msg: str) -> None: 96 print(msg, file=sys.stderr) 97 98 99 def prompt_yes(question: str, *, default: bool) -> bool: 100 """Confirmação em TTY; fora de TTY devolve ``default``.""" 101 if not sys.stdin.isatty(): 102 return default 103 suffix = "[S/n]" if default else "[s/N]" 104 try: 105 raw = input(f"{question}{suffix} ").strip().lower() 106 except EOFError: 107 return default 108 if not raw: 109 return default 110 return raw in ("s", "sim", "y", "yes") 111 112 113 def require_root() -> None: 114 if os.geteuid() != 0: 115 eprint("Execute como root (sudo).") 116 raise SystemExit(1) 117 118 119 def run(cmd: list[str], *, timeout: int = 120) -> None: 120 r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) 121 if r.returncode != 0: 122 err = (r.stderr or r.stdout or "").strip() 123 raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") 124 125 126 def run_capture(cmd: list[str], *, timeout: int = 120) -> str: 127 r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) 128 if r.returncode != 0: 129 err = (r.stderr or r.stdout or "").strip() 130 raise RuntimeError(f"Falhou: {' '.join(cmd)}\n{err}") 131 return (r.stdout or "").strip() 132 133 134 def user_exists(name: str) -> bool: 135 try: 136 pwd.getpwnam(name) 137 except KeyError: 138 return False 139 return True 140 141 142 def group_exists(name: str) -> bool: 143 try: 144 grp.getgrnam(name) 145 except KeyError: 146 return False 147 return True 148 149 150 def user_in_group(username: str, group_name: str) -> bool: 151 try: 152 g = grp.getgrnam(group_name) 153 except KeyError: 154 return False 155 if username in g.gr_mem: 156 return True 157 try: 158 pw = pwd.getpwnam(username) 159 except KeyError: 160 return False 161 return pw.pw_gid == g.gr_gid 162 163 164 def ensure_onboarding_group( 165 group_name: str, 166 *, 167 dry_run: bool, 168 ) -> None: 169 if dry_run: 170 print(f"[dry-run] groupadd -f {group_name!r} (se não existir)") 171 return 172 if not group_exists(group_name): 173 run(["groupadd", group_name]) 174 print(f"Criado grupo {group_name!r}.") 175 else: 176 print(f"Grupo {group_name!r} já existe.") 177 178 179 def ensure_user_in_onboarding_group(group_name: str, *, dry_run: bool) -> None: 180 if dry_run: 181 print(f"[dry-run] usermod -aG {group_name} {ENTRE_USER}") 182 return 183 if user_in_group(ENTRE_USER, group_name): 184 print(f"{ENTRE_USER!r} já está no grupo {group_name!r}.") 185 return 186 run(["usermod", "-aG", group_name, ENTRE_USER]) 187 print(f"Adicionado {ENTRE_USER!r} ao grupo {group_name!r}.") 188 189 190 def pam_line_for_onboarding_group(group_name: str) -> str: 191 return ( 192 "auth [success=done default=ignore] pam_succeed_if.so " 193 f"user ingroup {group_name}" 194 ) 195 196 197 def install_pam_empty_password_rule( 198 group_name: str, 199 *, 200 dry_run: bool, 201 ) -> None: 202 """ 203 Insere regra tilde.town-style antes da autenticação PAM padrão (ex.: @include common-auth). 204 Backup: /etc/pam.d/sshd.bak.<timestamp> 205 """ 206 line = pam_line_for_onboarding_group(group_name) 207 marker = f"runv.club setup_entre.py — onboarding {group_name}" 208 block = ( 209 f"# {marker}\n" 210 f"{line}\n" 211 ) 212 213 if dry_run: 214 print(f"[dry-run] backup + inserir em {PAM_SSHD}:\n{line}") 215 return 216 217 if not PAM_SSHD.is_file(): 218 raise RuntimeError(f"{PAM_SSHD} não existe; não é possível instalar regra PAM.") 219 220 current = PAM_SSHD.read_text(encoding="utf-8", errors="replace") 221 if line in current: 222 print(f"Regra PAM já presente em {PAM_SSHD} (saltar).") 223 return 224 225 backup = PAM_SSHD.with_name(f"{PAM_SSHD.name}.bak.{int(time.time())}") 226 shutil.copy2(PAM_SSHD, backup) 227 print(f"Backup PAM: {backup}") 228 229 lines = current.splitlines(keepends=True) 230 insert_at = 0 231 for i, raw in enumerate(lines): 232 s = raw.strip() 233 if not s or s.startswith("#"): 234 continue 235 if s.startswith("@include") or re.match(r"^auth\s", s): 236 insert_at = i 237 break 238 insert_at = i + 1 239 240 part1 = [lines[j] for j in range(insert_at)] 241 part2 = [lines[j] for j in range(insert_at, len(lines))] 242 new_body = "".join(part1) + block + "".join(part2) 243 PAM_SSHD.write_text(new_body, encoding="utf-8") 244 print(f"Inserida regra PAM em {PAM_SSHD} (antes da auth padrão).") 245 246 247 def ensure_user_entre(*, home: Path, shell: str) -> None: 248 if user_exists(ENTRE_USER): 249 print(f"Utilizador {ENTRE_USER!r} já existe.") 250 return 251 run( 252 [ 253 "useradd", 254 "--create-home", 255 "--home-dir", 256 str(home), 257 "--shell", 258 shell, 259 "--user-group", 260 ENTRE_USER, 261 ] 262 ) 263 print(f"Criado utilizador {ENTRE_USER!r} (shell {shell!r}).") 264 265 266 def ensure_entre_shell(shell: str, *, dry_run: bool) -> None: 267 """Garante shell em passwd (ex.: migração de contas antigas com nologin).""" 268 if dry_run: 269 return 270 pw = pwd.getpwnam(ENTRE_USER) 271 if pw.pw_shell == shell: 272 return 273 run(["chsh", "-s", shell, ENTRE_USER]) 274 print(f"Shell de {ENTRE_USER!r} actualizado de {pw.pw_shell!r} para {shell!r}.") 275 276 277 def ensure_entre_dot_ssh(home: Path, uid: int, gid: int, *, dry_run: bool) -> None: 278 """Garante ~/.ssh/authorized_keys com modos correctos (ficheiro pode ficar vazio).""" 279 if dry_run: 280 print(f"[dry-run] garantiria {home}/.ssh e authorized_keys") 281 return 282 home.mkdir(parents=True, exist_ok=True) 283 try: 284 os.chown(home, uid, gid) 285 except OSError: 286 pass 287 ssh = home / ".ssh" 288 ssh.mkdir(mode=0o700, exist_ok=True) 289 os.chmod(ssh, 0o700) 290 os.chown(ssh, uid, gid) 291 auth = ssh / "authorized_keys" 292 if not auth.exists(): 293 auth.write_text("", encoding="utf-8") 294 os.chmod(auth, 0o600) 295 os.chown(auth, uid, gid) 296 print(f"Garantido {ssh} e {auth} (dono {ENTRE_USER}).") 297 298 299 def clear_entre_password(*, dry_run: bool) -> None: 300 """Palavra-passe vazia (modo empty-password).""" 301 if dry_run: 302 print("[dry-run] passwd -d entre (palavra-passe vazia)") 303 return 304 run(["passwd", "-d", ENTRE_USER]) 305 print(f"Palavra-passe de {ENTRE_USER!r} removida (passwd -d).") 306 307 308 def assert_entre_password_empty(*, dry_run: bool) -> None: 309 """Estado NP em passwd -S (sem palavra-passe utilizável).""" 310 if dry_run: 311 print("[dry-run] validaria passwd -S entre (esperado NP)") 312 return 313 out = run_capture(["passwd", "-S", ENTRE_USER], timeout=30) 314 parts = out.split() 315 if len(parts) < 2: 316 raise RuntimeError(f"passwd -S inesperado: {out!r}") 317 status = parts[1] 318 if status != "NP": 319 raise RuntimeError( 320 f"Esperava estado NP (sem palavra-passe) após passwd -d; obtido {status!r} " 321 f"em «{out}». Verifique bloqueios (usermod -U) ou política de palavras-passe." 322 ) 323 print(f"passwd -S: {ENTRE_USER!r} está NP (sem palavra-passe utilizável).") 324 325 326 def build_sshd_dropin_content( 327 python_path: str, 328 app_path: Path, 329 auth_mode: str, 330 *, 331 empty_ssh_auth: str | None = None, 332 ) -> str: 333 cmd = f"{python_path} {app_path}" 334 header = ( 335 f"# Instalado por runv.club setup_entre.py — auth_mode={auth_mode}\n" 336 f"# Validar: sshd -t\n" 337 ) 338 if auth_mode == AUTH_EMPTY: 339 header += "# Onboarding tilde.town-style: PAM pam_succeed_if + conta especial entre.\n" 340 341 lines = [ 342 header.rstrip(), 343 f"Match User {ENTRE_USER}", 344 ] 345 346 if auth_mode == AUTH_SHARED: 347 lines.extend( 348 [ 349 " AuthenticationMethods password", 350 " PasswordAuthentication yes", 351 " KbdInteractiveAuthentication no", 352 " PubkeyAuthentication no", 353 " PermitEmptyPasswords no", 354 ] 355 ) 356 elif auth_mode == AUTH_KEY: 357 lines.extend( 358 [ 359 " AuthenticationMethods publickey", 360 " PasswordAuthentication no", 361 " KbdInteractiveAuthentication no", 362 " PubkeyAuthentication yes", 363 " PermitEmptyPasswords no", 364 ] 365 ) 366 elif auth_mode == AUTH_EMPTY: 367 # Omissão: keyboard-interactive + PAM (compatível com OpenSSH Windows; sem senha vazia no wire). 368 # tilde-password: como README tilde (password + PermitEmptyPasswords); Linux/Git Bash. 369 if empty_ssh_auth == "password": 370 lines.extend( 371 [ 372 " AuthenticationMethods password", 373 " PasswordAuthentication yes", 374 " KbdInteractiveAuthentication no", 375 " PubkeyAuthentication no", 376 " PermitEmptyPasswords yes", 377 ] 378 ) 379 else: 380 lines.extend( 381 [ 382 " AuthenticationMethods keyboard-interactive", 383 " PasswordAuthentication no", 384 " KbdInteractiveAuthentication yes", 385 " PubkeyAuthentication no", 386 " PermitEmptyPasswords no", 387 ] 388 ) 389 else: 390 raise ValueError(f"auth_mode desconhecido: {auth_mode!r}") 391 392 lines.extend( 393 [ 394 f" ForceCommand {cmd}", 395 " PermitTTY yes", 396 " PermitUserRC no", 397 " X11Forwarding no", 398 " AllowAgentForwarding no", 399 " AllowTcpForwarding no", 400 " PermitTunnel no", 401 " DisableForwarding yes", 402 "", 403 ] 404 ) 405 return "\n".join(lines) 406 407 408 def parse_sshd_t(output: str) -> dict[str, str]: 409 cfg: dict[str, str] = {} 410 for raw in output.splitlines(): 411 line = raw.strip() 412 if not line or line.startswith("#"): 413 continue 414 parts = line.split(None, 1) 415 if len(parts) == 1: 416 cfg[parts[0].lower()] = "" 417 else: 418 cfg[parts[0].lower()] = parts[1].strip() 419 return cfg 420 421 422 def _norm_ws(s: str) -> str: 423 return " ".join(s.split()) 424 425 426 def validate_effective_sshd( 427 *, 428 conn: str, 429 force_command: str, 430 auth_mode: str, 431 empty_ssh_auth: str | None = None, 432 ) -> None: 433 """Confirma opções efectivas para Match User entre via sshd -T -C.""" 434 try: 435 out = run_capture(["sshd", "-T", "-C", conn], timeout=60) 436 except RuntimeError as e: 437 raise RuntimeError( 438 "Validação sshd -T -C falhou (sshd inacessível ou -C inválido?). " 439 f"Detalhe: {e}" 440 ) from e 441 442 cfg = parse_sshd_t(out) 443 errs: list[str] = [] 444 445 fc_eff = _norm_ws(cfg.get("forcecommand", "")) 446 fc_exp = _norm_ws(force_command) 447 if not fc_eff or (fc_eff != fc_exp and fc_exp not in fc_eff and fc_eff not in fc_exp): 448 errs.append(f"forcecommand: esperado «{fc_exp}», efectivo «{fc_eff}»") 449 450 if cfg.get("permittty", "").lower() != "yes": 451 errs.append(f"permittty: esperado yes, efectivo «{cfg.get('permittty', '')}»") 452 453 if cfg.get("disableforwarding", "").lower() != "yes": 454 errs.append( 455 f"disableforwarding: esperado yes, efectivo «{cfg.get('disableforwarding', '')}»" 456 ) 457 458 if "permituserrc" in cfg and cfg.get("permituserrc", "").lower() != "no": 459 errs.append(f"permituserrc: esperado no, efectivo «{cfg.get('permituserrc', '')}»") 460 461 am = cfg.get("authenticationmethods", "").lower().replace(",", " ") 462 pw = cfg.get("passwordauthentication", "").lower() 463 pk = cfg.get("pubkeyauthentication", "").lower() 464 kbd = cfg.get("kbdinteractiveauthentication", "").lower() 465 empty = cfg.get("permitemptypasswords", "").lower() 466 467 if auth_mode == AUTH_SHARED: 468 if "password" not in am.split(): 469 errs.append(f"authenticationmethods: esperado incluir password, efectivo «{am}»") 470 if pw != "yes": 471 errs.append(f"passwordauthentication: esperado yes, efectivo «{pw}»") 472 if pk != "no": 473 errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»") 474 if kbd != "no": 475 errs.append(f"kbdinteractiveauthentication: esperado no, efectivo «{kbd}»") 476 if empty != "no": 477 errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»") 478 elif auth_mode == AUTH_KEY: 479 if "publickey" not in am.split(): 480 errs.append(f"authenticationmethods: esperado incluir publickey, efectivo «{am}»") 481 if pw != "no": 482 errs.append(f"passwordauthentication: esperado no, efectivo «{pw}»") 483 if pk != "yes": 484 errs.append(f"pubkeyauthentication: esperado yes, efectivo «{pk}»") 485 if empty != "no": 486 errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»") 487 elif auth_mode == AUTH_EMPTY: 488 if empty_ssh_auth == "password": 489 if "password" not in am.split(): 490 errs.append(f"authenticationmethods: esperado incluir password, efectivo «{am}»") 491 if pw != "yes": 492 errs.append(f"passwordauthentication: esperado yes, efectivo «{pw}»") 493 if pk != "no": 494 errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»") 495 if kbd != "no": 496 errs.append(f"kbdinteractiveauthentication: esperado no, efectivo «{kbd}»") 497 if empty != "yes": 498 errs.append(f"permitemptypasswords: esperado yes, efectivo «{empty}»") 499 else: 500 if "keyboard-interactive" not in am.split(): 501 errs.append( 502 f"authenticationmethods: esperado incluir keyboard-interactive, efectivo «{am}»" 503 ) 504 if pw != "no": 505 errs.append(f"passwordauthentication: esperado no, efectivo «{pw}»") 506 if kbd != "yes": 507 errs.append( 508 f"kbdinteractiveauthentication: esperado yes, efectivo «{kbd}»" 509 ) 510 if pk != "no": 511 errs.append(f"pubkeyauthentication: esperado no, efectivo «{pk}»") 512 if empty != "no": 513 errs.append(f"permitemptypasswords: esperado no, efectivo «{empty}»") 514 515 if errs: 516 raise RuntimeError( 517 "Validação pós-configuração (sshd -T -C) falhou:\n - " 518 + "\n - ".join(errs) 519 ) 520 521 522 def sshd_main_config_mentions_dropin() -> bool: 523 main = Path("/etc/ssh/sshd_config") 524 if not main.is_file(): 525 return False 526 try: 527 text = main.read_text(encoding="utf-8", errors="replace") 528 except OSError: 529 return False 530 return "sshd_config.d" in text and "Include" in text 531 532 533 def apply_sshd_configuration( 534 python_path: str, 535 app_path: Path, 536 *, 537 install_root: Path, 538 auth_mode: str, 539 sshd_test_connection: str, 540 empty_ssh_auth: str | None, 541 dry_run: bool, 542 skip_sshd: bool, 543 no_reload: bool, 544 ) -> None: 545 force_cmd = f"{python_path} {app_path}" 546 content = build_sshd_dropin_content( 547 python_path, app_path, auth_mode, empty_ssh_auth=empty_ssh_auth 548 ) 549 550 if skip_sshd: 551 print() 552 print("== Modo --skip-sshd: configure o SSH manualmente ==") 553 print( 554 "1. Opcional: editar", 555 install_root / "config.toml", 556 "— admin_email pode ficar vazio se /etc/runv-email.json já tiver admin_email; From padrão noreply@runv.club.", 557 ) 558 print("2. Criar /etc/ssh/sshd_config.d/… com o bloco abaixo.") 559 print("3. sshd -t && systemctl reload ssh") 560 print("4. empty-password: regra PAM por omissão (ou --skip-pam-empty-password-rule).") 561 print("5. Testar conforme --auth-mode.") 562 print() 563 print(content) 564 return 565 566 if dry_run: 567 print(f"[dry-run] escreveria {SSHD_DROPIN} e correria sshd -t + validação -T") 568 print("--- conteúdo ---") 569 print(content) 570 return 571 572 if not sshd_main_config_mentions_dropin(): 573 print( 574 "AVISO: /etc/ssh/sshd_config pode não incluir /etc/ssh/sshd_config.d/*.conf.\n" 575 " Confirme uma linha «Include … sshd_config.d» ou o drop-in não será lido.", 576 file=sys.stderr, 577 ) 578 579 SSHD_DROPIN.parent.mkdir(parents=True, exist_ok=True) 580 backup: Path | None = None 581 if SSHD_DROPIN.is_file(): 582 backup = SSHD_DROPIN.with_name(f"{SSHD_DROPIN.name}.bak.{int(time.time())}") 583 shutil.copy2(SSHD_DROPIN, backup) 584 print(f"Backup do drop-in anterior: {backup}") 585 586 SSHD_DROPIN.write_text(content, encoding="utf-8") 587 SSHD_DROPIN.chmod(0o644) 588 print(f"Escrito {SSHD_DROPIN}") 589 590 def revert() -> None: 591 if backup is not None: 592 shutil.copy2(backup, SSHD_DROPIN) 593 print(f"Revertido {SSHD_DROPIN} a partir de {backup}.", file=sys.stderr) 594 else: 595 try: 596 SSHD_DROPIN.unlink() 597 except OSError: 598 pass 599 print(f"Removido {SSHD_DROPIN}.", file=sys.stderr) 600 601 try: 602 run(["sshd", "-t"]) 603 except RuntimeError as e: 604 revert() 605 raise RuntimeError("sshd -t falhou após instalar drop-in; configuração revertida.") from e 606 607 print("sshd -t: OK.") 608 609 try: 610 validate_effective_sshd( 611 conn=sshd_test_connection, 612 force_command=force_cmd, 613 auth_mode=auth_mode, 614 empty_ssh_auth=empty_ssh_auth, 615 ) 616 except RuntimeError as e: 617 revert() 618 raise RuntimeError( 619 f"{e}\nConfiguração revertida; corrija o Match User ou a string -C de teste." 620 ) from e 621 622 print(f"Validação efectiva sshd -T -C {sshd_test_connection!r}: OK.") 623 624 if no_reload: 625 print("Saltado reload (--no-reload). Execute: systemctl reload ssh") 626 return 627 628 try: 629 run(["systemctl", "reload", "ssh"], timeout=60) 630 except RuntimeError: 631 try: 632 run(["systemctl", "reload", "sshd"], timeout=60) 633 except RuntimeError as e2: 634 raise RuntimeError( 635 "sshd -t e validação passaram mas falhou systemctl reload ssh/sshd; " 636 "recarregue o serviço SSH manualmente." 637 ) from e2 638 print("Serviço SSH recarregado (reload).") 639 640 641 def copy_module(dest: Path, *, dry_run: bool) -> None: 642 files = [ 643 "entre_app.py", 644 "entre_core.py", 645 "closed_app.py", 646 "close_entre.py", 647 "config.example.toml", 648 "gen_config_toml.py", 649 "README.md", 650 ] 651 subdirs = ["templates", "docs", "systemd", "scripts", "data", "examples"] 652 if dry_run: 653 print(f"[dry-run] copiaria para {dest}") 654 return 655 dest.mkdir(parents=True, exist_ok=True) 656 for name in files: 657 src = MODULE_SRC / name 658 if src.is_file(): 659 shutil.copy2(src, dest / name) 660 for sd in subdirs: 661 s = MODULE_SRC / sd 662 if s.is_dir(): 663 d = dest / sd 664 if d.exists(): 665 shutil.rmtree(d) 666 shutil.copytree(s, d) 667 print(f"Módulo copiado para {dest}") 668 669 670 def install_config(dest: Path, *, dry_run: bool, force: bool) -> None: 671 cfg = dest / "config.toml" 672 example = dest / "config.example.toml" 673 if dry_run: 674 print(f"[dry-run] config em {cfg} (gen_config_toml)") 675 return 676 if not example.is_file(): 677 eprint(f"Aviso: {example} não encontrado.") 678 return 679 try: 680 result = write_terminal_config_toml( 681 example=example, out=cfg, force=force, dry_run=False 682 ) 683 except FileNotFoundError as e: 684 eprint(str(e)) 685 return 686 if result == "skipped": 687 print(f"Mantido {cfg} existente (use --force-config para regenerar do example).") 688 else: 689 print(f"Instalado {cfg} (gen_config_toml a partir do example).") 690 691 692 def ensure_install_tree_permissions(root: Path, *, gid: int) -> None: 693 """Permissões determinísticas para o módulo usado pelo ForceCommand.""" 694 # /opt/runv precisa ser atravessável para o utilizador entre chegar ao módulo. 695 parent = root.parent 696 if parent.exists(): 697 try: 698 parent.chmod(0o755) 699 except OSError: 700 pass 701 702 for dirpath, dirs, files in os.walk(root, followlinks=False): 703 current = Path(dirpath) 704 try: 705 os.chown(current, 0, gid) 706 current.chmod(0o750) 707 except OSError: 708 pass 709 710 for name in dirs: 711 p = current / name 712 try: 713 os.chown(p, 0, gid, follow_symlinks=False) 714 p.chmod(0o750) 715 except OSError: 716 pass 717 718 for name in files: 719 p = current / name 720 try: 721 os.chown(p, 0, gid, follow_symlinks=False) 722 p.chmod(0o640) 723 except OSError: 724 pass 725 726 # O ForceCommand executa /usr/bin/python3 entre_app.py; Python precisa ler este ficheiro, 727 # e os módulos/templates adjacentes também precisam ser legíveis pelo utilizador entre. 728 for name in ( 729 "entre_app.py", 730 "entre_core.py", 731 "closed_app.py", 732 "close_entre.py", 733 "gen_config_toml.py", 734 "config.toml", 735 "config.example.toml", 736 ): 737 p = root / name 738 if p.is_file(): 739 p.chmod(0o640) 740 741 742 def print_final_instructions( 743 *, 744 auth_mode: str, 745 install_root: Path, 746 empty_group: str, 747 pam_installed: bool, 748 empty_ssh_auth: str | None, 749 ) -> None: 750 print() 751 print("== Concluído ==") 752 print( 753 f"1. Opcional: {install_root / 'config.toml'} — regenere com " 754 f"python3 {install_root / 'gen_config_toml.py'} --install-root {install_root} " 755 "(ou --force para repor o example). Com /etc/runv-email.json, admin_email pode ficar vazio no TOML." 756 ) 757 758 if auth_mode == AUTH_SHARED: 759 print("2. Acesso por palavra-passe Unix partilhada (definida só pelo root):") 760 print(f" sudo passwd {ENTRE_USER}") 761 print(" ou: echo 'entre:A_SENHA' | sudo chpasswd") 762 print("3. Testar:") 763 print(" ssh entre@runv.club") 764 elif auth_mode == AUTH_KEY: 765 auth_keys = Path(pwd.getpwnam(ENTRE_USER).pw_dir) / ".ssh" / "authorized_keys" 766 print("2. Colocar chaves públicas em (uma linha por chave):") 767 print(f" {auth_keys}") 768 print("3. Testar:") 769 print(" ssh entre@runv.club") 770 elif auth_mode == AUTH_EMPTY: 771 print(INSECURE_EMPTY_BANNER) 772 print("2. Onboarding estilo join@tilde.town:") 773 print(f" - Conta {ENTRE_USER!r} sem palavra-passe utilizável (passwd -d; estado NP).") 774 print(f" - Grupo suplementar {empty_group!r} (para alinhar com PAM pam_succeed_if).") 775 if pam_installed: 776 print(f" - PAM: linha ingroup {empty_group!r} em /etc/pam.d/sshd (com backup .bak.*).") 777 else: 778 print(" - PAM: saltado (--skip-pam-empty-password-rule). No Debian o login com") 779 print(" senha vazia falha sem pam_succeed_if antes de common-auth; volte a correr") 780 print(" o setup sem --skip-pam ou edite /etc/pam.d/sshd à mão.") 781 if empty_ssh_auth == "password": 782 print("3. Testar (Enter em branco no prompt de palavra-passe):") 783 print(" ssh entre@runv.club") 784 print(" Nota: OpenSSH do Windows em geral não envia palavra-passe vazia neste modo.") 785 print(" Use WSL/Git Bash, ou volte a correr o setup sem --empty-password-tilde-password-auth") 786 print(" (omissão: keyboard-interactive, mais compatível com Windows).") 787 else: 788 print("3. Testar (omissão: keyboard-interactive + PAM; pode não pedir palavra-passe):") 789 print(" ssh entre@runv.club") 790 print(" Se aparecer prompt, tente Enter em branco; em Windows este modo costuma funcionar.") 791 792 793 def main() -> int: 794 parser = argparse.ArgumentParser( 795 description="Setup utilizador entre + /opt/runv/terminal + OpenSSH (automatizado).", 796 ) 797 parser.add_argument("--dry-run", action="store_true") 798 parser.add_argument( 799 "-y", 800 "--yes", 801 action="store_true", 802 help="não perguntar em reinstalação; combinar com --force-config para repor config.toml sem prompt", 803 ) 804 parser.add_argument("--force-config", action="store_true", help="sobrescrever config.toml com example") 805 parser.add_argument("--home", type=Path, default=Path(f"/home/{ENTRE_USER}")) 806 parser.add_argument( 807 "--shell", 808 default="/bin/sh", 809 help="shell em passwd (ForceCommand precisa de shell funcional; não use nologin)", 810 ) 811 parser.add_argument( 812 "--auth-mode", 813 choices=[AUTH_SHARED, AUTH_KEY, AUTH_EMPTY], 814 default=AUTH_EMPTY, 815 help="método SSH para «entre» (default: empty-password; onboarding tilde.town-style)", 816 ) 817 parser.add_argument( 818 "--empty-password-group", 819 default=ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT, 820 metavar="GRUPO", 821 help=f"grupo suplementar em empty-password + PAM ingroup (default: {ENTRE_EMPTY_PASSWORD_GROUP_DEFAULT})", 822 ) 823 parser.add_argument( 824 "--empty-password-tilde-password-auth", 825 action="store_true", 826 help="empty-password: password + PermitEmptyPasswords (README tilde); omissão usa " 827 "keyboard-interactive (melhor no OpenSSH do Windows)", 828 ) 829 parser.add_argument( 830 "--skip-pam-empty-password-rule", 831 action="store_true", 832 help="não alterar /etc/pam.d/sshd (empty-password: sem PAM, Debian costuma fechar a sessão)", 833 ) 834 parser.add_argument( 835 "--install-pam-empty-password-rule", 836 action="store_true", 837 help=argparse.SUPPRESS, 838 ) 839 parser.add_argument( 840 "--sshd-test-connection", 841 default="user=entre,host=runv.club,addr=127.0.0.1", 842 help="argumento -C para sshd -T na validação pós-config (user/host/addr do Match)", 843 ) 844 parser.add_argument("--install-root", type=Path, default=INSTALL_ROOT) 845 parser.add_argument("--queue-dir", type=Path, default=QUEUE_DIR) 846 parser.add_argument("--skip-copy", action="store_true", help="não copiar ficheiros do módulo") 847 parser.add_argument( 848 "--skip-sshd", 849 action="store_true", 850 help="não escrever drop-in nem recarregar SSH; imprime bloco para cópia manual", 851 ) 852 parser.add_argument( 853 "--no-reload", 854 action="store_true", 855 help="após sshd -t e validação -T, não executar systemctl reload", 856 ) 857 parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") 858 args = parser.parse_args() 859 ensure_admin_cli( 860 script_name=Path(__file__).name, 861 dry_run=bool(args.dry_run), 862 ) 863 864 if args.empty_password_tilde_password_auth and args.auth_mode != AUTH_EMPTY: 865 eprint("--empty-password-tilde-password-auth só com --auth-mode empty-password.") 866 return 2 867 868 empty_ssh_auth: str | None 869 if args.auth_mode == AUTH_EMPTY: 870 empty_ssh_auth = ( 871 "password" if args.empty_password_tilde_password_auth else "keyboard-interactive" 872 ) 873 else: 874 empty_ssh_auth = None 875 876 if args.auth_mode == AUTH_EMPTY: 877 print(INSECURE_EMPTY_BANNER, file=sys.stderr) 878 if args.skip_pam_empty_password_rule: 879 eprint( 880 "AVISO: --skip-pam-empty-password-rule — em Debian/Ubuntu o stack PAM em " 881 "sshd recusa palavra-passe vazia sem pam_succeed_if; espere «Connection closed» " 882 "após o prompt se não configurar PAM à mão." 883 ) 884 885 require_root() 886 887 ir = args.install_root 888 qd = args.queue_dir 889 empty_group = args.empty_password_group.strip() 890 if not empty_group: 891 eprint("--empty-password-group não pode ser vazio.") 892 return 2 893 894 existing_module = (ir / "entre_app.py").is_file() 895 if ( 896 existing_module 897 and not args.skip_copy 898 and not args.dry_run 899 and not args.yes 900 ): 901 if sys.stdin.isatty(): 902 if not prompt_yes( 903 f"Já existe instalação em {ir} (ficheiros do módulo serão actualizados; " 904 f"config.toml só se pedir abaixo ou usar --force-config). Continuar? ", 905 default=True, 906 ): 907 print("Operação cancelada.") 908 return 0 909 else: 910 print( 911 f"Aviso: instalação existente em {ir}; a actualizar sem prompt " 912 f"(TTY ausente). Use --dry-run para simular ou --yes para suprimir avisos." 913 ) 914 915 pam_done = False 916 apply_pam_empty = ( 917 args.auth_mode == AUTH_EMPTY 918 and not args.skip_pam_empty_password_rule 919 ) 920 921 if not args.skip_copy: 922 copy_module(ir, dry_run=args.dry_run) 923 force_cfg = bool(args.force_config) 924 cfg_path = ir / "config.toml" 925 if ( 926 cfg_path.is_file() 927 and not force_cfg 928 and not args.dry_run 929 and not args.yes 930 and sys.stdin.isatty() 931 ): 932 if prompt_yes( 933 f"Manter {cfg_path} com as suas definições (recomendado) ou substituir " 934 f"por config.example.toml (repor mail_from noreply@runv.club, etc.)? Substituir? ", 935 default=False, 936 ): 937 force_cfg = True 938 install_config(ir, dry_run=args.dry_run, force=force_cfg) 939 940 if not args.dry_run: 941 LOG_DIR.mkdir(parents=True, exist_ok=True) 942 qd.mkdir(parents=True, mode=0o750, exist_ok=True) 943 ensure_user_entre(home=args.home, shell=args.shell) 944 ensure_entre_shell(args.shell, dry_run=False) 945 946 pw = pwd.getpwnam(ENTRE_USER) 947 uid, gid = pw.pw_uid, pw.pw_gid 948 entre_home = Path(pw.pw_dir) 949 ensure_entre_dot_ssh(entre_home, uid, gid, dry_run=False) 950 951 if args.auth_mode == AUTH_EMPTY: 952 ensure_onboarding_group(empty_group, dry_run=False) 953 ensure_user_in_onboarding_group(empty_group, dry_run=False) 954 clear_entre_password(dry_run=False) 955 assert_entre_password_empty(dry_run=False) 956 if apply_pam_empty: 957 install_pam_empty_password_rule(empty_group, dry_run=False) 958 pam_done = True 959 960 os.chown(qd, uid, gid) 961 qd.chmod(0o700) 962 963 log_path = LOG_DIR / "entre.log" 964 if not log_path.exists(): 965 log_path.touch(mode=0o640) 966 os.chown(log_path, uid, gid) 967 log_path.chmod(0o640) 968 969 if ir.exists(): 970 ensure_install_tree_permissions(ir, gid=gid) 971 else: 972 print("[dry-run] utilizador entre, fila, log e .ssh seriam garantidos (sem alterar sistema).") 973 if args.auth_mode == AUTH_EMPTY: 974 ensure_onboarding_group(empty_group, dry_run=True) 975 ensure_user_in_onboarding_group(empty_group, dry_run=True) 976 clear_entre_password(dry_run=True) 977 assert_entre_password_empty(dry_run=True) 978 if apply_pam_empty: 979 install_pam_empty_password_rule(empty_group, dry_run=True) 980 981 py = shutil.which("python3") or "/usr/bin/python3" 982 app = ir / "entre_app.py" 983 984 try: 985 apply_sshd_configuration( 986 py, 987 app, 988 install_root=ir, 989 auth_mode=args.auth_mode, 990 sshd_test_connection=args.sshd_test_connection, 991 empty_ssh_auth=empty_ssh_auth, 992 dry_run=args.dry_run, 993 skip_sshd=args.skip_sshd, 994 no_reload=args.no_reload, 995 ) 996 except RuntimeError as e: 997 eprint(str(e)) 998 return 1 999 1000 if not args.skip_sshd and not args.dry_run: 1001 print_final_instructions( 1002 auth_mode=args.auth_mode, 1003 install_root=ir, 1004 empty_group=empty_group, 1005 pam_installed=pam_done, 1006 empty_ssh_auth=empty_ssh_auth, 1007 ) 1008 1009 return 0 1010 1011 1012 if __name__ == "__main__": 1013 raise SystemExit(main())