entre_core.py (17778B)
1 #!/usr/bin/env python3 2 """ 3 Coisas comuns ao login «entre»: validar username/email/chave, escrever JSON na fila, log, mail. 4 5 Regras de nome e chave batem com o que ``create_runv_user.py`` aceita; o texto ``online_presence`` 6 só existe aqui na fila. PyPI não entra. 7 8 v0.02 — runv.club 9 """ 10 11 from __future__ import annotations 12 13 import json 14 import logging 15 import os 16 import sys 17 import time 18 import pwd 19 import re 20 import subprocess 21 import tempfile 22 import uuid 23 from dataclasses import dataclass 24 from datetime import datetime, timezone 25 from email.message import EmailMessage 26 from pathlib import Path 27 from typing import Any, Final 28 29 import tomllib 30 31 # --- Alinhado a create_runv_user.py (não importar em runtime) ---------------- 32 33 USERNAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$") 34 EMAIL_PATTERN: Final[re.Pattern[str]] = re.compile( 35 r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" 36 r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$" 37 ) 38 39 RESERVED_USERNAMES: Final[frozenset[str]] = frozenset( 40 { 41 "root", 42 "daemon", 43 "bin", 44 "sys", 45 "sync", 46 "games", 47 "man", 48 "lp", 49 "mail", 50 "news", 51 "uucp", 52 "proxy", 53 "www-data", 54 "backup", 55 "list", 56 "irc", 57 "_apt", 58 "nobody", 59 "admin", 60 "postmaster", 61 "entre", 62 "join", 63 "welcome", 64 } 65 ) 66 67 ALLOWED_KEY_TYPES: Final[tuple[str, ...]] = ( 68 "ssh-ed25519", 69 "sk-ssh-ed25519@openssh.com", 70 "ecdsa-sha2-nistp256", 71 "ecdsa-sha2-nistp384", 72 "ecdsa-sha2-nistp521", 73 "ssh-rsa", 74 ) 75 76 FINGERPRINT_SHA256_RE: Final[re.Pattern[str]] = re.compile(r"\b(SHA256:[+A-Za-z0-9/_=-]+)\b") 77 78 PRIVATE_KEY_MARKERS: Final[tuple[str, ...]] = ( 79 "-----BEGIN OPENSSH PRIVATE KEY-----", 80 "-----BEGIN RSA PRIVATE KEY-----", 81 "-----BEGIN EC PRIVATE KEY-----", 82 "-----BEGIN DSA PRIVATE KEY-----", 83 "-----BEGIN PRIVATE KEY-----", 84 "-----BEGIN ENCRYPTED PRIVATE KEY-----", 85 "PuTTY-User-Key-File", 86 ) 87 88 MAX_USERNAME_LEN: Final[int] = 32 89 MAX_EMAIL_LEN: Final[int] = 254 90 MAX_PUBKEY_LEN: Final[int] = 16_384 91 MIN_ONLINE_PRESENCE_LEN: Final[int] = 16 92 MAX_ONLINE_PRESENCE_LEN: Final[int] = 4000 93 94 APP_VERSION: Final[str] = "0.02" 95 SOURCE_TAG: Final[str] = "entre-ssh" 96 # Remetente por omissão das notificações sendmail do fluxo «entre» (cabeçalho From). 97 DEFAULT_MAIL_FROM: Final[str] = "noreply@runv.club" 98 # Antigo default em config.toml antigo — normalizado para noreply@runv.club 99 LEGACY_MAIL_FROM_ENTRE: Final[str] = "entre@runv.club" 100 101 102 class ValidationError(ValueError): 103 """Entrada inválida (mensagem para o utilizador).""" 104 105 106 def load_config(path: Path) -> dict[str, Any]: 107 if not path.is_file(): 108 raise FileNotFoundError(f"config não encontrado: {path}") 109 data = tomllib.loads(path.read_text(encoding="utf-8")) 110 if not isinstance(data, dict): 111 raise ValueError("config TOML inválido: raiz deve ser tabela") 112 return data 113 114 115 def validate_username(username: str) -> str: 116 if not username or not username.strip(): 117 raise ValidationError("o nome de utilizador desejado é obrigatório.") 118 u = username.strip() 119 if len(u) > MAX_USERNAME_LEN: 120 raise ValidationError("nome de utilizador demasiado longo.") 121 if not USERNAME_PATTERN.fullmatch(u): 122 raise ValidationError( 123 "use apenas letras minúsculas, dígitos, _ e -; comece com letra; " 124 "entre 2 e 32 caracteres." 125 ) 126 if u in RESERVED_USERNAMES: 127 raise ValidationError("esse nome está reservado ou não é permitido.") 128 try: 129 pwd.getpwnam(u) 130 except KeyError: 131 pass 132 else: 133 raise ValidationError("esse nome já existe neste servidor.") 134 return u 135 136 137 def validate_online_presence(raw: str) -> str: 138 """Texto livre: URLs, perfis, uma linha por sítio — sem mencionar moderação ao utilizador.""" 139 if raw is None or not str(raw).strip(): 140 raise ValidationError( 141 "indica sítios ou perfis onde possamos ver o teu trabalho ou o que publicas online " 142 f"(mínimo {MIN_ONLINE_PRESENCE_LEN} caracteres). Podes usar várias linhas no passo anterior." 143 ) 144 t = str(raw).strip() 145 if len(t) < MIN_ONLINE_PRESENCE_LEN: 146 raise ValidationError( 147 "esse campo ainda é curto demais — adiciona um link, perfil ou página onde apareças online." 148 ) 149 if len(t) > MAX_ONLINE_PRESENCE_LEN: 150 raise ValidationError( 151 "texto demasiado longo; resume ou escolhe os links mais importantes." 152 ) 153 if "\x00" in t: 154 raise ValidationError("caracteres inválidos no texto.") 155 return t 156 157 158 def validate_email(email: str) -> str: 159 if not email or not email.strip(): 160 raise ValidationError("o email é obrigatório.") 161 if email != email.strip(): 162 raise ValidationError("o email não pode ter espaços no início ou fim.") 163 e = email.strip() 164 if len(e) > MAX_EMAIL_LEN: 165 raise ValidationError("email demasiado longo.") 166 at = e.count("@") 167 if at == 0: 168 raise ValidationError( 169 "indica um endereço com @, por exemplo nome@exemplo.org." 170 ) 171 if at != 1: 172 raise ValidationError("o email deve ter um único @.") 173 if not EMAIL_PATTERN.fullmatch(e): 174 raise ValidationError("formato de email inválido.") 175 return e 176 177 178 def _reject_private_key_blob(raw: str) -> None: 179 s = raw.strip() 180 low = s.lower() 181 for marker in PRIVATE_KEY_MARKERS: 182 if marker.lower() in low: 183 raise ValidationError( 184 "isto parece uma chave **privada**. Nunca a cole aqui. " 185 "Cole apenas a linha da chave **pública** (.pub)." 186 ) 187 188 189 def normalize_public_key(raw: str) -> str: 190 if raw is None or raw == "": 191 raise ValidationError("a chave pública é obrigatória.") 192 if len(raw) > MAX_PUBKEY_LEN: 193 raise ValidationError("linha da chave demasiado longa.") 194 _reject_private_key_blob(raw) 195 if "\n" in raw or "\r" in raw: 196 raise ValidationError("cole uma única linha, sem quebras.") 197 line = raw.strip() 198 if not line: 199 raise ValidationError("chave pública vazia.") 200 parts = line.split() 201 if len(parts) < 2: 202 raise ValidationError("formato inválido: esperado tipo, dados base64 e comentário opcional.") 203 key_type = parts[0] 204 if key_type not in ALLOWED_KEY_TYPES: 205 raise ValidationError( 206 f"tipo de chave não aceite ({key_type!r}). " 207 f"Exemplos: ssh-ed25519, ecdsa-sha2-nistp256, ssh-rsa." 208 ) 209 blob = parts[1] 210 if not re.fullmatch(r"[A-Za-z0-9+/]+=*", blob): 211 raise ValidationError("dados da chave (base64) inválidos.") 212 normalized = key_type + " " + blob 213 if len(parts) > 2: 214 normalized += " " + " ".join(parts[2:]) 215 return normalized 216 217 218 def compute_public_key_fingerprint(public_key_line: str, tmp_dir: Path | None = None) -> str: 219 line = normalize_public_key(public_key_line) 220 fd, tmppath = tempfile.mkstemp(prefix="runv-entre-key-", suffix=".pub", dir=tmp_dir) 221 path = Path(tmppath) 222 try: 223 with os.fdopen(fd, "w", encoding="utf-8") as f: 224 f.write(line + "\n") 225 proc = subprocess.run( 226 ["ssh-keygen", "-l", "-E", "sha256", "-f", str(path)], 227 capture_output=True, 228 text=True, 229 timeout=30, 230 ) 231 if proc.returncode != 0: 232 err = (proc.stderr or proc.stdout or "").strip() 233 raise ValidationError(f"a chave foi rejeitada pelo ssh-keygen: {err}") 234 out = (proc.stdout or "").strip().splitlines() 235 if not out: 236 raise RuntimeError("ssh-keygen não devolveu saída") 237 m = FINGERPRINT_SHA256_RE.search(out[0]) 238 if not m: 239 raise RuntimeError(f"não foi possível ler o fingerprint: {out[0]!r}") 240 return m.group(1) 241 finally: 242 path.unlink(missing_ok=True) 243 244 245 def validate_public_key_line(raw: str) -> tuple[str, str]: 246 normalized = normalize_public_key(raw) 247 fp = compute_public_key_fingerprint(normalized) 248 return normalized, fp 249 250 251 def ssh_remote_context() -> dict[str, str | None]: 252 return { 253 "remote_addr": os.environ.get("SSH_CONNECTION", "").split()[0] 254 if os.environ.get("SSH_CONNECTION") 255 else ( 256 os.environ.get("SSH_CLIENT", "").split()[0] 257 if os.environ.get("SSH_CLIENT") 258 else None 259 ), 260 "ssh_connection": os.environ.get("SSH_CONNECTION"), 261 "ssh_client": os.environ.get("SSH_CLIENT"), 262 "tty": os.environ.get("SSH_TTY"), 263 } 264 265 266 @dataclass 267 class EntrePaths: 268 install_root: Path 269 templates_dir: Path 270 queue_dir: Path 271 log_file: Path 272 config_path: Path 273 274 275 def resolve_paths(cfg: dict[str, Any], install_root: Path) -> EntrePaths: 276 q = os.environ.get("RUNV_ENTRE_QUEUE_DIR", "").strip() 277 queue = Path(q) if q else Path(cfg.get("queue_dir", "/var/lib/runv/entre-queue")) 278 lf_e = os.environ.get("RUNV_ENTRE_LOG_FILE", "").strip() 279 logf = Path(lf_e) if lf_e else Path(cfg.get("log_file", "/var/log/runv/entre.log")) 280 td_e = os.environ.get("RUNV_ENTRE_TEMPLATES_DIR", "").strip() 281 td = Path(td_e) if td_e else Path(cfg.get("templates_dir", str(install_root / "templates"))) 282 return EntrePaths( 283 install_root=install_root, 284 templates_dir=td, 285 queue_dir=queue, 286 log_file=logf, 287 config_path=install_root / "config.toml", 288 ) 289 290 291 def setup_file_logger(log_path: Path) -> logging.Logger: 292 log = logging.getLogger("runv.entre") 293 log.setLevel(logging.INFO) 294 log.handlers.clear() 295 fmt = logging.Formatter("%(asctime)sZ %(levelname)s %(message)s") 296 fmt.converter = time.gmtime 297 try: 298 log_path.parent.mkdir(parents=True, exist_ok=True) 299 fh = logging.FileHandler(log_path, encoding="utf-8") 300 fh.setFormatter(fmt) 301 log.addHandler(fh) 302 except OSError: 303 sh = logging.StreamHandler() 304 fmt_err = logging.Formatter("%(asctime)sZ %(levelname)s %(message)s") 305 fmt_err.converter = time.gmtime 306 sh.setFormatter(fmt_err) 307 log.addHandler(sh) 308 return log 309 310 311 def log_session(logger: logging.Logger, msg: str, *, level: int = logging.INFO) -> None: 312 logger.log(level, msg) 313 314 315 RUNV_EMAIL_STATE_PATH: Final[Path] = Path("/etc/runv-email.json") 316 317 318 def resolve_entre_notify_recipients( 319 cfg: dict[str, Any], 320 *, 321 logger: logging.Logger | None = None, 322 ) -> tuple[str, str]: 323 """ 324 Destinatário e remetente para o email de novo pedido (fluxo entre). 325 326 ``admin_email``: primeiro ``config.toml``; se vazio, ``admin_email`` em 327 :file:`/etc/runv-email.json` (setup Mailgun/msmtp). Assim não é obrigatório 328 repetir o admin no TOML. 329 330 ``mail_from``: TOML ou constante ``DEFAULT_MAIL_FROM`` (``noreply@runv.club``). 331 Valores antigos ``entre@runv.club`` no TOML são normalizados para noreply. 332 Para outro remetente (ex.: domínio Mailgun alternativo), defina ``mail_from`` 333 explicitamente no TOML. 334 """ 335 admin = str(cfg.get("admin_email", "")).strip() 336 mail_raw = str(cfg.get("mail_from", DEFAULT_MAIL_FROM)).strip() 337 mail_from = mail_raw or DEFAULT_MAIL_FROM 338 if mail_from.strip().lower() == LEGACY_MAIL_FROM_ENTRE.lower(): 339 mail_from = DEFAULT_MAIL_FROM 340 if logger is not None: 341 logger.info( 342 "notificação: mail_from legado %s substituído por %s", 343 LEGACY_MAIL_FROM_ENTRE, 344 DEFAULT_MAIL_FROM, 345 ) 346 347 data: dict[str, Any] | None = None 348 if RUNV_EMAIL_STATE_PATH.is_file(): 349 try: 350 raw = json.loads(RUNV_EMAIL_STATE_PATH.read_text(encoding="utf-8")) 351 if isinstance(raw, dict): 352 data = raw 353 except (OSError, json.JSONDecodeError): 354 data = None 355 356 if not admin and data is not None: 357 fe = str(data.get("admin_email", "")).strip() 358 if fe: 359 admin = fe 360 if logger is not None: 361 logger.info( 362 "notificação: admin_email obtido de %s (config.toml vazio)", 363 RUNV_EMAIL_STATE_PATH, 364 ) 365 366 return admin, mail_from 367 368 369 def _try_runv_mailgun_notify( 370 *, 371 admin_email: str, 372 mail_from: str, 373 subject: str, 374 body: str, 375 logger: logging.Logger, 376 ) -> bool: 377 """ 378 Se ``/etc/runv-email.json`` indicar Mailgun, envia via ``lib.mailer.send_mail``. 379 Requer ``RUNV_EMAIL_ROOT`` ou ``email_package_root`` no JSON apontando à pasta ``email/``. 380 """ 381 if not RUNV_EMAIL_STATE_PATH.is_file(): 382 return False 383 try: 384 data = json.loads(RUNV_EMAIL_STATE_PATH.read_text(encoding="utf-8")) 385 except (OSError, json.JSONDecodeError): 386 return False 387 be = str(data.get("backend", "")).lower() 388 mailgun = be == "mailgun" or ( 389 bool(data.get("mailgun_domain")) 390 and bool(data.get("mailgun_region")) 391 and be != "sendmail" 392 ) 393 if not mailgun: 394 return False 395 root = os.environ.get("RUNV_EMAIL_ROOT", "").strip() 396 if not root: 397 root = str(data.get("email_package_root", "")).strip() 398 if not root: 399 logger.warning( 400 "notificação Mailgun: defina email_package_root em %s ou a variável RUNV_EMAIL_ROOT.", 401 RUNV_EMAIL_STATE_PATH, 402 ) 403 return False 404 email_root = str(Path(root).resolve()) 405 if email_root not in sys.path: 406 sys.path.insert(0, email_root) 407 try: 408 from lib.mailer import send_mail 409 except ImportError as e: 410 logger.warning("notificação Mailgun: import lib.mailer falhou: %s", e) 411 return False 412 from_addr = mail_from.strip() or DEFAULT_MAIL_FROM 413 try: 414 send_mail( 415 admin_email.strip(), 416 subject, 417 body, 418 from_addr=from_addr, 419 _state=data, 420 ) 421 except Exception as e: 422 logger.warning("notificação Mailgun falhou: %s", e) 423 return False 424 logger.info("notificação por email (Mailgun API) enviada para %s", admin_email) 425 return True 426 427 428 def sendmail_notify( 429 *, 430 admin_email: str, 431 mail_from: str, 432 subject: str, 433 body: str, 434 sendmail_path: str, 435 logger: logging.Logger, 436 ) -> None: 437 if not admin_email.strip(): 438 logger.info("notificação por email: admin_email vazio, ignorado.") 439 return 440 if _try_runv_mailgun_notify( 441 admin_email=admin_email, 442 mail_from=mail_from, 443 subject=subject, 444 body=body, 445 logger=logger, 446 ): 447 return 448 if not Path(sendmail_path).is_file(): 449 logger.warning( 450 "notificação por email: sendmail não encontrado em %s — pedido continua gravado.", 451 sendmail_path, 452 ) 453 return 454 from_addr = mail_from.strip() or DEFAULT_MAIL_FROM 455 msg = EmailMessage() 456 msg["Subject"] = subject 457 msg["From"] = from_addr 458 msg["To"] = admin_email 459 msg.set_content(body) 460 try: 461 proc = subprocess.run( 462 [sendmail_path, "-t", "-i"], 463 input=msg.as_bytes(), 464 capture_output=True, 465 timeout=60, 466 ) 467 if proc.returncode != 0: 468 err = (proc.stderr or b"").decode("utf-8", errors="replace").strip() 469 logger.warning("sendmail falhou (código %s): %s", proc.returncode, err) 470 else: 471 logger.info("notificação por email enviada para %s", admin_email) 472 except OSError as e: 473 logger.warning("notificação por email: erro ao executar sendmail: %s", e) 474 except subprocess.TimeoutExpired: 475 logger.warning("notificação por email: timeout ao executar sendmail.") 476 477 478 def save_request_json( 479 *, 480 queue_dir: Path, 481 request_id: str, 482 payload: dict[str, Any], 483 logger: logging.Logger, 484 ) -> Path: 485 queue_dir.mkdir(parents=True, exist_ok=True) 486 path = queue_dir / f"{request_id}.json" 487 fd = os.open( 488 str(path), 489 os.O_WRONLY | os.O_CREAT | os.O_EXCL, 490 0o640, 491 ) 492 try: 493 data = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" 494 os.write(fd, data.encode("utf-8")) 495 finally: 496 os.close(fd) 497 logger.info("pedido gravado: %s", path) 498 return path 499 500 501 def build_request_payload( 502 *, 503 request_id: str, 504 username: str, 505 email: str, 506 online_presence: str, 507 public_key: str, 508 fingerprint: str, 509 remote_addr: str | None, 510 tty: str | None, 511 ) -> dict[str, Any]: 512 return { 513 "request_id": request_id, 514 "username": username, 515 "email": email, 516 "online_presence": online_presence, 517 "public_key": public_key, 518 "public_key_fingerprint": fingerprint, 519 "submitted_at": datetime.now(timezone.utc).isoformat(), 520 "remote_addr": remote_addr, 521 "tty": tty, 522 "source": SOURCE_TAG, 523 "status": "pending", 524 "app_version": APP_VERSION, 525 } 526 527 528 def new_request_id() -> str: 529 return str(uuid.uuid4()) 530 531 532 def render_template(path: Path, mapping: dict[str, str]) -> str: 533 text = path.read_text(encoding="utf-8") 534 for k, v in mapping.items(): 535 text = text.replace("{" + k + "}", v) 536 return text 537 538 539 def find_install_root() -> Path: 540 env = os.environ.get("RUNV_ENTRE_ROOT", "").strip() 541 if env: 542 return Path(env).resolve() 543 return Path(__file__).resolve().parent 544 545 546 def find_config_path(install_root: Path) -> Path: 547 env = os.environ.get("RUNV_ENTRE_CONFIG", "").strip() 548 if env: 549 return Path(env).resolve() 550 p = install_root / "config.toml" 551 if p.is_file(): 552 return p 553 example = install_root / "config.example.toml" 554 if example.is_file(): 555 return example 556 return p