File size: 12,916 Bytes
c0e2219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""

Диагностика и бенчмарк провайдеров туннелей.



Использование:



from colab_tunnel import benchmark

results = benchmark()                   # все провайдеры

results = benchmark(['serveo', 'bore']) # выборочно

"""
import os
import socket
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from requests import get as get_url

from ._config import test_server_bin, test_server_bin_url
from ._logger import logger
from ._registry import proxies_functions
from ._utils import run, kill_process_by_name, download


@dataclass
class TunnelBenchmarkResult:
    """Результат тестирования одного провайдера."""
    name: str
    url: Optional[str]   = None
    tunnel_time: float   = 0.0       # Время получения ссылки (сек)
    latency: Optional[float] = None  # RTT до публичного URL (сек)
    success: bool        = False
    error: Optional[str] = None


def find_free_port() -> int:
    """Находит свободный TCP-порт в системе."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(('', 0))
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        return s.getsockname()[1]


def _find_test_server_bin() -> Path:
    """

    Ищет бинарник тестового сервера в следующем порядке:

    1. Переменная окружения TEST_SERVER_PATH

    2. ../test_server/test_server (рядом с пакетом)

    3. ./test_server/test_server (текущая директория)

    Если не нашел - скачивает.

    """
    candidates: list[Path] = []

    env = os.environ.get('TEST_SERVER_PATH')
    env_path = Path(env) if env else None
    if env:
        candidates.append(env_path)
    if test_server_bin.exists():
        candidates.append(test_server_bin)

    candidates += [
        Path(__file__).parent.parent / 'test_server' / 'test_server',
        Path.cwd() / 'test_server' / 'test_server',
    ]

    bin_path = None
    for path in candidates:
        if path.exists() and path.is_file():
            bin_path = path

    if not bin_path:
        downloaded = download(test_server_bin_url, save_path=test_server_bin.parent, progress=False)
        if downloaded and downloaded.exists():
            downloaded.chmod(0o755)
            return downloaded

    raise FileNotFoundError(
        'Бинарник тестового сервера не найден. '
        'Укажите путь через переменную окружения TEST_SERVER_PATH.'
    )


def _start_test_server(server_bin: Path, port: int) -> None:
    """Запускает тестовый HTTP-сервер в daemon-режиме."""
    server_bin.chmod(0o755)
    run(f'"{server_bin}" -d -p {port}', timeout=10)
    time.sleep(1.5)  # Даём серверу время на запуск
    logger.debug(f'Тестовый сервер запущен на порту {port}.')


def _stop_test_server(server_bin: Path) -> None:
    """Останавливает тестовый HTTP-сервер."""
    kill_process_by_name(server_bin.name)
    logger.debug('Тестовый сервер остановлен.')


def _check_url_latency(

    url: str,

    timeout: float = 10.0,

    warmup_retries: int = 3,

    warmup_delay: float = 5.0,

) -> Optional[float]:
    """

    Проверяет доступность публичного URL и возвращает RTT.



    Некоторые провайдеры (cloudflared, tunnelite) анонсируют URL раньше,

    чем маршрут фактически распространяется по их сети. cloudflared

    обычно требует 5–15 секунд после получения URL.

    Делаем несколько попыток с паузой между ними.



    Returns:

        RTT в секундах, или None если URL недоступен после всех попыток.

    """
    # Убираем строку с IPv4-паролем, если провайдер вернул её второй строкой
    clean_url = url.split('\n')[0].strip()

    for attempt in range(warmup_retries):
        if attempt > 0:
            logger.debug(
                f'Повтор проверки доступности {attempt + 1}/{warmup_retries}: {clean_url}'
            )
            time.sleep(warmup_delay)
        try:
            t = time.time()
            resp = get_url(clean_url, timeout=timeout, allow_redirects=True)
            elapsed = round(time.time() - t, 3)
            if 200 <= resp.status_code < 400:
                return elapsed
            logger.debug(f'HTTP {resp.status_code} от {clean_url}')
        except Exception as e:
            logger.debug(f'Попытка {attempt + 1}: {e}')

    return None


