rottenstuff commited on
Commit
c0e2219
·
verified ·
1 Parent(s): 5cec15c

Upload 13 files

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