Files
e2e_Pets/tests/utils/tls.py
VolandSZ cdca9d6003 feat: implement universal runner for standard and GOST TLS
- Add smart two-phase TLS detection with caching in tests/utils/tls.py

- Add auto-discovery for Chromium-GOST and Yandex.Browser

- Add --tls-mode and --ignore-https-errors CLI options

- Update browser_page context with ignore_https_errors and unified viewport

- Add unit tests in tests/utils/test_tls.py

- Update README.md and .env.example
2026-09-03 00:02:36 +03:00

230 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Утилита для определения типа 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 (AppData / Program Files)
os.path.join(local_app_data, "Chromium-Gost", "Application", "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"),
# Яндекс.Браузер (поддерживает ГОСТ TLS при наличии КриптоПро CSP)
os.path.join(local_app_data, "Yandex", "YandexBrowser", "Application", "browser.exe"),
os.path.join(prog_files, "Yandex", "YandexBrowser", "Application", "browser.exe"),
os.path.join(prog_files_x86, "Yandex", "YandexBrowser", "Application", "browser.exe"),
# Linux / macOS пути
"/opt/chromium-gost/chrome",
"/usr/bin/chromium-gost",
"/Applications/Chromium-Gost.app/Contents/MacOS/Chromium-Gost",
"/Applications/Yandex.app/Contents/MacOS/Yandex",
]
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", "yandex-browser"):
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:
print(f"\n[GOST] Предупреждение: ошибка прямого запуска ({gost_path}): {e}")
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}"
)