def _print_report(results: list[TunnelBenchmarkResult]) -> None:
    """Выводит форматированную таблицу результатов."""
    W = {'name': 14, 'status': 10, 'tunnel': 14, 'latency': 12}
    total_w = sum(W.values()) + 38  # 38 — ширина колонки URL
    sep = '=' * total_w

    print(f'\n{sep}')
    print(f'{"БЕНЧМАРК ПРОВАЙДЕРОВ ТУННЕЛЕЙ":^{total_w}}')
    print(sep)
    print(
        f'{"Провайдер":<{W["name"]}}'
        f'{"Статус":<{W["status"]}}'
        f'{"Туннель":<{W["tunnel"]}}'
        f'{"Задержка":<{W["latency"]}}'
        f'URL / Ошибка'
    )
    print('-' * total_w)

    for r in results:
        status  = '✓ OK  ' if r.success else '✗ FAIL'
        t_str   = f'{r.tunnel_time:.2f}с'
        lat_str = f'{r.latency * 1000:.0f}мс' if r.latency else '—'
        detail  = (r.url or r.error or '').split('\n')[0]
        if len(detail) > 38:
            detail = detail[:35] + '…'
        print(
            f'{r.name:<{W["name"]}}'
            f'{status:<{W["status"]}}'
            f'{t_str:<{W["tunnel"]}}'
            f'{lat_str:<{W["latency"]}}'
            f'{detail}'
        )

    print(sep)
    ok = [r for r in results if r.success]
    print(f'Итого: {len(ok)}/{len(results)} провайдеров работают.')

    if ok:
        fastest = min(ok, key=lambda r: r.tunnel_time)
        print(f'Быстрейший запуск  : {fastest.name} ({fastest.tunnel_time:.2f}с)')
        with_lat = [r for r in ok if r.latency]
        if with_lat:
            lowest = min(with_lat, key=lambda r: r.latency)  # type: ignore[arg-type]
            print(f'Минимальная задержка: {lowest.name} ({lowest.latency * 1000:.0f}мс)')  # type: ignore[operator]

    # Пояснение для native — его URL требует сессионные куки Google Colab
    native_result = next((r for r in results if r.name == 'native'), None)
    if native_result and native_result.success and not native_result.latency:
        print('\n  ℹ native: URL работает, но требует Google-авторизации —')
        print('    внешняя проверка без куки браузера всегда будет недоступна.')
    print()


def benchmark(

    providers: list[str] | None = None,

    timeout_per_provider: float = 30.0,

    latency_timeout: float = 10.0,

    print_report: bool = True,

) -> list[TunnelBenchmarkResult]:
    """

    Последовательно тестирует провайдеров туннелей и собирает метрики.



    По умолчанию тестирует только активных провайдеров (из proxies_functions).

    Передать список имён явно — включая отключённых — можно через параметр providers.



    Схема работы:

    1. Запускает тестовый HTTP-сервер.

    2. Последовательно запускает каждого провайдера, измеряя время старта.

    3. Сразу проверяет RTT полученного URL.

    4. После всех провайдеров — второй проход RTT для тех, кто не ответил

       с первого раза (cloudflared, tunnelite: маршрут распространяется 10–15с).

    5. Останавливает тестовый сервер (блок finally).

    6. Выводит форматированный отчёт.



    Args:

        providers:            Имена провайдеров. None — все активные.

        timeout_per_provider: Таймаут ожидания ссылки от провайдера (сек).

        latency_timeout:      Таймаут проверки RTT (сек).

        print_report:         Вывести таблицу результатов.



    Returns:

        Список TunnelBenchmarkResult.

    """
    server_bin = _find_test_server_bin()
    port       = find_free_port()

    # Если провайдеры переданы явно — ищем в _all_providers (включая отключённых).
    # Если нет — берём только активных из proxies_functions.
    if providers is not None:
        from ._registry import _all_providers
        unknown = [n for n in providers if n not in _all_providers]
        if unknown:
            raise ValueError(
                f'Неизвестные провайдеры: {unknown}. '
                f'Все зарегистрированные: {list(_all_providers)}'
            )
        names = providers
        func_map = _all_providers
    else:
        names    = list(proxies_functions.keys())
        func_map = proxies_functions

    print(f'Бенчмарк: {len(names)} провайдеров, порт {port}.')
    _start_test_server(server_bin, port)
    print('Тестовый сервер запущен.\n')

    results: list[TunnelBenchmarkResult] = []

    try:
        # Основной проход: запуск + первая проверка RTT
        for name in names:
            func = func_map[name]
            print(f'[{name:<12}] запуск...', end=' ', flush=True)
            result = TunnelBenchmarkResult(name=name)
            t_start = time.time()

            try:
                url = func(port)
                result.tunnel_time = round(time.time() - t_start, 2)
                result.url     = url
                result.success = True
                print(f'OK ({result.tunnel_time:.2f}с). Проверка...', end=' ', flush=True)

                result.latency = _check_url_latency(url, timeout=latency_timeout)
                if result.latency:
                    print(f'RTT {result.latency * 1000:.0f}мс')
                else:
                    print('URL недоступен (туннель ещё поднимается?)')

            except Exception as e:
                result.tunnel_time = round(time.time() - t_start, 2)
                result.error = str(e)
                print(f'ОШИБКА: {str(e)[:100]}')
                logger.debug(f'[{name}] детали:', exc_info=True)

            results.append(result)

        # Второй проход: RTT для провайдеров с warmup-задержкой
        # К этому моменту прошло достаточно времени (пока тестировались
        # остальные провайдеры), чтобы маршруты cloudflared/tunnelite
        # успели распространиться по сети провайдера.
        needs_recheck = [
            r for r in results
            if r.success and r.latency is None and r.name != 'native'
        ]
        if needs_recheck:
            print(f'\nПовторная проверка RTT ({len(needs_recheck)} провайдеров)...')
            for r in needs_recheck:
                r.latency = _check_url_latency(r.url, timeout=latency_timeout)  # type: ignore[arg-type]
                status = f'RTT {r.latency * 1000:.0f}мс' if r.latency else 'всё ещё недоступен'
                print(f'  [{r.name:<12}] {status}')

    finally:
        print()
        _stop_test_server(server_bin)
        print('Тестовый сервер остановлен.')

    if print_report:
        _print_report(results)

    return results