runv-server

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

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