""" Утилита для определения типа TLS (стандартный vs ГОСТ TLS) и управления запуском соответствующего браузера. """ import functools import os import shutil import socket import ssl from typing import Any, Dict, Optional, Tuple from urllib.parse import urlparse # pyrefly: ignore [missing-import] import pytest # pyrefly: ignore [missing-import] from playwright.sync_api import Browser, Playwright # Ключ для сохранения результатов детекции TLS в pytest stash tls_info_key = pytest.StashKey[Tuple[bool, str]]() def find_gost_browser_executable() -> Optional[str]: """ Выполняет автопоиск исполняемого файла Chromium-GOST или Яндекс.Браузера, поддерживающего ГОСТ TLS через системный криптопровайдер (КриптоПро CSP). Возвращает: Абсолютный путь к найденному браузеру или None. """ # 1. Приоритет — путь, явно заданный в .env custom_path = os.getenv("CHROMIUM_GOST_PATH", "").strip() if custom_path and os.path.exists(custom_path): return os.path.abspath(custom_path) # 2. Список типовых путей установки в ОС Windows local_app_data = os.environ.get("LOCALAPPDATA", "") prog_files = os.environ.get("ProgramFiles", "") prog_files_x86 = os.environ.get("ProgramFiles(x86)", "") candidates = [ # Пути официального установщика Chromium-GOST (устанавливается в папку Chromium\Application) os.path.join(local_app_data, "Chromium", "Application", "chrome.exe"), os.path.join(local_app_data, "Chromium", "chrome.exe"), os.path.join(local_app_data, "Programs", "Chromium", "Application", "chrome.exe"), os.path.join(prog_files, "Chromium", "Application", "chrome.exe"), os.path.join(prog_files, "Chromium", "chrome.exe"), os.path.join(prog_files_x86, "Chromium", "Application", "chrome.exe"), os.path.join(prog_files_x86, "Chromium", "chrome.exe"), # Портативные пути и архивы Chromium-Gost os.path.join(local_app_data, "Chromium-Gost", "chrome.exe"), os.path.join(local_app_data, "Chromium-Gost", "Application", "chrome.exe"), os.path.join(local_app_data, "Programs", "Chromium-Gost", "chrome.exe"), os.path.join(local_app_data, "Programs", "Chromium-Gost", "Application", "chrome.exe"), os.path.join(prog_files, "Chromium-Gost", "chrome.exe"), os.path.join(prog_files, "Chromium-Gost", "Application", "chrome.exe"), os.path.join(prog_files_x86, "Chromium-Gost", "chrome.exe"), os.path.join(prog_files_x86, "Chromium-Gost", "Application", "chrome.exe"), # Linux / macOS пути "/opt/chromium-gost/chrome", "/usr/bin/chromium-gost", "/Applications/Chromium-Gost.app/Contents/MacOS/Chromium-Gost", ] for path in candidates: if path and os.path.exists(path): return os.path.abspath(path) # 3. Поиск в системном PATH for name in ("chromium-gost", "chrome-gost", "chromium"): found = shutil.which(name) if found: return os.path.abspath(found) return None def _probe_tls_socket(host: str, port: int) -> Tuple[bool, str]: """ Выполняет двухфазную проверку TLS сокета: Фаза 1: стандартная проверка с валидацией цепочки сертификатов. Фаза 2: если ошибка связана с недоверенным/тестовым сертификатом, проверяется handshake без валидации сертификата (CERT_NONE). Если он успешен — это стандартный TLS (не ГОСТ!). Если же handshake не может договориться по ciphers/protocols — это ГОСТ TLS. """ # Фаза 1: Обычный контекст с валидацией ctx = ssl.create_default_context() try: with ctx.wrap_socket(socket.socket(), server_hostname=host) as s: s.settimeout(5) s.connect((host, port)) cipher = s.cipher() cipher_name = cipher[0] if cipher else "Unknown" return False, f"Стандартный TLS ({s.version()}, {cipher_name})" except ssl.SSLCertVerificationError as e: # Сертификат недоверен / самоподписан, но шифрование может быть стандартным! # Проверяем Фазу 2 с CERT_NONE try: unverified_ctx = ssl._create_unverified_context() with unverified_ctx.wrap_socket(socket.socket(), server_hostname=host) as s: s.settimeout(5) s.connect((host, port)) cipher = s.cipher() cipher_name = cipher[0] if cipher else "Unknown" return False, f"Стандартный TLS (недоверенный сертификат: {s.version()}, {cipher_name})" except ssl.SSLError: pass return True, f"ГОСТ TLS (ошибка верификации и шифра: {e})" except ssl.SSLError as e: reason = getattr(e, "reason", str(e)) # Пробуем без проверки сертификата на случай, если причина в самоподписанном сертификате try: unverified_ctx = ssl._create_unverified_context() with unverified_ctx.wrap_socket(socket.socket(), server_hostname=host) as s: s.settimeout(5) s.connect((host, port)) cipher = s.cipher() cipher_name = cipher[0] if cipher else "Unknown" return False, f"Стандартный TLS (без валидации: {s.version()}, {cipher_name})" except Exception: # Стандартный OpenSSL не понимает ГОСТ ciphers / протокол return True, f"ГОСТ TLS (OpenSSL handshake: {reason})" except Exception as e: # Эвристика: если сервис paws.* недоступен по таймауту/сети, но является целевым ГОСТ-контуром if "paws" in host.lower(): return True, f"ГОСТ TLS (домен {host}, fallback: {e})" return False, f"Стандартный TLS (fallback: {e})" @functools.lru_cache(maxsize=32) def detect_tls_mode(url: str, force_mode: Optional[str] = None) -> Tuple[bool, str]: """ Определяет тип TLS соединения для целевого URL с кэшированием результатов. Возвращает: (is_gost: bool, description: str) Приоритет: 1. Аргумент force_mode (например, переданный из CLI pytest --tls-mode) 2. Переменная USE_GOST или TLS_MODE в .env 3. Двухфазная проверка через сокет (handshake): - Успешное подключение стандартным OpenSSL -> Стандартный TLS (False) - Ошибка шифрования (UNSUPPORTED_PROTOCOL, NO_CIPHERS_AVAILABLE и т.д.) -> ГОСТ TLS (True) - Домен содержит "paws" при таймауте -> ГОСТ TLS (True) """ # 1. Приоритет — явный force_mode mode = (force_mode or os.getenv("TLS_MODE") or os.getenv("USE_GOST") or "").strip().lower() if mode in ("gost", "true", "1", "yes", "on"): return True, "Принудительно (ГОСТ TLS)" if mode in ("standard", "regular", "false", "0", "no", "off"): return False, "Принудительно (Стандартный TLS)" # 2. Проверка протокола parsed = urlparse(url) if parsed.scheme == "http": return False, "HTTP (без TLS)" host = parsed.hostname or url port = parsed.port or 443 # 3. Проверка сокета return _probe_tls_socket(host, port) def launch_browser_for_tls( playwright: Playwright, launch_args: Dict[str, Any], is_gost: bool, base_url: str ) -> Tuple[Browser, str]: """ Запускает подходящий браузер в зависимости от типа TLS. Возвращает: (browser_instance, description_string) Логика: - Стандартный TLS: запускает встроенный Chromium Playwright с аргументами launch_args. - ГОСТ TLS: 1. Ищет путь к Chromium-GOST / Яндекс.Браузеру через find_gost_browser_executable(). 2. Если найден — запускает с заданными launch_args. 3. Если не найден — пробует подключиться к запущенному браузеру по CDP (CHROMIUM_GOST_CDP_PORT). 4. Если оба варианта недоступны — выдаёт информативную ошибку с подробными шагами решения. """ cdp_port = os.getenv("CHROMIUM_GOST_CDP_PORT", "9222").strip() if not is_gost: # Стандартный TLS browser = playwright.chromium.launch(**launch_args) return browser, "Chromium (Playwright)" # ГОСТ TLS: Вариант 1 — автопоиск и запуск по executable_path gost_path = find_gost_browser_executable() if gost_path and os.path.exists(gost_path): try: browser = playwright.chromium.launch( executable_path=gost_path, **launch_args ) browser_name = "Яндекс.Браузер" if "yandex" in gost_path.lower() else "Chromium GOST" return browser, f"{browser_name} ({gost_path})" except Exception as e: err_msg = str(e).encode("ascii", errors="replace").decode("ascii") print(f"\n[GOST] Предупреждение: ошибка прямого запуска ({gost_path}): {err_msg}") print("[GOST] Пробуем подключение по CDP...") # ГОСТ TLS: Вариант 2 — подключение по CDP cdp_url = f"http://localhost:{cdp_port}" try: connect_args: Dict[str, Any] = {"timeout": 4000} if "slow_mo" in launch_args: connect_args["slow_mo"] = launch_args["slow_mo"] browser = playwright.chromium.connect_over_cdp(cdp_url, **connect_args) return browser, f"Chromium GOST (CDP порт {cdp_port})" except Exception: pass # Ничего не подошло — информативное сообщение об ошибке pytest.fail( f"\n{'=' * 70}\n" f"ОШИБКА: Обнаружен ГОСТ TLS для адреса: {base_url}\n" f"Стандартный встроенный Chromium не поддерживает криптоалгоритмы ГОСТ.\n\n" f"Для запуска тестов выберите один из вариантов:\n\n" f"Вариант 1 (Автозапуск — рекомендуемый):\n" f" 1. Скачайте Chromium GOST:\n" f" https://github.com/deemru/chromium-gost/releases\n" f" 2. Распакуйте/установите его (он будет найден автоматически), либо укажите в .env:\n" f" CHROMIUM_GOST_PATH=C:\\Path\\To\\chromium-gost\\chrome.exe\n" f" * Примечание: также поддерживается установленный Яндекс.Браузер с КриптоПро CSP.\n\n" f"Вариант 2 (Подключение по CDP):\n" f" 1. Запустите Chromium GOST вручную с флагом отладки:\n" f" chrome.exe --remote-debugging-port={cdp_port} --no-first-run\n" f" 2. Запустите pytest повторно\n" f"{'=' * 70}" )