238 lines
12 KiB
Python
238 lines
12 KiB
Python
"""
|
||
Утилита для определения типа 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}"
|
||
)
|