rottenstuff commited on
Commit
2f35c7d
·
verified ·
1 Parent(s): dba1ffa

Delete colab_tunnel

Browse files
colab_tunnel/__init__.py DELETED
@@ -1,146 +0,0 @@
1
- """
2
- colab-tunnel — утилиты для туннелирования веб-интерфейсов в Google Colab.
3
-
4
- Быстрый старт:
5
- from colab_tunnel import get_share_link
6
- link = get_share_link('cloudflared', 7860)
7
- print(link)
8
-
9
- Запуск всех провайдеров параллельно:
10
- from colab_tunnel import try_all
11
- print(try_all(7860))
12
-
13
- Бенчмарк — найти лучший провайдер прямо сейчас:
14
- from colab_tunnel import benchmark
15
- results = benchmark()
16
-
17
- Добавить собственный провайдер:
18
- from colab_tunnel import tunnel_provider
19
-
20
- @tunnel_provider('myprovider')
21
- def get_myprovider_url(port: int) -> str:
22
- ...
23
- """
24
- from concurrent.futures import ThreadPoolExecutor, as_completed
25
-
26
- from ._config import WORK_FOLDER
27
- from ._logger import logger
28
- from ._registry import proxies_functions, tunnel_provider, list_providers, enable_provider, disable_provider
29
- from ._utils import run, download
30
-
31
- # Импорт модулей регистрирует провайдеров через @tunnel_provider
32
- from . import _tunnels # noqa: F401
33
- from . import _ssh # noqa: F401
34
-
35
- # Прямой доступ к функциям провайдеров (обратная совместимость)
36
- from ._tunnels import (
37
- get_tmole_url,
38
- get_tunwg_url,
39
- get_cloudflared_url,
40
- get_localt_url,
41
- get_gradio_url,
42
- get_native_url,
43
- get_mmar_url,
44
- get_tunnelite_url,
45
- get_beeceptor_url,
46
- get_bore_url,
47
- get_boredigital_url,
48
- )
49
- from ._ssh import (
50
- get_optimistix_url,
51
- get_srvus_url,
52
- get_serveo_url,
53
- get_localhostrun_url,
54
- )
55
- from ._diagnostics import benchmark, TunnelBenchmarkResult
56
-
57
- __version__ = '1.1.0'
58
-
59
- __all__ = [
60
- # Основной API
61
- 'get_share_link',
62
- 'try_all',
63
- 'benchmark',
64
- 'TunnelBenchmarkResult',
65
- # Реестр (для расширения)
66
- 'disable_provider',
67
- 'enable_provider',
68
- 'list_providers',
69
- 'proxies_functions',
70
- 'tunnel_provider',
71
- # Провайдеры (прямой доступ)
72
- 'get_tmole_url',
73
- 'get_tunwg_url',
74
- 'get_cloudflared_url',
75
- 'get_localt_url',
76
- 'get_gradio_url',
77
- 'get_native_url',
78
- 'get_mmar_url',
79
- 'get_tunnelite_url',
80
- 'get_beeceptor_url',
81
- 'get_bore_url',
82
- 'get_boredigital_url',
83
- 'get_optimistix_url',
84
- 'get_srvus_url',
85
- 'get_serveo_url',
86
- 'get_localhostrun_url',
87
- # Утилиты
88
- 'download',
89
- 'run',
90
- 'WORK_FOLDER',
91
- 'logger',
92
- ]
93
-
94
-
95
- def try_all(port: int) -> str:
96
- """
97
- Параллельно запускает все зарегистрированные провайдеры туннелей.
98
-
99
- В отличие от benchmark(), не измеряет метрики точно, зато возвращает
100
- все работающие ссылки в разы быстрее — за время самого медленного провайдера
101
- вместо суммы времён всех провайдеров.
102
-
103
- Args:
104
- port: Локальный порт для туннелирования.
105
-
106
- Returns:
107
- Строка со всеми полученными ссылками в формате 'провайдер: URL'.
108
- """
109
- results: list[str] = []
110
-
111
- with ThreadPoolExecutor(max_workers=len(proxies_functions)) as executor:
112
- futures = {
113
- executor.submit(func, port): name
114
- for name, func in proxies_functions.items()
115
- }
116
- for future in as_completed(futures):
117
- name = futures[future]
118
- try:
119
- url = future.result()
120
- results.append(f'{name}: {url}')
121
- except Exception as e:
122
- logger.warning(f'[try_all] {name}: {e}')
123
-
124
- return '\n'.join(results)
125
-
126
-
127
- def get_share_link(host: str, port: int) -> str:
128
- """
129
- Получает публичную ссылку на локальный порт через указанный туннель.
130
-
131
- Args:
132
- host: Имя провайдера или 'all' для параллельного запуска всех.
133
- Доступные провайдеры: list(proxies_functions.keys()).
134
- port: Локальный порт веб-интерфейса.
135
-
136
- Returns:
137
- Публичная URL-ссылка (или несколько строк при host='all').
138
- """
139
- if host in proxies_functions:
140
- return proxies_functions[host](port)
141
-
142
- if host == 'all':
143
- return try_all(port)
144
-
145
- available = ', '.join(proxies_functions.keys())
146
- return f'Провайдер "{host}" не найден. Доступные: {available}'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_config.py DELETED
@@ -1,52 +0,0 @@
1
- from os import environ
2
- from pathlib import Path
3
-
4
- # Переопределяется переменной окружения TUNNEL_WORK_DIR
5
- WORK_FOLDER = Path(environ.get('TUNNEL_WORK_DIR', '/content/.config'))
6
-
7
- cloudflare_bin = WORK_FOLDER / 'cloudflared'
8
- tmole_bin = WORK_FOLDER / 'tmole'
9
- tunwg_bin = WORK_FOLDER / 'tunwg'
10
- go_localt_bin = WORK_FOLDER / 'go_localt'
11
- gradio_bin = WORK_FOLDER / 'frpc_linux_amd64'
12
- mmar_bin = WORK_FOLDER / 'mmar'
13
- tunnelite_bin = WORK_FOLDER / 'tunnelite'
14
- beeceptor_bin = WORK_FOLDER / 'beeceptor-cli'
15
- bore_bin = WORK_FOLDER / 'bore'
16
- boredigital_bin = WORK_FOLDER / 'bore_digital'
17
- colab_native_url = WORK_FOLDER / 'colab_url.txt'
18
- links_file = WORK_FOLDER / 'links.txt'
19
-
20
- HEADERS = {
21
- 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
22
- 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
23
- 'cache-control': 'no-cache',
24
- 'device-memory': '8',
25
- 'downlink': '10.0',
26
- 'dpr': '1.5',
27
- 'ect': '4g',
28
- 'origin': 'https://colab.research.google.com',
29
- 'pragma': 'no-cache',
30
- 'priority': 'u=0, i',
31
- 'referer': 'https://colab.research.google.com',
32
- 'rtt': '200',
33
- 'sec-ch-ua': '"Google Chrome";v="148", "Not?A)Brand";v="24", "Chromium";v="148"',
34
- 'sec-ch-ua-arch': '"x86"',
35
- 'sec-ch-ua-bitness': '"64"',
36
- 'sec-ch-ua-full-version': '"148.0.7778.168"',
37
- 'sec-ch-ua-full-version-list': '"Google Chrome";v="148.0.7778.168", "Not?A)Brand";v="24.0.0.0", "Chromium";v="148.0.7778.168"',
38
- 'sec-ch-ua-mobile': '?0',
39
- 'sec-ch-ua-model': '""',
40
- 'sec-ch-ua-platform': '"Windows"',
41
- 'sec-ch-ua-platform-version': '"19.0.0"',
42
- 'sec-ch-ua-wow64': '?0',
43
- 'sec-ch-viewport-height': '592',
44
- 'sec-ch-viewport-width': '1216',
45
- 'sec-fetch-dest': 'document',
46
- 'sec-fetch-mode': 'navigate',
47
- 'sec-fetch-site': 'same-origin',
48
- 'sec-fetch-user': '?1',
49
- 'upgrade-insecure-requests': '1',
50
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.168 Safari/537.36',
51
- 'viewport-width': '1216'
52
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_diagnostics.py DELETED
@@ -1,285 +0,0 @@
1
- """
2
- Диагностика и бенчмарк провайдеров туннелей.
3
-
4
- Использование:
5
- from colab_tunnel import benchmark
6
- results = benchmark() # все провайдеры
7
- results = benchmark(['cloudflared', 'bore']) # выборочно
8
- """
9
- import os
10
- import socket
11
- import time
12
- from dataclasses import dataclass
13
- from pathlib import Path
14
- from typing import Optional
15
-
16
- from requests import get as get_url
17
-
18
- from ._logger import logger
19
- from ._registry import proxies_functions
20
- from ._utils import run, kill_process_by_name
21
-
22
-
23
- @dataclass
24
- class TunnelBenchmarkResult:
25
- """Результат тестирования одного провайдера."""
26
- name: str
27
- url: Optional[str] = None
28
- tunnel_time: float = 0.0 # Время получения ссылки (сек)
29
- latency: Optional[float] = None # RTT до публичного URL (сек)
30
- success: bool = False
31
- error: Optional[str] = None
32
-
33
-
34
- def find_free_port() -> int:
35
- """Находит свободный TCP-порт в системе."""
36
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
37
- s.bind(('', 0))
38
- s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
39
- return s.getsockname()[1]
40
-
41
-
42
- def _find_test_server_bin() -> Path:
43
- """
44
- Ищет бинарник тестового сервера в следующем порядке:
45
- 1. Переменная окружения TEST_SERVER_PATH
46
- 2. ../test_server/test_server (рядом с пакетом)
47
- 3. ./test_server/test_server (текущая директория)
48
- """
49
- candidates: list[Path] = []
50
-
51
- env = os.environ.get('TEST_SERVER_PATH')
52
- if env:
53
- candidates.append(Path(env))
54
-
55
- candidates += [
56
- Path(__file__).parent.parent / 'test_server' / 'test_server',
57
- Path.cwd() / 'test_server' / 'test_server',
58
- ]
59
-
60
- for path in candidates:
61
- if path.exists() and path.is_file():
62
- return path
63
-
64
- raise FileNotFoundError(
65
- 'Бинарник тестового сервера не найден. '
66
- 'Укажите путь через переменную окружения TEST_SERVER_PATH.'
67
- )
68
-
69
-
70
- def _start_test_server(server_bin: Path, port: int) -> None:
71
- """Запускает тестовый HTTP-сервер в daemon-режиме."""
72
- server_bin.chmod(0o755)
73
- run(f'"{server_bin}" -d -p {port}', timeout=10)
74
- time.sleep(1.5) # Даём серверу время на запуск
75
- logger.debug(f'Тестовый сервер запущен на порту {port}.')
76
-
77
-
78
- def _stop_test_server(server_bin: Path) -> None:
79
- """Останавливает тестовый HTTP-сервер."""
80
- kill_process_by_name(server_bin.name)
81
- logger.debug('Тестовый сервер остановлен.')
82
-
83
-
84
- def _check_url_latency(
85
- url: str,
86
- timeout: float = 10.0,
87
- warmup_retries: int = 3,
88
- warmup_delay: float = 5.0,
89
- ) -> Optional[float]:
90
- """
91
- Проверяет доступность публичного URL и возвращает RTT.
92
-
93
- Некоторые провайдеры (cloudflared, tunnelite) анонсируют URL раньше,
94
- чем маршрут фактически распространяется по их сети. cloudflared
95
- обычно требует 5–15 секунд после получения URL.
96
- Делаем несколько попыток с паузой между ними.
97
-
98
- Returns:
99
- RTT в секундах, или None если URL недоступен после всех попыток.
100
- """
101
- # Убираем строку с IPv4-паролем, если провайдер вернул её второй строкой
102
- clean_url = url.split('\n')[0].strip()
103
-
104
- for attempt in range(warmup_retries):
105
- if attempt > 0:
106
- logger.debug(
107
- f'Повтор проверки доступности {attempt + 1}/{warmup_retries}: {clean_url}'
108
- )
109
- time.sleep(warmup_delay)
110
- try:
111
- t = time.time()
112
- resp = get_url(clean_url, timeout=timeout, allow_redirects=True)
113
- elapsed = round(time.time() - t, 3)
114
- if 200 <= resp.status_code < 400:
115
- return elapsed
116
- logger.debug(f'HTTP {resp.status_code} от {clean_url}')
117
- except Exception as e:
118
- logger.debug(f'Попытка {attempt + 1}: {e}')
119
-
120
- return None
121
-
122
-
123
-
124
- def _print_report(results: list[TunnelBenchmarkResult]) -> None:
125
- """Выводит форматированную таблицу результатов."""
126
- W = {'name': 14, 'status': 10, 'tunnel': 14, 'latency': 12}
127
- total_w = sum(W.values()) + 38 # 38 — ширина колонки URL
128
- sep = '=' * total_w
129
-
130
- print(f'\n{sep}')
131
- print(f'{"БЕНЧМАРК ПРОВАЙДЕРОВ ТУННЕЛЕЙ":^{total_w}}')
132
- print(sep)
133
- print(
134
- f'{"Провайдер":<{W["name"]}}'
135
- f'{"Статус":<{W["status"]}}'
136
- f'{"Туннель":<{W["tunnel"]}}'
137
- f'{"Задержка":<{W["latency"]}}'
138
- f'URL / Ошибка'
139
- )
140
- print('-' * total_w)
141
-
142
- for r in results:
143
- status = '✓ OK ' if r.success else '✗ FAIL'
144
- t_str = f'{r.tunnel_time:.2f}с'
145
- lat_str = f'{r.latency * 1000:.0f}мс' if r.latency else '—'
146
- detail = (r.url or r.error or '').split('\n')[0]
147
- if len(detail) > 38:
148
- detail = detail[:35] + '…'
149
- print(
150
- f'{r.name:<{W["name"]}}'
151
- f'{status:<{W["status"]}}'
152
- f'{t_str:<{W["tunnel"]}}'
153
- f'{lat_str:<{W["latency"]}}'
154
- f'{detail}'
155
- )
156
-
157
- print(sep)
158
- ok = [r for r in results if r.success]
159
- print(f'Итого: {len(ok)}/{len(results)} провайдеров работают.')
160
-
161
- if ok:
162
- fastest = min(ok, key=lambda r: r.tunnel_time)
163
- print(f'Быстрейший запуск : {fastest.name} ({fastest.tunnel_time:.2f}с)')
164
- with_lat = [r for r in ok if r.latency]
165
- if with_lat:
166
- lowest = min(with_lat, key=lambda r: r.latency) # type: ignore[arg-type]
167
- print(f'Минимальная задержка: {lowest.name} ({lowest.latency * 1000:.0f}мс)') # type: ignore[operator]
168
-
169
- # Пояснение для native — его URL требует сессионные куки Google Colab
170
- native_result = next((r for r in results if r.name == 'native'), None)
171
- if native_result and native_result.success and not native_result.latency:
172
- print('\n ℹ native: URL работает, но требует Google-авторизации —')
173
- print(' внешняя проверка без куки браузера всегда будет недоступна.')
174
- print()
175
-
176
-
177
- def benchmark(
178
- providers: list[str] | None = None,
179
- timeout_per_provider: float = 30.0,
180
- latency_timeout: float = 10.0,
181
- print_report: bool = True,
182
- ) -> list[TunnelBenchmarkResult]:
183
- """
184
- Последовательно тестирует провайдеров туннелей и собирает метрики.
185
-
186
- По умолчанию тестирует только активных провайдеров (из proxies_functions).
187
- Передать список имён явно — включая отключённых — можно через параметр providers.
188
-
189
- Схема работы:
190
- 1. Запускает тестовый HTTP-сервер.
191
- 2. Последовательно запускает каждого провайдера, измеряя время старта.
192
- 3. Сразу проверяет RTT полученного URL.
193
- 4. После всех провайдеров — второй проход RTT для тех, кто не ответил
194
- с первого раза (cloudflared, tunnelite: маршрут распространяется 10–15с).
195
- 5. Останавливает тестовый сервер (блок finally).
196
- 6. Выводит форматированный отчёт.
197
-
198
- Args:
199
- providers: Имена провайдеров. None — все активные.
200
- timeout_per_provider: Таймаут ожидания ссылки от провайдера (сек).
201
- latency_timeout: Таймаут проверки RTT (сек).
202
- print_report: Вывести таблицу результатов.
203
-
204
- Returns:
205
- Список TunnelBenchmarkResult.
206
- """
207
- server_bin = _find_test_server_bin()
208
- port = find_free_port()
209
-
210
- # Если провайдеры переданы явно — ищем в _all_providers (включая отключённых).
211
- # Если нет — берём только активных из proxies_functions.
212
- if providers is not None:
213
- from ._registry import _all_providers
214
- unknown = [n for n in providers if n not in _all_providers]
215
- if unknown:
216
- raise ValueError(
217
- f'Неизвестные провайдеры: {unknown}. '
218
- f'Все зарегистрированные: {list(_all_providers)}'
219
- )
220
- names = providers
221
- func_map = _all_providers
222
- else:
223
- names = list(proxies_functions.keys())
224
- func_map = proxies_functions
225
-
226
- print(f'Бенчмарк: {len(names)} провайдеров, порт {port}.')
227
- _start_test_server(server_bin, port)
228
- print('Тестовый сервер запущен.\n')
229
-
230
- results: list[TunnelBenchmarkResult] = []
231
-
232
- try:
233
- # ── Основной проход: запуск + первая проверка RTT ────────────────
234
- for name in names:
235
- func = func_map[name]
236
- print(f'[{name:<12}] запуск...', end=' ', flush=True)
237
- result = TunnelBenchmarkResult(name=name)
238
- t_start = time.time()
239
-
240
- try:
241
- url = func(port)
242
- result.tunnel_time = round(time.time() - t_start, 2)
243
- result.url = url
244
- result.success = True
245
- print(f'OK ({result.tunnel_time:.2f}с). Проверка...', end=' ', flush=True)
246
-
247
- result.latency = _check_url_latency(url, timeout=latency_timeout)
248
- if result.latency:
249
- print(f'RTT {result.latency * 1000:.0f}мс')
250
- else:
251
- print('URL недоступен (туннель ещё поднимается?)')
252
-
253
- except Exception as e:
254
- result.tunnel_time = round(time.time() - t_start, 2)
255
- result.error = str(e)
256
- print(f'ОШИБКА: {str(e)[:100]}')
257
- logger.debug(f'[{name}] детали:', exc_info=True)
258
-
259
- results.append(result)
260
-
261
- # ── Второй проход: RTT для провайдеров с warmup-задержкой ────────
262
- # К этому моменту прошло достаточно времени (пока тестировались
263
- # остальные провайдеры), чтобы маршруты cloudflared/tunnelite
264
- # успели распространиться по сети провайдера.
265
- needs_recheck = [
266
- r for r in results
267
- if r.success and r.latency is None and r.name != 'native'
268
- ]
269
- if needs_recheck:
270
- print(f'\nПовторная проверка RTT ({len(needs_recheck)} провайдеров)...')
271
- for r in needs_recheck:
272
- r.latency = _check_url_latency(r.url, timeout=latency_timeout) # type: ignore[arg-type]
273
- status = f'RTT {r.latency * 1000:.0f}мс' if r.latency else 'всё ещё недоступен'
274
- print(f' [{r.name:<12}] {status}')
275
-
276
- finally:
277
- print()
278
- _stop_test_server(server_bin)
279
- print('Тестовый сервер остановлен.')
280
-
281
- if print_report:
282
- _print_report(results)
283
-
284
- return results
285
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_logger.py DELETED
@@ -1,16 +0,0 @@
1
- """
2
- Централизованный логгер для библиотеки colab_tunnel.
3
-
4
- Управление детализацией:
5
- import logging
6
- logging.getLogger('colab_tunnel').setLevel(logging.DEBUG)
7
- """
8
- import logging
9
-
10
- logger = logging.getLogger('colab_tunnel')
11
-
12
- if not logger.handlers:
13
- _handler = logging.StreamHandler()
14
- _handler.setFormatter(logging.Formatter('[%(name)s] %(levelname)s: %(message)s'))
15
- logger.addHandler(_handler)
16
- logger.setLevel(logging.WARNING)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_registry.py DELETED
@@ -1,87 +0,0 @@
1
- """
2
- Реестр провайдеров туннелей.
3
-
4
- Регистрация нового провайдера:
5
- @tunnel_provider('myprovider')
6
- def get_myprovider_url(port: int) -> str: ...
7
-
8
- Отключить провайдера (сервис временно недоступен):
9
- @tunnel_provider('tunwg', enabled=False)
10
- # или в рантайме:
11
- disable_provider('tunwg')
12
-
13
- Включить обратно:
14
- enable_provider('tunwg')
15
-
16
- Посмотреть статус всех:
17
- list_providers()
18
- """
19
- from typing import Callable
20
-
21
- # Активные провайдеры — используются get_share_link(), try_all(), benchmark()
22
- proxies_functions: dict[str, Callable[[int], str]] = {}
23
-
24
- # Все зарегистрированные провайдеры (активные + отключённые)
25
- _all_providers: dict[str, Callable[[int], str]] = {}
26
-
27
- # Имена отключённых провайдеров
28
- _disabled_providers: set[str] = set()
29
-
30
-
31
- def tunnel_provider(name: str, *, enabled: bool = True) -> Callable:
32
- """
33
- Декоратор регистрации провайдера туннеля.
34
-
35
- Args:
36
- name: Уникальное имя провайдера.
37
- enabled: False — провайдер зарегистрирован, но не активен.
38
- Включить позднее через enable_provider(name).
39
- """
40
- def decorator(func: Callable[[int], str]) -> Callable[[int], str]:
41
- _all_providers[name] = func
42
- if enabled:
43
- proxies_functions[name] = func
44
- else:
45
- _disabled_providers.add(name)
46
- return func
47
- return decorator
48
-
49
-
50
- def disable_provider(name: str) -> None:
51
- """
52
- Деактивирует провайдера: убирает из активного реестра.
53
- Функция сохраняется — включить обратно через enable_provider().
54
-
55
- Raises:
56
- KeyError: Провайдер с таким именем не зарегистрирован.
57
- """
58
- if name not in _all_providers:
59
- raise KeyError(f'Провайдер "{name}" не зарегистрирован. Доступные: {list(_all_providers)}')
60
- proxies_functions.pop(name, None)
61
- _disabled_providers.add(name)
62
-
63
-
64
- def enable_provider(name: str) -> None:
65
- """
66
- Активирует ранее отключённого провайдера.
67
-
68
- Raises:
69
- KeyError: Провайдер с таким именем не зарегистрирован.
70
- """
71
- if name not in _all_providers:
72
- raise KeyError(f'Провайдер "{name}" не зарегистрирован. Доступные: {list(_all_providers)}')
73
- _disabled_providers.discard(name)
74
- proxies_functions[name] = _all_providers[name]
75
-
76
-
77
- def list_providers() -> dict[str, bool]:
78
- """
79
- Возвращает статус всех зарегистрированных провайдеров.
80
-
81
- Returns:
82
- Словарь {имя: активен}, в порядке регистрации.
83
-
84
- Пример вывода:
85
- {'cloudflared': True, 'tunwg': False, 'bore': True, ...}
86
- """
87
- return {name: (name not in _disabled_providers) for name in _all_providers}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_ssh.py DELETED
@@ -1,174 +0,0 @@
1
- """
2
- Провайдеры туннелей на основе SSH.
3
- """
4
- import subprocess
5
- from atexit import register as exit_register
6
- from pathlib import Path
7
- from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired
8
-
9
- from ._logger import logger
10
- from ._registry import tunnel_provider
11
- from ._utils import kill_process_by_name, terminate_process, drain_process_output, read_until_pattern
12
-
13
- # Реестр активных SSH-туннелей: имя процесса → объект Popen
14
- # dict вместо list — предотвращает накопление мёртвых процессов при повторных вызовах
15
- _active_tunnels: dict[str, Popen] = {}
16
-
17
-
18
- def _register_active_tunnel(name: str, process: Popen) -> None:
19
- """Регистрирует туннель, корректно завершая предыдущий с тем же именем."""
20
- if name in _active_tunnels:
21
- old = _active_tunnels[name]
22
- try:
23
- old.terminate()
24
- old.wait(timeout=3)
25
- except TimeoutExpired:
26
- old.kill()
27
- old.wait()
28
- except Exception:
29
- pass
30
- _active_tunnels[name] = process
31
-
32
-
33
- def ensure_ssh_key() -> None:
34
- """Гарантирует наличие ed25519 SSH-ключа. Генерирует без пароля при отсутствии."""
35
- ssh_dir = Path.home() / '.ssh'
36
- ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
37
- key_file = ssh_dir / 'id_ed25519'
38
-
39
- if not key_file.exists():
40
- subprocess.run(
41
- ['ssh-keygen', '-t', 'ed25519', '-N', '', '-f', str(key_file)],
42
- stdout=DEVNULL,
43
- stderr=DEVNULL,
44
- )
45
- logger.debug('SSH-ключ ed25519 сгенерирован.')
46
-
47
-
48
- def get_ssh_tunnel_url(
49
- start_commands: list[str],
50
- process_name: str,
51
- url_pattern: str,
52
- timeout: float = 15.0,
53
- ) -> str:
54
- """
55
- Запускает SSH-туннельный процесс и ждёт появления публичного URL.
56
-
57
- stdin намеренно оставляется ОТКРЫТЫМ: SSH завершается при получении EOF,
58
- что прервало бы туннель. Объект процесса сохраняется в _active_tunnels
59
- для предотвращения сборки мусора.
60
-
61
- Args:
62
- start_commands: Полная команда SSH с флагами.
63
- process_name: Идентификатор процесса (часть адреса хоста).
64
- url_pattern: Regex для поиска URL в выводе.
65
- timeout: Таймаут ожидания URL в секундах.
66
-
67
- Returns:
68
- Публичный URL туннеля.
69
- """
70
- ensure_ssh_key()
71
- kill_process_by_name(process_name)
72
-
73
- process = Popen(start_commands, stdout=PIPE, stderr=PIPE, stdin=PIPE)
74
-
75
- try:
76
- url, _ = read_until_pattern(
77
- process=process,
78
- url_pattern=url_pattern,
79
- timeout=timeout,
80
- read_both_streams=True, # SSH может писать URL в stdout или stderr
81
- )
82
- except RuntimeError:
83
- process.terminate()
84
- try:
85
- process.wait(timeout=3)
86
- except TimeoutExpired:
87
- process.kill()
88
- process.wait()
89
- kill_process_by_name(process_name)
90
- raise
91
-
92
- drain_process_output(process)
93
- _register_active_tunnel(process_name, process)
94
- exit_register(terminate_process, process_name, process)
95
-
96
- logger.debug(f'[{process_name}] SSH-туннель: {url}')
97
- return url
98
-
99
-
100
- # ---------------------------------------------------------------------------
101
- # Провайдеры
102
- # ---------------------------------------------------------------------------
103
-
104
- @tunnel_provider('optimistix')
105
- def get_optimistix_url(port: int) -> str:
106
- return get_ssh_tunnel_url(
107
- start_commands=[
108
- 'ssh',
109
- '-o', 'StrictHostKeyChecking=no',
110
- '-o', 'UserKnownHostsFile=/dev/null',
111
- '-o', 'ExitOnForwardFailure=yes',
112
- '-o', 'ServerAliveInterval=10',
113
- '-o', 'ServerAliveCountMax=3',
114
- '-p', '1122',
115
- '-R', f'80:127.0.0.1:{port}',
116
- 'ssh.optimistixtunnel.com',
117
- ],
118
- process_name='ssh.optimistixtunnel.com',
119
- url_pattern=r'https://\S+\.otnl\.link',
120
- )
121
-
122
-
123
- @tunnel_provider('srvus')
124
- def get_srvus_url(port: int) -> str:
125
- return get_ssh_tunnel_url(
126
- start_commands=[
127
- 'ssh',
128
- '-o', 'StrictHostKeyChecking=no',
129
- '-o', 'UserKnownHostsFile=/dev/null',
130
- '-o', 'ExitOnForwardFailure=yes',
131
- '-o', 'ServerAliveInterval=10',
132
- '-o', 'ServerAliveCountMax=3',
133
- '-R', f'1:127.0.0.1:{port}',
134
- 'srv.us',
135
- ],
136
- process_name='srv.us',
137
- url_pattern=r'https://[a-z0-9]{10,}\.srv\.us/?',
138
- )
139
-
140
-
141
- @tunnel_provider('serveo')
142
- def get_serveo_url(port: int) -> str:
143
- return get_ssh_tunnel_url(
144
- start_commands=[
145
- 'ssh',
146
- '-o', 'StrictHostKeyChecking=no',
147
- '-o', 'UserKnownHostsFile=/dev/null',
148
- '-o', 'ExitOnForwardFailure=yes',
149
- '-o', 'ServerAliveInterval=10',
150
- '-o', 'ServerAliveCountMax=3',
151
- '-R', f'80:127.0.0.1:{port}',
152
- 'serveo.net',
153
- ],
154
- process_name='serveo.net',
155
- url_pattern=r'https://\S+\.serveousercontent\.com',
156
- )
157
-
158
-
159
- @tunnel_provider('localhostrun')
160
- def get_localhostrun_url(port: int) -> str:
161
- return get_ssh_tunnel_url(
162
- start_commands=[
163
- 'ssh',
164
- '-o', 'StrictHostKeyChecking=no',
165
- '-o', 'UserKnownHostsFile=/dev/null',
166
- '-o', 'ExitOnForwardFailure=yes',
167
- '-o', 'ServerAliveInterval=10',
168
- '-o', 'ServerAliveCountMax=3',
169
- '-R', f'80:127.0.0.1:{port}',
170
- 'nokey@localhost.run',
171
- ],
172
- process_name='localhost.run',
173
- url_pattern=r'https://(?!admin\b)[a-zA-Z0-9-]+\.(?:localhost\.run|lhr\.life)',
174
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_tunnels.py DELETED
@@ -1,370 +0,0 @@
1
- """
2
- Провайдеры туннелей на основе бинарных утилит.
3
- """
4
- from atexit import register as exit_register
5
- from pathlib import Path
6
- from subprocess import PIPE, Popen
7
- from time import sleep
8
-
9
- from requests import get as get_url
10
-
11
- from ._config import (
12
- WORK_FOLDER,
13
- cloudflare_bin, tmole_bin, tunwg_bin,
14
- go_localt_bin, gradio_bin, mmar_bin,
15
- tunnelite_bin, beeceptor_bin, bore_bin,
16
- boredigital_bin, colab_native_url, links_file,
17
- )
18
- from ._logger import logger
19
- from ._registry import tunnel_provider
20
- from ._utils import (
21
- download, unpack_archive, run,
22
- read_until_pattern, drain_process_output,
23
- kill_process_by_name, terminate_process,
24
- move_path, is_ipv4, _snapshot,
25
- get_github_latest_release_url,
26
- )
27
-
28
-
29
- def get_revproxy_url(
30
- bin_url: str,
31
- need_unpack: bool,
32
- bin_path: Path,
33
- start_commands: list,
34
- read_from_stderr: bool,
35
- url_pattern: str = r'https://\S+',
36
- timeout: float = 20.0,
37
- write_link: bool = False,
38
- stdin_input: str | None = None,
39
- extra_env: dict[str, str] | None = None,
40
- read_both_streams: bool = False,
41
- ) -> str:
42
- """
43
- Универсальный запуск бинарного туннельного провайдера.
44
-
45
- При первом вызове скачивает и устанавливает бинарник.
46
- Использует неблокирующее чтение вывода для поиска URL.
47
-
48
- Args:
49
- bin_url: URL скачивания бинарника (или архива).
50
- need_unpack: Нужно ли распаковывать архив.
51
- bin_path: Целевой путь бинарника на диске.
52
- start_commands: Команда запуска со всеми аргументами.
53
- read_from_stderr: Искать URL в stderr (иначе в stdout).
54
- url_pattern: Regex для поиска публичного URL.
55
- timeout: Максимальное время ожидания URL (сек).
56
- write_link: Сохранять ли ссылку в links.txt.
57
- stdin_input: Строка для отправки в stdin процесса.
58
- extra_env: Дополнительные переменные окружения для процесса.
59
- Накладываются поверх os.environ (не заменяют его).
60
- read_both_streams: Читать ли оба потока.
61
-
62
- Returns:
63
- Публичный URL туннеля.
64
- """
65
- # ── Установка бинарника ──────────────────────────────────────────────
66
- if not bin_path.exists():
67
- WORK_FOLDER.mkdir(parents=True, exist_ok=True)
68
- snapshot_before = _snapshot(WORK_FOLDER)
69
-
70
- if need_unpack:
71
- archive = download(bin_url, save_path=WORK_FOLDER, progress=False)
72
- unpack_archive(archive, WORK_FOLDER, rm_archive=True)
73
- else:
74
- download(bin_url, save_path=bin_path.parent, progress=False)
75
-
76
- new_files = list(_snapshot(WORK_FOLDER) - snapshot_before)
77
-
78
- if not new_files:
79
- raise RuntimeError(
80
- f'В {WORK_FOLDER} не появилось новых файлов после загрузки {bin_url}'
81
- )
82
-
83
- if len(new_files) == 1:
84
- move_path(new_files[0], bin_path)
85
- else:
86
- # Несколько файлов (например, архив с доп. файлами) — берём наибольший
87
- largest = max(new_files, key=lambda f: f.stat().st_size)
88
- move_path(largest, bin_path)
89
- for f in new_files:
90
- if f != largest and f.exists():
91
- f.unlink(missing_ok=True)
92
-
93
- bin_path.chmod(0o755)
94
- logger.debug(f'Установлен бинарник: {bin_path}')
95
-
96
- # ── Завершение предыдущего экземпляра ───────────────────────────────
97
- kill_process_by_name(bin_path.name)
98
- # ── Подготовка окружения ─────────────────────────────────────────────
99
- import os as _os
100
- proc_env = _os.environ.copy()
101
- if extra_env:
102
- proc_env.update(extra_env)
103
- # ── Запуск процесса ──────────────────────────────────────────────────
104
- stdin_flag = PIPE if stdin_input is not None else None
105
- process = Popen(
106
- start_commands,
107
- stdout=PIPE, stderr=PIPE, stdin=stdin_flag,
108
- env=proc_env, # ← передаём окружение
109
- )
110
- if stdin_input is not None and process.stdin:
111
- try:
112
- process.stdin.write(stdin_input.encode())
113
- process.stdin.flush()
114
- finally:
115
- process.stdin.close() # Сигнал EOF — многие утилиты ждут его перед стартом
116
-
117
- # ── Чтение URL из вывода ─────────────────────────────────────────────
118
- try:
119
- url, full_output = read_until_pattern(
120
- process=process,
121
- url_pattern=url_pattern,
122
- timeout=timeout,
123
- read_from_stderr=read_from_stderr,
124
- read_both_streams=read_both_streams, # ← передаём дальше
125
- )
126
- except RuntimeError:
127
- kill_process_by_name(bin_path.name)
128
- try:
129
- process.wait(timeout=3)
130
- except Exception:
131
- process.kill()
132
- process.wait()
133
- raise
134
-
135
- # Слив вывода в фоне — предотвращает блокировку из-за переполнения пайпа
136
- drain_process_output(process)
137
-
138
- # Проверка наличия IPv4 в выводе (пароль/IP у некоторых провайдеров)
139
- ipv4 = next(
140
- (ln.strip() for ln in full_output.splitlines() if is_ipv4(ln.strip())),
141
- None,
142
- )
143
-
144
- exit_register(terminate_process, bin_path.name, process)
145
-
146
- if write_link:
147
- try:
148
- links_file.write_text(url)
149
- except Exception as e:
150
- logger.warning(f'Не удалось записать ссылку в {links_file}: {e}')
151
-
152
- logger.debug(f'[{bin_path.name}] Туннель: {url}')
153
- return f'{url}\n пароль(IP): {ipv4}' if ipv4 else url
154
-
155
-
156
- # ---------------------------------------------------------------------------
157
- # Провайдеры
158
- # ---------------------------------------------------------------------------
159
-
160
- @tunnel_provider('tmole')
161
- def get_tmole_url(port: int) -> str:
162
- return get_revproxy_url(
163
- bin_url='https://tunnelmole.com/downloads/tmole-linux.gz',
164
- need_unpack=True,
165
- bin_path=tmole_bin,
166
- start_commands=[str(tmole_bin), str(port)],
167
- read_from_stderr=False,
168
- timeout=20.0,
169
- )
170
-
171
-
172
- @tunnel_provider('tunwg', enabled=False) # Сервис недоступен с 2026-06-16, включить: enable_provider('tunwg')
173
- def get_tunwg_url(port: int) -> str:
174
- return get_revproxy_url(
175
- bin_url='https://github.com/ntnj/tunwg/releases/latest/download/tunwg',
176
- need_unpack=False,
177
- bin_path=tunwg_bin,
178
- start_commands=[str(tunwg_bin), f'--forward=http://127.0.0.1:{port}'],
179
- read_from_stderr=True,
180
- timeout=20.0,
181
- )
182
-
183
-
184
- @tunnel_provider('cloudflared')
185
- def get_cloudflared_url(port: int) -> str:
186
- return get_revproxy_url(
187
- bin_url='https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
188
- need_unpack=False,
189
- bin_path=cloudflare_bin,
190
- start_commands=[str(cloudflare_bin), 'tunnel', '--url', f'http://127.0.0.1:{port}'],
191
- read_from_stderr=True,
192
- url_pattern=r'(?P<url>https?://\S+\.trycloudflare\.com)',
193
- timeout=25.0,
194
- )
195
-
196
-
197
- @tunnel_provider('localt')
198
- def get_localt_url(port: int) -> str:
199
- return get_revproxy_url(
200
- bin_url='https://huggingface.co/prolapse/go_localt/resolve/main/go_localt',
201
- need_unpack=False,
202
- bin_path=go_localt_bin,
203
- start_commands=[str(go_localt_bin), str(port)],
204
- read_from_stderr=False,
205
- timeout=20.0,
206
- )
207
-
208
-
209
- @tunnel_provider('gradio')
210
- def get_gradio_url(port: int) -> str:
211
- max_attempts = 3
212
- last_error: Exception | None = None
213
-
214
- for attempt in range(max_attempts):
215
- try:
216
- resp = get_url('https://api.gradio.app/v2/tunnel-request', timeout=10)
217
- resp.raise_for_status()
218
- info = resp.json()[0]
219
- remote_host, remote_port = info['host'], int(info['port'])
220
- cmd = [
221
- str(gradio_bin), 'http', '-n', 'random',
222
- '-l', str(port), '-i', '127.0.0.1',
223
- '--uc', '--sd', 'random', '--ue',
224
- '--server_addr', f'{remote_host}:{remote_port}',
225
- '--disable_log_color',
226
- ]
227
- return get_revproxy_url(
228
- bin_url='https://cdn-media.huggingface.co/frpc-gradio-0.1/frpc_linux_amd64',
229
- need_unpack=False,
230
- bin_path=gradio_bin,
231
- start_commands=cmd,
232
- read_from_stderr=False,
233
- timeout=20.0,
234
- )
235
- except Exception as e:
236
- last_error = e
237
- if attempt < max_attempts - 1:
238
- logger.warning(f'Попытка {attempt + 1} Gradio провалилась: {e}. Повтор через 5с...')
239
- sleep(5)
240
-
241
- raise RuntimeError(f'После {max_attempts} попыток Gradio-туннель не запущен: {last_error}')
242
-
243
-
244
- @tunnel_provider('native')
245
- def get_native_url(port: int) -> str:
246
- """
247
- Встроенный прокси Google Colab. Результат кэшируется по номеру порта.
248
- """
249
- try:
250
- from google.colab.output import eval_js # type: ignore[import]
251
- except ImportError:
252
- raise RuntimeError('get_native_url доступен только в среде Google Colab.')
253
-
254
- cache_url_file = colab_native_url
255
- cache_port_file = colab_native_url.with_suffix('.port')
256
-
257
- if cache_url_file.exists() and cache_port_file.exists():
258
- try:
259
- if int(cache_port_file.read_text().strip()) == port:
260
- cached = cache_url_file.read_text().strip()
261
- if cached:
262
- logger.debug(f'native URL из кэша (порт {port}): {cached}')
263
- return cached
264
- except Exception:
265
- pass
266
-
267
- url = eval_js(f'google.colab.kernel.proxyPort({port})')
268
-
269
- try:
270
- WORK_FOLDER.mkdir(parents=True, exist_ok=True)
271
- cache_url_file.write_text(url)
272
- cache_port_file.write_text(str(port))
273
- except Exception as e:
274
- logger.warning(f'Не удалось кэшировать native URL: {e}')
275
-
276
- return url
277
-
278
-
279
- @tunnel_provider('mmar')
280
- def get_mmar_url(port: int) -> str:
281
- return get_revproxy_url(
282
- bin_url='https://github.com/yusuf-musleh/mmar/releases/latest/download/mmar_Linux_x86_64.tar.gz',
283
- need_unpack=True,
284
- bin_path=mmar_bin,
285
- start_commands=[str(mmar_bin), 'client', '--local-port', str(port)],
286
- read_from_stderr=True,
287
- url_pattern=r'(?P<url>https?://\S+\.mmar\.dev)',
288
- timeout=20.0,
289
- )
290
-
291
-
292
- @tunnel_provider('tunnelite')
293
- def get_tunnelite_url(port: int) -> str:
294
- """
295
- tunnelite — интерактивное TUI-приложение, которое проверяет isatty(stdout).
296
- Без PTY оно выводит URL и сразу завершается, не поддерживая туннель → 404.
297
- `script -q -c "..." /dev/null` выделяет pseudo-TTY для дочернего процесса,
298
- tunnelite думает что работает в терминале и держит соединение.
299
- script при этом транслирует весь вывод в свой stdout — читаем через обычный PIPE.
300
- """
301
- kill_process_by_name(tunnelite_bin.name)
302
- return get_revproxy_url(
303
- bin_url='https://github.com/hehepiska/tunnellite-linux/releases/download/latest/tunnelite',
304
- need_unpack=False,
305
- bin_path=tunnelite_bin,
306
- start_commands=[
307
- 'script', '-q', '-c',
308
- f'{tunnelite_bin} http://127.0.0.1:{port}',
309
- '/dev/null',
310
- ],
311
- read_from_stderr=False,
312
- url_pattern=r'(?P<url>https?://\S+\.tunnelite\.com)',
313
- timeout=45.0,
314
- )
315
-
316
-
317
- @tunnel_provider('beeceptor')
318
- def get_beeceptor_url(port: int) -> str:
319
- return get_revproxy_url(
320
- bin_url='https://cdn.beeceptor.com/downloads/cli/latest/beeceptor-cli-linux-x64.tar.gz',
321
- need_unpack=True,
322
- bin_path=beeceptor_bin,
323
- start_commands=[str(beeceptor_bin), '-p', str(port), '-y'],
324
- read_from_stderr=False,
325
- url_pattern=r'(?P<url>https?://\S+\.beeceptor\.com)',
326
- timeout=20.0,
327
- stdin_input='\n',
328
- )
329
-
330
-
331
- @tunnel_provider('bore')
332
- def get_bore_url(port: int) -> str:
333
- bin_url = 'https://github.com/ekzhang/bore/releases/download/v0.6.0/bore-v0.6.0-x86_64-unknown-linux-musl.tar.gz'
334
- if not bore_bin.exists():
335
- try:
336
- bin_url = get_github_latest_release_url(
337
- 'ekzhang/bore',
338
- r'bore-v[\d.]+-x86_64-unknown-linux-musl\.tar\.gz',
339
- )
340
- logger.debug(f'Актуальный URL bore: {bin_url}')
341
- except Exception as e:
342
- logger.warning(f'GitHub API недоступен, используется закреплённая версия bore: {e}')
343
-
344
- raw = get_revproxy_url(
345
- bin_url=bin_url,
346
- need_unpack=True,
347
- bin_path=bore_bin,
348
- start_commands=[str(bore_bin), 'local', str(port), '--to', 'bore.pub'],
349
- # read_both_streams: bore пишет в stderr через tracing-subscriber,
350
- # но читаем оба потока — на случай если конкретная сборка пишет в stdout
351
- read_from_stderr=False,
352
- read_both_streams=True,
353
- url_pattern=r'bore\.pub:\d+',
354
- timeout=20.0,
355
- )
356
- return f'http://{raw}/'
357
-
358
-
359
-
360
- @tunnel_provider('boredigital')
361
- def get_boredigital_url(port: int) -> str:
362
- return get_revproxy_url(
363
- bin_url='https://github.com/jkuri/bore/releases/latest/download/bore_linux_amd64',
364
- need_unpack=False,
365
- bin_path=boredigital_bin,
366
- start_commands=[str(boredigital_bin), '-lp', str(port)],
367
- read_from_stderr=False,
368
- url_pattern=r'(?P<url>https://\S+\.bore\.digital)',
369
- timeout=20.0,
370
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
colab_tunnel/_utils.py DELETED
@@ -1,590 +0,0 @@
1
- """
2
- Вспомогательные утилиты: процессы, архивы, сеть, чтение потоков.
3
- """
4
- import os
5
- import re
6
- import select
7
- import threading
8
- import time
9
- from os import name as os_name
10
- from pathlib import Path
11
- from shutil import move as shutil_move
12
- from subprocess import (
13
- PIPE, Popen, STDOUT, TimeoutExpired,
14
- check_output, CalledProcessError,
15
- )
16
- from sys import stdout as sys_stdout
17
- from typing import TypedDict
18
- from urllib.parse import unquote, urlparse
19
-
20
- from requests import get as get_url, head as get_head
21
- from requests.structures import CaseInsensitiveDict
22
-
23
- from ._config import WORK_FOLDER, HEADERS
24
- from ._logger import logger
25
-
26
-
27
- # ---------------------------------------------------------------------------
28
- # Типы
29
- # ---------------------------------------------------------------------------
30
-
31
- class RunResult(TypedDict):
32
- status_code: int
33
- output: str
34
-
35
-
36
- # ---------------------------------------------------------------------------
37
- # Процессы
38
- # ---------------------------------------------------------------------------
39
-
40
- def run(
41
- command: str,
42
- cwd: str | Path | None = None,
43
- live_output: bool = False,
44
- timeout: float | None = None,
45
- ) -> RunResult:
46
- """
47
- Выполняет shell-команду, возвращает код выхода и вывод.
48
-
49
- Args:
50
- command: Shell-команда.
51
- cwd: Рабочая директория (None — текущая).
52
- live_output: Выводить прогресс в реальном времени.
53
- timeout: Таймаут в секундах (None — без ограничений).
54
-
55
- Returns:
56
- RunResult: {'status_code': int, 'output': str}.
57
- При таймауте status_code == -1.
58
- """
59
- _encodings = ['utf-8', 'iso-8859-5', 'windows-1251', 'cp866', 'koi8-r', 'mac_cyrillic']
60
- _progress_re = re.compile(r'\d+%|\d+/\d+')
61
-
62
- def _decode(data: bytes) -> str:
63
- for enc in _encodings:
64
- try:
65
- return data.decode(enc)
66
- except UnicodeDecodeError:
67
- continue
68
- return data.decode('utf-8', errors='replace')
69
-
70
- process = Popen(command, shell=True, cwd=cwd, stdout=PIPE, stderr=STDOUT)
71
-
72
- # Режим без живого вывода: communicate() с поддержкой таймаута
73
- if not live_output:
74
- try:
75
- stdout_data, _ = process.communicate(timeout=timeout)
76
- except TimeoutExpired:
77
- process.kill()
78
- process.communicate()
79
- msg = f'Таймаут ({timeout}с): {command}'
80
- logger.warning(msg)
81
- return RunResult(status_code=-1, output=msg)
82
- lines = [ln.strip() for ln in _decode(stdout_data).splitlines() if ln.strip()]
83
- return RunResult(status_code=process.returncode, output='\n'.join(lines))
84
-
85
- # Режим живого вывода (для ручного использования, таймаут приблизительный)
86
- final_output: list[str] = []
87
- last_progress = ''
88
- deadline = time.time() + timeout if timeout else None
89
-
90
- for raw_line in iter(process.stdout.readline, b''): # type: ignore[union-attr]
91
- if deadline and time.time() > deadline:
92
- process.kill()
93
- process.wait()
94
- final_output.append(f'[ТАЙМАУТ после {timeout}с]')
95
- break
96
- line = _decode(raw_line.strip())
97
- if _progress_re.search(line):
98
- last_progress = line
99
- sys_stdout.write('\r' + line)
100
- sys_stdout.flush()
101
- else:
102
- final_output.append(line)
103
- sys_stdout.write(line + '\n')
104
- sys_stdout.flush()
105
-
106
- try:
107
- process.wait(timeout=5)
108
- except TimeoutExpired:
109
- process.kill()
110
- process.wait()
111
-
112
- if last_progress:
113
- final_output.append(last_progress)
114
-
115
- return RunResult(status_code=process.returncode, output='\n'.join(final_output))
116
-
117
-
118
- def kill_process_by_name(name: str) -> None:
119
- """
120
- Завершает процессы текущего пользователя по подстроке имени.
121
- Ограничение по UID предотвращает случайное завершение чужих процессов.
122
- """
123
- run(f'pkill -TERM -u "$(id -u)" -f "{name}" 2>/dev/null; true', timeout=5)
124
-
125
-
126
- def is_process_running(process_name: str | Path) -> bool:
127
- try:
128
- output = check_output(['pgrep', '-f', str(process_name)], text=True)
129
- return bool(output.strip())
130
- except CalledProcessError:
131
- return False
132
-
133
-
134
- def terminate_process(process_name: str, process_obj: Popen) -> None:
135
- """
136
- Корректно завершает процесс туннеля.
137
- Сначала SIGTERM + wait(5с), при зависании — SIGKILL.
138
- """
139
- for stream in (process_obj.stdout, process_obj.stderr, process_obj.stdin):
140
- if stream:
141
- try:
142
- stream.close()
143
- except Exception:
144
- pass
145
-
146
- try:
147
- process_obj.terminate()
148
- process_obj.wait(timeout=5)
149
- except TimeoutExpired:
150
- process_obj.kill()
151
- process_obj.wait()
152
- except Exception:
153
- pass
154
-
155
- kill_process_by_name(process_name)
156
-
157
-
158
- def _drain_raw(raw_stream: object) -> None:
159
- """Сливает данные из сырого потока в никуда, предотвращая блокировку пайпа."""
160
- try:
161
- os.set_blocking(raw_stream.fileno(), True) # type: ignore[attr-defined]
162
- except Exception:
163
- pass
164
- try:
165
- while True:
166
- data = raw_stream.read(4096) # type: ignore[attr-defined]
167
- if not data:
168
- break
169
- except Exception:
170
- pass
171
-
172
-
173
- def drain_process_output(process: Popen) -> None:
174
- """
175
- Запускает daemon-потоки для слива stdout/stderr долгоживущего процесса.
176
-
177
- Без этого туннельный процесс заблокируется, когда системный буфер
178
- пайпа (обычно 64 КБ) заполнится непрочитанными логами.
179
- """
180
- for stream in (process.stdout, process.stderr):
181
- if stream:
182
- raw = getattr(stream, 'raw', stream)
183
- t = threading.Thread(target=_drain_raw, args=(raw,), daemon=True)
184
- t.start()
185
-
186
-
187
- def read_until_pattern(
188
- process: Popen,
189
- url_pattern: str,
190
- timeout: float = 20.0,
191
- read_from_stderr: bool = False,
192
- read_both_streams: bool = False,
193
- ) -> tuple[str, str]:
194
- """
195
- Читает вывод процесса с таймаутом, ища URL-паттерн.
196
-
197
- Использует блокирующие read() в daemon-потоках вместо select() +
198
- неблокирующих read(). select()-подход даёт гонку при быстрых коротких
199
- записях (< 1мс): select сигнализирует «readable», но к моменту вызова
200
- read() данные ещё не попали в буфер пайпа — read возвращает пусто.
201
- Блокирующий read в потоке этой гонки не имеет: он просто ждёт данных.
202
-
203
- После нахождения URL потоки автоматически переходят в режим дренирования:
204
- продолжают читать и отбрасывать данные, предотвращая переполнение
205
- системного буфера пайпа (обычно 64 КБ) за время жизни туннеля.
206
-
207
- Args:
208
- process: Запущенный Popen с открытыми потоками PIPE.
209
- url_pattern: Regex для поиска URL.
210
- timeout: Максимальное время ожидания (сек).
211
- read_from_stderr: Читать из stderr (иначе stdout).
212
- read_both_streams: Читать из обоих потоков одновременно (для SSH).
213
-
214
- Returns:
215
- Кортеж (matched_url, full_output_so_far).
216
-
217
- Raises:
218
- RuntimeError: Паттерн не найден за timeout секунд.
219
- """
220
- import queue as _queue
221
-
222
- if read_both_streams:
223
- active = [s for s in (process.stdout, process.stderr) if s]
224
- elif read_from_stderr:
225
- active = [process.stderr] if process.stderr else []
226
- else:
227
- active = [process.stdout] if process.stdout else []
228
-
229
- if not active:
230
- raise RuntimeError('Потоки вывода процесса недоступны.')
231
-
232
- q: _queue.Queue[bytes | None] = _queue.Queue()
233
- drain_mode = threading.Event()
234
-
235
- def _reader(stream) -> None:
236
- """
237
- Блокирующий читатель в daemon-потоке.
238
- Пока URL не найден — пушит чанки в очередь.
239
- После установки drain_mode — читает и отбрасывает (дренирование).
240
- """
241
- raw = getattr(stream, 'raw', stream)
242
- try:
243
- # Явно возвращаем в блокирующий режим на случай,
244
- # если где-то раньше FD был переведён в неблокирующий
245
- os.set_blocking(raw.fileno(), True)
246
- except Exception:
247
- pass
248
- try:
249
- while True:
250
- chunk = raw.read(4096)
251
- if not chunk: # EOF — процесс закрыл поток
252
- break
253
- if drain_mode.is_set():
254
- pass # Дренирование: читаем и отбрасываем
255
- else:
256
- q.put(chunk) # Ищем URL: передаём в очередь
257
- except Exception:
258
- pass
259
- finally:
260
- # Сигнал EOF только если мы всё ещё ищем URL
261
- if not drain_mode.is_set():
262
- q.put(None)
263
-
264
- for stream in active:
265
- t = threading.Thread(target=_reader, args=(stream,), daemon=True)
266
- t.start()
267
-
268
- buffer = b''
269
- deadline = time.time() + timeout
270
- n_sentinels = 0
271
-
272
- while time.time() < deadline:
273
- remaining = max(0.01, deadline - time.time())
274
- try:
275
- item = q.get(timeout=min(remaining, 0.5))
276
- except _queue.Empty:
277
- if process.poll() is not None:
278
- # Процесс завершился — дочитываем остатки из очереди
279
- while True:
280
- try:
281
- item = q.get_nowait()
282
- except _queue.Empty:
283
- break
284
- if item is None:
285
- n_sentinels += 1
286
- else:
287
- buffer += item
288
- break
289
- continue
290
-
291
- if item is None:
292
- n_sentinels += 1
293
- if n_sentinels >= len(active):
294
- break # Все читатели достигли EOF
295
- continue
296
-
297
- buffer += item
298
- text = buffer.decode('utf-8', errors='ignore')
299
- match = re.search(url_pattern, text)
300
- if match:
301
- drain_mode.set() # Переключаем потоки в режим дренирования
302
- return match.group(), text
303
-
304
- raise RuntimeError(
305
- f'URL не найден за {timeout} сек. Вывод процесса:\n'
306
- + buffer.decode('utf-8', errors='ignore')
307
- )
308
-
309
-
310
-
311
- def move_path(old_path: Path | str, new_path: Path | str) -> None:
312
- old, new = Path(old_path), Path(new_path)
313
- if not old.exists():
314
- raise RuntimeError(f'Не найден исходный путь для перемещения: {old}')
315
- try:
316
- old.replace(new)
317
- except Exception:
318
- try:
319
- shutil_move(str(old), str(new))
320
- except Exception:
321
- if os_name == 'posix':
322
- run(f'mv "{old}" "{new}"')
323
- else:
324
- run(f'move "{old}" "{new}"')
325
-
326
-
327
- # ---------------------------------------------------------------------------
328
- # Файлы и архивы
329
- # ---------------------------------------------------------------------------
330
-
331
- def _snapshot(folder: Path) -> set[Path]:
332
- """Рекурсивный снимок всех файлов в директории (включая вложенные папки)."""
333
- if not folder.exists():
334
- return set()
335
- return {f for f in folder.rglob('*') if f.is_file()}
336
-
337
-
338
- def determine_archive_format(filepath: str | Path) -> str | None:
339
- """Определяет формат архива по сигнатуре байт (magic bytes)."""
340
- filepath = Path(filepath)
341
- zip_sig = bytes([0x50, 0x4B, 0x03, 0x04])
342
- seven_z = bytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])
343
- lzma_xz = bytes([0xFD, 0x37, 0x7A, 0x58, 0x5A])
344
- tgz = bytes([0x1F, 0x8B])
345
- tbz = bytes([0x42, 0x5A, 0x68])
346
- ustar = bytes([0x75, 0x73, 0x74, 0x61, 0x72])
347
-
348
- with filepath.open('rb') as fh:
349
- header = fh.read(262)
350
-
351
- if header.startswith(zip_sig): return 'zip'
352
- if header.startswith(seven_z): return '7z'
353
- if header.startswith(lzma_xz): return '7z'
354
- if header.startswith(tgz): return 'tar.gz'
355
- if header.startswith(tbz): return 'tar.bz2'
356
- if header[0x101:0x101 + len(ustar)] == ustar: return 'tar'
357
- return None
358
-
359
-
360
- def unpack_archive(
361
- archive_path: str | Path,
362
- dest_path: str | Path,
363
- rm_archive: bool = True,
364
- ) -> None:
365
- """
366
- Распаковывает архив в указанную директорию.
367
-
368
- Поддерживает: tar, tar.gz, tar.bz2, tar.xz, gz (одиночный файл), zip, 7z.
369
- При неудаче основного метода делает fallback к 7z.
370
- Принимает URL — скачивает архив сам.
371
- """
372
- if str(archive_path).startswith(('https://', 'http://')):
373
- archive_path = download(str(archive_path), save_path=WORK_FOLDER, progress=False)
374
-
375
- archive_path = Path(archive_path)
376
- dest_path = Path(dest_path)
377
-
378
- if not archive_path.exists():
379
- raise RuntimeError(f'Архив не найден: {archive_path}')
380
-
381
- fmt = determine_archive_format(archive_path)
382
- dest_path.mkdir(parents=True, exist_ok=True)
383
-
384
- def _rm() -> None:
385
- if rm_archive:
386
- archive_path.unlink(missing_ok=True)
387
-
388
- def _try_7z() -> None:
389
- result = run(
390
- f'7z -bso0 -bd -mmt4 -slp -y x "{archive_path}" -o"{dest_path}"',
391
- timeout=180,
392
- )
393
- if result['status_code'] != 0:
394
- raise RuntimeError(
395
- f'Не удалось распаковать {archive_path.name}.\n{result["output"]}'
396
- )
397
- _rm()
398
-
399
- try:
400
- if fmt == 'zip' or archive_path.suffix == '.zip':
401
- r = run(f'unzip -o "{archive_path}" -d "{dest_path}"', timeout=180)
402
- if r['status_code'] != 0:
403
- raise RuntimeError(r['output'])
404
- _rm()
405
-
406
- elif fmt in ('7z',) or archive_path.suffix == '.7z':
407
- r = run(
408
- f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"',
409
- timeout=180,
410
- )
411
- if r['status_code'] != 0:
412
- raise RuntimeError(r['output'])
413
- _rm()
414
-
415
- elif fmt in ('tar', 'tar.gz', 'tar.bz2') or archive_path.suffix in (
416
- '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz', '.txz', '.gz',
417
- ):
418
- # GNU tar определяет формат сжатия автоматически через -xpf
419
- r = run(f'tar -xpf "{archive_path}" -C "{dest_path}"', timeout=180)
420
- if r['status_code'] != 0:
421
- # Возможно, это одиночный gzip-файл без tar-обёртки (напр. tmole-linux.gz)
422
- gz_r = run(f'gzip -df "{archive_path}"', timeout=60)
423
- if gz_r['status_code'] != 0:
424
- raise RuntimeError(
425
- f'tar: {r["output"]}\ngzip: {gz_r["output"]}'
426
- )
427
- # gzip -f уже удалил .gz и создал распакованный файл на месте
428
- return
429
- _rm()
430
-
431
- else:
432
- _try_7z()
433
-
434
- except RuntimeError as e:
435
- logger.debug(f'Основной метод распаковки не сработал ({e}), fallback к 7z.')
436
- _try_7z()
437
-
438
-
439
- # ---------------------------------------------------------------------------
440
- # Сеть
441
- # ---------------------------------------------------------------------------
442
-
443
- def is_ipv4(address: str) -> bool:
444
- pattern = re.compile(
445
- r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$'
446
- )
447
- return bool(pattern.match(address))
448
-
449
-
450
- def is_valid_url(url: str, custom_headers: dict | None = None) -> bool:
451
- """
452
- Проверяет доступность URL (HEAD или GET запрос).
453
-
454
- Returns:
455
- True, если сервер вернул HTTP 2xx.
456
- """
457
- req_headers = HEADERS.copy()
458
- if custom_headers:
459
- req_headers.update(custom_headers)
460
-
461
- for _ in range(2):
462
- try:
463
- with get_url(url, headers=req_headers, allow_redirects=True, stream=True, timeout=10) as resp:
464
- if 200 <= resp.status_code < 300:
465
- return True
466
- except Exception:
467
- try:
468
- resp = get_head(url, headers=req_headers, allow_redirects=True, timeout=10)
469
- if 200 <= resp.status_code < 300:
470
- return True
471
- except Exception:
472
- pass
473
- else:
474
- break
475
- return False
476
-
477
-
478
- def get_filename_from_headers(headers: CaseInsensitiveDict) -> str | None:
479
- cd = headers.get('content-disposition')
480
- if not cd:
481
- return headers.get('filename')
482
- for part in cd.split(';'):
483
- part = part.strip()
484
- if part.startswith('filename*='):
485
- encoding, _, encoded = part[len('filename*='):].partition("''")
486
- return unquote(encoded, encoding=encoding)
487
- if part.startswith('filename='):
488
- return part[len('filename='):].strip('"')
489
- return None
490
-
491
-
492
- def download(
493
- url: str,
494
- filename: str | Path | None = None,
495
- save_path: str | Path | None = None,
496
- progress: bool = True,
497
- extra_headers: dict[str, str] | None = None,
498
- ) -> Path:
499
- """
500
- Скачивает файл по URL.
501
-
502
- Args:
503
- url: URL файла.
504
- filename: Имя для сохранения (None — из заголовков или URL).
505
- save_path: Папка сохранения (None — текущая директория).
506
- progress: Показывать прогресс загрузки.
507
- extra_headers: Дополнительные HTTP-заголовки.
508
-
509
- Returns:
510
- Path к скачанному файлу.
511
-
512
- Raises:
513
- RuntimeError: При ошибке сети или записи файла.
514
- """
515
- save_path = Path(save_path) if save_path else Path.cwd()
516
- save_path.mkdir(parents=True, exist_ok=True)
517
-
518
- req_headers = HEADERS.copy()
519
- if extra_headers:
520
- req_headers.update(extra_headers)
521
-
522
- try:
523
- resp = get_url(url, stream=True, allow_redirects=True, headers=req_headers, timeout=60)
524
- resp.raise_for_status()
525
- except Exception as e:
526
- raise RuntimeError(f'Не удалось скачать файл по ссылке {url}:\n{e}')
527
-
528
- file_size = int(resp.headers.get('content-length', 0))
529
- raw_name = (
530
- str(filename)
531
- if filename
532
- else (get_filename_from_headers(resp.headers) or Path(urlparse(resp.url).path).name)
533
- )
534
- file_name = Path(raw_name).name
535
- file_path = save_path / file_name
536
- chunk_size = max(4096, file_size // 2000) if file_size else 4096
537
-
538
- downloaded = 0
539
- start = time.time()
540
- try:
541
- with open(file_path, 'wb') as fp:
542
- for chunk in resp.iter_content(chunk_size=chunk_size):
543
- if chunk:
544
- fp.write(chunk)
545
- if progress and file_size:
546
- downloaded += len(chunk)
547
- elapsed = time.time() - start
548
- print(
549
- f'\rзагрузка {file_name}: '
550
- f'{downloaded / file_size * 100:.2f}% | {elapsed:.2f} сек.',
551
- end='',
552
- )
553
- except Exception as e:
554
- raise RuntimeError(f'Не удалось сохранить файл из {url}:\n{e}')
555
-
556
- if progress and file_size:
557
- print()
558
-
559
- logger.debug(f'Скачан: {file_path} ({file_size} байт)')
560
- return file_path
561
-
562
-
563
- def get_github_latest_release_url(repo: str, asset_pattern: str) -> str:
564
- """
565
- Возвращает URL загрузки актуального релизного ассета с GitHub.
566
-
567
- Args:
568
- repo: Репозиторий в формате 'owner/repo'.
569
- asset_pattern: Регулярное выражение для имени ассета.
570
-
571
- Returns:
572
- browser_download_url для найденного ассета.
573
-
574
- Raises:
575
- RuntimeError: Если ассет не найден или GitHub API недоступен.
576
- """
577
- api_url = f'https://api.github.com/repos/{repo}/releases/latest'
578
- resp = get_url(api_url, timeout=10)
579
- resp.raise_for_status()
580
-
581
- assets = resp.json().get('assets', [])
582
- for asset in assets:
583
- if re.search(asset_pattern, asset['name']):
584
- return asset['browser_download_url']
585
-
586
- available = [a['name'] for a in assets]
587
- raise RuntimeError(
588
- f'Ассет "{asset_pattern}" не найден в {repo}. '
589
- f'Доступные: {available}'
590
- )