rottenstuff commited on
Commit
adf497c
·
verified ·
1 Parent(s): d81e309

Upload 12 files

Browse files
colab_tunnel/__init__.py CHANGED
@@ -1,15 +1,38 @@
1
- # ./colab_tunnel/__init__.py
2
  """
3
- colab-proxy — утилиты для туннелирования веб-интерфейсов в Google Colab.
4
 
5
  Быстрый старт:
6
  from colab_tunnel import get_share_link
7
  link = get_share_link('cloudflared', 7860)
8
  print(link)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
 
10
 
11
  from ._config import WORK_FOLDER
 
 
12
  from ._utils import run, download
 
 
 
 
 
 
13
  from ._tunnels import (
14
  get_tmole_url,
15
  get_tunwg_url,
@@ -20,13 +43,29 @@ from ._tunnels import (
20
  get_mmar_url,
21
  get_tunnelite_url,
22
  get_beeceptor_url,
 
 
 
 
 
 
 
 
23
  )
 
 
 
24
 
25
- __version__ = '1.0.0'
26
  __all__ = [
 
27
  'get_share_link',
28
  'try_all',
 
 
 
29
  'proxies_functions',
 
 
30
  'get_tmole_url',
31
  'get_tunwg_url',
32
  'get_cloudflared_url',
@@ -36,51 +75,69 @@ __all__ = [
36
  'get_mmar_url',
37
  'get_tunnelite_url',
38
  'get_beeceptor_url',
 
 
 
 
 
 
 
39
  'download',
40
  'run',
41
  'WORK_FOLDER',
 
42
  ]
43
 
44
- proxies_functions: dict = {
45
- 'tmole': get_tmole_url,
46
- 'tunwg': get_tunwg_url,
47
- 'cloudflared': get_cloudflared_url,
48
- 'localt': get_localt_url,
49
- 'gradio': get_gradio_url,
50
- 'native': get_native_url,
51
- 'mmar': get_mmar_url,
52
- 'tunnelite': get_tunnelite_url,
53
- 'beeceptor': get_beeceptor_url,
54
- }
55
-
56
 
57
  def try_all(port: int) -> str:
58
- """Запускает все доступные туннели и возвращает все полученные ссылки."""
 
 
 
 
 
 
 
 
 
 
 
 
59
  results: list[str] = []
60
- for name, func in proxies_functions.items():
61
- try:
62
- results.append(func(port))
63
- except Exception as e:
64
- print(f'{name}: {e}')
 
 
 
 
 
 
 
 
 
65
  return '\n'.join(results)
66
 
67
 
68
  def get_share_link(host: str, port: int) -> str:
69
  """
70
- Получить публичную ссылку на локальный порт через указанный туннель.
71
 
72
  Args:
73
- host: Имя провайдера: 'cloudflared', 'tmole', 'tunwg',
74
- 'localt', 'gradio', 'native', 'mmar', 'tunnelite',
75
- 'beeceptor' — или 'all' для всех сразу.
76
- port: Локальный порт веб-интерфейса.
77
 
78
  Returns:
79
- Публичная URL-ссылка (или несколько, если host='all').
80
  """
81
  if host in proxies_functions:
82
  return proxies_functions[host](port)
 
83
  if host == 'all':
84
  return try_all(port)
 
85
  available = ', '.join(proxies_functions.keys())
86
- return f'провайдер "{host}" не найден. Доступные: {available}'
 
 
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
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,
 
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
  'proxies_functions',
67
+ 'tunnel_provider',
68
+ # Провайдеры (прямой доступ)
69
  'get_tmole_url',
70
  'get_tunwg_url',
71
  'get_cloudflared_url',
 
75
  'get_mmar_url',
76
  'get_tunnelite_url',
77
  'get_beeceptor_url',
78
+ 'get_bore_url',
79
+ 'get_boredigital_url',
80
+ 'get_optimistix_url',
81
+ 'get_srvus_url',
82
+ 'get_serveo_url',
83
+ 'get_localhostrun_url',
84
+ # Утилиты
85
  'download',
86
  'run',
87
  'WORK_FOLDER',
88
+ 'logger',
89
  ]
90
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  def try_all(port: int) -> str:
93
+ """
94
+ Параллельно запускает все зарегистрированные провайдеры туннелей.
95
+
96
+ В отличие от benchmark(), не измеряет метрики точно, зато возвращает
97
+ все работающие ссылки в разы быстрее — за время самого медленного провайдера
98
+ вместо суммы времён всех провайдеров.
99
+
100
+ Args:
101
+ port: Локальный порт для туннелирования.
102
+
103
+ Returns:
104
+ Строка со всеми полученными ссылками в формате 'провайдер: URL'.
105
+ """
106
  results: list[str] = []
107
+
108
+ with ThreadPoolExecutor(max_workers=len(proxies_functions)) as executor:
109
+ futures = {
110
+ executor.submit(func, port): name
111
+ for name, func in proxies_functions.items()
112
+ }
113
+ for future in as_completed(futures):
114
+ name = futures[future]
115
+ try:
116
+ url = future.result()
117
+ results.append(f'{name}: {url}')
118
+ except Exception as e:
119
+ logger.warning(f'[try_all] {name}: {e}')
120
+
121
  return '\n'.join(results)
122
 
123
 
124
  def get_share_link(host: str, port: int) -> str:
125
  """
126
+ Получает публичную ссылку на локальный порт через указанный туннель.
127
 
128
  Args:
129
+ host: Имя провайдера или 'all' для параллельного запуска всех.
130
+ Доступные провайдеры: list(proxies_functions.keys()).
131
+ port: Локальный порт веб-интерфейса.
 
132
 
133
  Returns:
134
+ Публичная URL-ссылка (или несколько строк при host='all').
135
  """
136
  if host in proxies_functions:
137
  return proxies_functions[host](port)
138
+
139
  if host == 'all':
140
  return try_all(port)
141
+
142
  available = ', '.join(proxies_functions.keys())
143
+ return f'Провайдер "{host}" не найден. Доступные: {available}'
colab_tunnel/_config.py CHANGED
@@ -1,8 +1,7 @@
1
- # ./colab_tunnel/_config.py
2
  from os import environ
3
  from pathlib import Path
4
 
5
- # Переопределяется переменной окружения colab_tunnel_WORK_DIR
6
  WORK_FOLDER = Path(environ.get('TUNNEL_WORK_DIR', '/content/.config'))
7
 
8
  cloudflare_bin = WORK_FOLDER / 'cloudflared'
@@ -13,6 +12,8 @@ gradio_bin = WORK_FOLDER / 'frpc_linux_amd64'
13
  mmar_bin = WORK_FOLDER / 'mmar'
14
  tunnelite_bin = WORK_FOLDER / 'tunnelite'
15
  beeceptor_bin = WORK_FOLDER / 'beeceptor-cli'
 
 
16
  colab_native_url = WORK_FOLDER / 'colab_url.txt'
17
  links_file = WORK_FOLDER / 'links.txt'
18
 
 
 
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'
 
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
 
colab_tunnel/_diagnostics.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(url: str, timeout: float = 15.0) -> Optional[float]:
85
+ """
86
+ Делает GET-запрос к публичному URL и возвращает RTT.
87
+
88
+ Returns:
89
+ Время ответа в секундах, или None если URL недоступен.
90
+ """
91
+ # Убираем строку с IPv4-паролем (если провайдер вернул её как вторую строку)
92
+ clean_url = url.split('\n')[0].strip()
93
+ try:
94
+ t = time.time()
95
+ resp = get_url(clean_url, timeout=timeout, allow_redirects=True)
96
+ elapsed = round(time.time() - t, 3)
97
+ if 200 <= resp.status_code < 400:
98
+ return elapsed
99
+ except Exception as e:
100
+ logger.debug(f'URL недоступен ({clean_url}): {e}')
101
+ return None
102
+
103
+
104
+ def _print_report(results: list[TunnelBenchmarkResult]) -> None:
105
+ """Выводит форматированную таблицу результатов."""
106
+ W = {'name': 14, 'status': 10, 'tunnel': 14, 'latency': 12}
107
+ total_w = sum(W.values()) + 38 # 38 — ширина колонки URL
108
+ sep = '=' * total_w
109
+
110
+ print(f'\n{sep}')
111
+ print(f'{"БЕНЧМАРК ПРОВАЙДЕРОВ ТУННЕЛЕЙ":^{total_w}}')
112
+ print(sep)
113
+ print(
114
+ f'{"Провайдер":<{W["name"]}}'
115
+ f'{"Статус":<{W["status"]}}'
116
+ f'{"Туннель":<{W["tunnel"]}}'
117
+ f'{"Задержка":<{W["latency"]}}'
118
+ f'URL / Ошибка'
119
+ )
120
+ print('-' * total_w)
121
+
122
+ for r in results:
123
+ status = '✓ OK ' if r.success else '✗ FAIL'
124
+ t_str = f'{r.tunnel_time:.2f}с'
125
+ lat_str = f'{r.latency * 1000:.0f}мс' if r.latency else '—'
126
+ detail = (r.url or r.error or '').split('\n')[0]
127
+ if len(detail) > 38:
128
+ detail = detail[:35] + '…'
129
+ print(
130
+ f'{r.name:<{W["name"]}}'
131
+ f'{status:<{W["status"]}}'
132
+ f'{t_str:<{W["tunnel"]}}'
133
+ f'{lat_str:<{W["latency"]}}'
134
+ f'{detail}'
135
+ )
136
+
137
+ print(sep)
138
+ ok = [r for r in results if r.success]
139
+ print(f'Итого: {len(ok)}/{len(results)} провайдеров работают.')
140
+
141
+ if ok:
142
+ fastest = min(ok, key=lambda r: r.tunnel_time)
143
+ print(f'Быстрейший запуск : {fastest.name} ({fastest.tunnel_time:.2f}с)')
144
+ with_lat = [r for r in ok if r.latency]
145
+ if with_lat:
146
+ lowest = min(with_lat, key=lambda r: r.latency) # type: ignore[arg-type]
147
+ print(f'Минимальная задержка: {lowest.name} ({lowest.latency * 1000:.0f}мс)') # type: ignore[operator]
148
+ print()
149
+
150
+
151
+ def benchmark(
152
+ providers: list[str] | None = None,
153
+ timeout_per_provider: float = 30.0,
154
+ latency_timeout: float = 15.0,
155
+ print_report: bool = True,
156
+ ) -> list[TunnelBenchmarkResult]:
157
+ """
158
+ Последовательно тестирует провайдеров туннелей и собирает метрики.
159
+
160
+ Схема работы:
161
+ 1. Находит свободный порт, запускает тестовый HTTP-сервер (бинарник
162
+ из папки test_server/).
163
+ 2. Поочерёдно вызывает каждого провайдера, замеряя время запуска.
164
+ 3. Проверяет доступность полученной ссылки, измеряет RTT.
165
+ 4. Гарантированно останавливает тестовый сервер (блок finally).
166
+ 5. Выводит форматированный отчёт.
167
+
168
+ Последовательный (не параллельный) порядок важен для точности замеров:
169
+ параллельный запуск исказил бы время за счёт конкуренции за сеть и CPU.
170
+
171
+ Args:
172
+ providers: Имена провайдеров для теста. None — все доступные.
173
+ timeout_per_provider: Таймаут ожидания ссылки от провайдера (сек).
174
+ latency_timeout: Таймаут проверки доступности URL (сек).
175
+ print_report: Вывести форматированную таблицу в конце.
176
+
177
+ Returns:
178
+ Список TunnelBenchmarkResult для каждого протестированного провайдера.
179
+
180
+ Raises:
181
+ FileNotFoundError: Бинарник тестового сервера не найден.
182
+ ValueError: Указан несуществующий провайдер.
183
+ """
184
+ server_bin = _find_test_server_bin()
185
+ port = find_free_port()
186
+
187
+ names = providers or list(proxies_functions.keys())
188
+ unknown = [n for n in names if n not in proxies_functions]
189
+ if unknown:
190
+ raise ValueError(
191
+ f'Неизвестные провайдеры: {unknown}. '
192
+ f'Доступные: {list(proxies_functions.keys())}'
193
+ )
194
+
195
+ print(f'Бенчмарк: {len(names)} провайдеров, порт {port}.')
196
+ _start_test_server(server_bin, port)
197
+ print('Тестовый сервер запущен.\n')
198
+
199
+ results: list[TunnelBenchmarkResult] = []
200
+
201
+ try:
202
+ for name in names:
203
+ func = proxies_functions[name]
204
+ print(f'[{name:<12}] запуск...', end=' ', flush=True)
205
+ result = TunnelBenchmarkResult(name=name)
206
+ t_start = time.time()
207
+
208
+ try:
209
+ url = func(port)
210
+ result.tunnel_time = round(time.time() - t_start, 2)
211
+ result.url = url
212
+ result.success = True
213
+ print(f'OK ({result.tunnel_time:.2f}с). Проверка...', end=' ', flush=True)
214
+
215
+ result.latency = _check_url_latency(url, timeout=latency_timeout)
216
+ if result.latency:
217
+ print(f'RTT {result.latency * 1000:.0f}мс')
218
+ else:
219
+ print('URL недоступен (туннель ещё поднимается?)')
220
+
221
+ except Exception as e:
222
+ result.tunnel_time = round(time.time() - t_start, 2)
223
+ result.error = str(e)
224
+ print(f'ОШИБКА: {str(e)[:100]}')
225
+ logger.debug(f'[{name}] детали:', exc_info=True)
226
+
227
+ results.append(result)
228
+
229
+ finally:
230
+ print()
231
+ _stop_test_server(server_bin)
232
+ print('Тестовый сервер остановлен.')
233
+
234
+ if print_report:
235
+ _print_report(results)
236
+
237
+ return results
colab_tunnel/_logger.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Реестр провайдеров туннелей.
3
+
4
+ Новый провайдер регистрируется декоратором:
5
+
6
+ from colab_tunnel._registry import tunnel_provider
7
+
8
+ @tunnel_provider('myprovider')
9
+ def get_myprovider_url(port: int) -> str:
10
+ ...
11
+
12
+ После этого провайдер автоматически доступен через get_share_link() и try_all().
13
+ """
14
+ from typing import Callable
15
+
16
+ # Заполняется при импорте модулей _tunnels и _ssh через декоратор @tunnel_provider
17
+ proxies_functions: dict[str, Callable[[int], str]] = {}
18
+
19
+
20
+ def tunnel_provider(name: str) -> Callable:
21
+ """Декоратор регистрации провайдера туннеля по имени."""
22
+ def decorator(func: Callable[[int], str]) -> Callable[[int], str]:
23
+ proxies_functions[name] = func
24
+ return func
25
+ return decorator
colab_tunnel/_ssh.py CHANGED
@@ -1,175 +1,174 @@
 
 
 
 
1
  from atexit import register as exit_register
2
- import os
3
  from pathlib import Path
4
- from re import search
5
- from select import select
6
- from subprocess import DEVNULL, PIPE, Popen
7
- import time
8
 
9
- from colab_tunnel._utils import terminate_process, run
 
 
10
 
11
- # Глобальный реестр для предотвращения Garbage Collection (сборки мусора) процессов и их труб
12
- _active_tunnels: list[Popen] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  def ensure_ssh_key() -> None:
16
- """
17
- Гарантирует наличие приватного SSH-ключа в системе.
18
- """
19
  ssh_dir = Path.home() / '.ssh'
20
  ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
21
  key_file = ssh_dir / 'id_ed25519'
22
 
23
  if not key_file.exists():
24
- import subprocess
25
  subprocess.run(
26
  ['ssh-keygen', '-t', 'ed25519', '-N', '', '-f', str(key_file)],
27
  stdout=DEVNULL,
28
- stderr=DEVNULL
29
  )
 
30
 
31
 
32
  def get_ssh_tunnel_url(
33
- start_commands: list[str],
34
- process_name: str,
35
- url_pattern: str,
36
- timeout: float = 15.0,
37
  ) -> str:
38
  """
39
- Запускает SSH-процесс с открытым stdin=PIPE для поддержания соединения.
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  """
41
  ensure_ssh_key()
 
42
 
43
- # Завершаем старые процессы
44
- run(f'pkill -f "{process_name}"')
45
-
46
- # Важно: stdin=PIPE оставляем ОТКРЫТЫМ, чтобы SSH не получал EOF и не выходил
47
  process = Popen(start_commands, stdout=PIPE, stderr=PIPE, stdin=PIPE)
48
 
49
- stdout_raw = process.stdout.raw
50
- stderr_raw = process.stderr.raw
51
-
52
- # Делаем сырые дескрипторы неблокирующими
53
- for raw_stream in (stdout_raw, stderr_raw):
 
 
 
 
54
  try:
55
- os.set_blocking(raw_stream.fileno(), False)
56
- except Exception:
57
- pass
 
 
 
58
 
59
- start_time = time.time()
60
- buffer = b""
61
-
62
- while time.time() - start_time < timeout:
63
- ready_readers, _, _ = select([stdout_raw, stderr_raw], [], [], 0.1)
64
-
65
- read_any = False
66
- for reader in ready_readers:
67
- try:
68
- chunk = reader.read(1024)
69
- if chunk:
70
- buffer += chunk
71
- read_any = True
72
- except (BlockingIOError, ValueError):
73
- pass
74
- except Exception:
75
- pass
76
-
77
- decoded_text = buffer.decode('utf-8', errors='ignore')
78
- match = search(url_pattern, decoded_text)
79
- if match:
80
- # Регистрируем завершение при выходе из Python
81
- exit_register(terminate_process, process_name, process)
82
- # Добавляем в глобальный список, чтобы сохранить процесс и stdin-трубу открытыми
83
- _active_tunnels.append(process)
84
- return match.group()
85
-
86
- if not read_any and process.poll() is not None:
87
- break
88
-
89
- time.sleep(0.05)
90
-
91
- process.terminate()
92
- run(f'pkill -f "{process_name}"')
93
-
94
- final_output = buffer.decode('utf-8', errors='ignore')
95
- raise RuntimeError(
96
- f'Не удалось получить SSH-ссылку за {timeout} сек. Вывод процесса:\n{final_output}'
97
- )
98
 
99
 
100
  # ---------------------------------------------------------------------------
101
  # Провайдеры
102
  # ---------------------------------------------------------------------------
103
 
 
104
  def get_optimistix_url(port: int) -> str:
105
- cmd = [
106
- 'ssh',
107
- '-o', 'StrictHostKeyChecking=no',
108
- '-o', 'UserKnownHostsFile=/dev/null',
109
- '-o', 'ExitOnForwardFailure=yes',
110
- '-o', 'ServerAliveInterval=10',
111
- '-o', 'ServerAliveCountMax=3',
112
- '-p', '1122',
113
- '-R', f'80:127.0.0.1:{port}',
114
- 'ssh.optimistixtunnel.com'
115
- ]
116
  return get_ssh_tunnel_url(
117
- start_commands=cmd,
 
 
 
 
 
 
 
 
 
 
118
  process_name='ssh.optimistixtunnel.com',
119
  url_pattern=r'https://\S+\.otnl\.link',
120
  )
121
 
122
 
 
123
  def get_srvus_url(port: int) -> str:
124
- cmd = [
125
- 'ssh',
126
- '-o', 'StrictHostKeyChecking=no',
127
- '-o', 'UserKnownHostsFile=/dev/null',
128
- '-o', 'ExitOnForwardFailure=yes',
129
- '-o', 'ServerAliveInterval=10',
130
- '-o', 'ServerAliveCountMax=3',
131
- '-R', f'1:127.0.0.1:{port}',
132
- 'srv.us'
133
- ]
134
  return get_ssh_tunnel_url(
135
- start_commands=cmd,
 
 
 
 
 
 
 
 
 
136
  process_name='srv.us',
137
  url_pattern=r'https://[a-z0-9]{10,}\.srv\.us/?',
138
  )
139
 
140
 
 
141
  def get_serveo_url(port: int) -> str:
142
- cmd = [
143
- 'ssh',
144
- '-o', 'StrictHostKeyChecking=no',
145
- '-o', 'UserKnownHostsFile=/dev/null',
146
- '-o', 'ExitOnForwardFailure=yes',
147
- '-o', 'ServerAliveInterval=10',
148
- '-o', 'ServerAliveCountMax=3',
149
- '-R', f'80:127.0.0.1:{port}',
150
- 'serveo.net'
151
- ]
152
  return get_ssh_tunnel_url(
153
- start_commands=cmd,
 
 
 
 
 
 
 
 
 
154
  process_name='serveo.net',
155
  url_pattern=r'https://\S+\.serveousercontent\.com',
156
  )
157
 
158
 
 
159
  def get_localhostrun_url(port: int) -> str:
160
- cmd = [
161
- 'ssh',
162
- '-o', 'StrictHostKeyChecking=no',
163
- '-o', 'UserKnownHostsFile=/dev/null',
164
- '-o', 'ExitOnForwardFailure=yes',
165
- '-o', 'ServerAliveInterval=10',
166
- '-o', 'ServerAliveCountMax=3',
167
- '-R', f'80:127.0.0.1:{port}',
168
- 'nokey@localhost.run'
169
- ]
170
  return get_ssh_tunnel_url(
171
- start_commands=cmd,
 
 
 
 
 
 
 
 
 
172
  process_name='localhost.run',
173
  url_pattern=r'https://(?!admin\b)[a-zA-Z0-9-]+\.(?:localhost\.run|lhr\.life)',
174
  )
175
-
 
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 CHANGED
@@ -1,7 +1,8 @@
1
- # ./colab_tunnel/_tunnels.py
 
 
2
  from atexit import register as exit_register
3
  from pathlib import Path
4
- from re import search
5
  from subprocess import PIPE, Popen
6
  from time import sleep
7
 
@@ -11,12 +12,17 @@ from ._config import (
11
  WORK_FOLDER,
12
  cloudflare_bin, tmole_bin, tunwg_bin,
13
  go_localt_bin, gradio_bin, mmar_bin,
14
- tunnelite_bin, beeceptor_bin, links_file
 
15
  )
 
 
16
  from ._utils import (
17
  download, unpack_archive, run,
18
- is_process_running, terminate_process,
19
- move_path, is_ipv4,
 
 
20
  )
21
 
22
 
@@ -26,77 +32,119 @@ def get_revproxy_url(
26
  bin_path: Path,
27
  start_commands: list,
28
  read_from_stderr: bool,
29
- lines_to_read: int,
30
  url_pattern: str = r'https://\S+',
 
31
  write_link: bool = False,
32
  stdin_input: str | None = None,
33
  ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  if not bin_path.exists():
35
- files_before = {f for f in WORK_FOLDER.iterdir() if f.is_file()}
 
36
 
37
  if need_unpack:
38
- unpack_archive(download(bin_url, progress=False), WORK_FOLDER, rm_archive=True)
 
39
  else:
40
  download(bin_url, save_path=bin_path.parent, progress=False)
41
 
42
- files_after = {f for f in WORK_FOLDER.iterdir() if f.is_file()}
43
- new_files = list(files_after - files_before)
 
 
 
 
44
 
45
  if len(new_files) == 1:
46
  move_path(new_files[0], bin_path)
47
- elif len(new_files) > 1:
48
- largest_file = max(new_files, key=lambda f: f.stat().st_size)
49
- move_path(largest_file, bin_path)
 
50
  for f in new_files:
51
- if f != largest_file:
52
  f.unlink(missing_ok=True)
53
- else:
54
- raise RuntimeError(
55
- f'ошибка при определении нового файла после распаковки!\n'
56
- f'ожидалось появление новых файлов в директории {WORK_FOLDER}'
57
- )
58
 
59
- bin_path.chmod(0o777)
 
60
 
61
- if is_process_running(bin_path.name):
62
- run(f'pkill -f {bin_path.name}')
63
 
64
- # Если требуется отправка ввода, открываем канал stdin
65
- stdin_channel = PIPE if stdin_input is not None else None
66
- process = Popen(start_commands, stdout=PIPE, stderr=PIPE, stdin=stdin_channel, text=True)
67
 
68
  if stdin_input is not None and process.stdin:
69
- process.stdin.write(stdin_input)
70
- process.stdin.flush()
71
-
72
- stream = process.stderr if read_from_stderr else process.stdout
73
- lines: list[str] = []
74
-
75
- for _ in range(lines_to_read):
76
- line = stream.readline()
77
- if not line:
78
- break
79
- lines.append(line.strip())
80
-
81
- ipv4 = next((ln for ln in lines if is_ipv4(ln)), None)
82
- urls = [search(url_pattern, ln).group() for ln in lines if search(url_pattern, ln)]
83
-
84
- if urls:
85
- exit_register(terminate_process, bin_path.name, process)
86
- if write_link:
87
- links_file.write_text(urls[0])
88
- return f'{urls[0]}\n пароль(IP): {ipv4}' if ipv4 else urls[0]
89
-
90
- run(f'pkill -f {bin_path.name}')
91
- raise RuntimeError(
92
- f'ссылку получить не удалось, вывод работы бинарника:\n' + '\n'.join(lines)
 
 
 
 
 
 
93
  )
94
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  # ---------------------------------------------------------------------------
97
- # Конкретные провайдеры
98
  # ---------------------------------------------------------------------------
99
 
 
100
  def get_tmole_url(port: int) -> str:
101
  return get_revproxy_url(
102
  bin_url='https://tunnelmole.com/downloads/tmole-linux.gz',
@@ -104,10 +152,11 @@ def get_tmole_url(port: int) -> str:
104
  bin_path=tmole_bin,
105
  start_commands=[str(tmole_bin), str(port)],
106
  read_from_stderr=False,
107
- lines_to_read=5,
108
  )
109
 
110
 
 
111
  def get_tunwg_url(port: int) -> str:
112
  return get_revproxy_url(
113
  bin_url='https://github.com/ntnj/tunwg/releases/latest/download/tunwg',
@@ -115,10 +164,11 @@ def get_tunwg_url(port: int) -> str:
115
  bin_path=tunwg_bin,
116
  start_commands=[str(tunwg_bin), f'--forward=http://127.0.0.1:{port}'],
117
  read_from_stderr=True,
118
- lines_to_read=2,
119
  )
120
 
121
 
 
122
  def get_cloudflared_url(port: int) -> str:
123
  return get_revproxy_url(
124
  bin_url='https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
@@ -126,11 +176,12 @@ def get_cloudflared_url(port: int) -> str:
126
  bin_path=cloudflare_bin,
127
  start_commands=[str(cloudflare_bin), 'tunnel', '--url', f'http://127.0.0.1:{port}'],
128
  read_from_stderr=True,
129
- lines_to_read=6,
130
  url_pattern=r'(?P<url>https?://\S+\.trycloudflare\.com)',
 
131
  )
132
 
133
 
 
134
  def get_localt_url(port: int) -> str:
135
  return get_revproxy_url(
136
  bin_url='https://huggingface.co/prolapse/go_localt/resolve/main/go_localt',
@@ -138,10 +189,11 @@ def get_localt_url(port: int) -> str:
138
  bin_path=go_localt_bin,
139
  start_commands=[str(go_localt_bin), str(port)],
140
  read_from_stderr=False,
141
- lines_to_read=2,
142
  )
143
 
144
 
 
145
  def get_gradio_url(port: int) -> str:
146
  max_attempts = 3
147
  last_error: Exception | None = None
@@ -165,17 +217,53 @@ def get_gradio_url(port: int) -> str:
165
  bin_path=gradio_bin,
166
  start_commands=cmd,
167
  read_from_stderr=False,
168
- lines_to_read=3,
169
  )
170
  except Exception as e:
171
  last_error = e
172
  if attempt < max_attempts - 1:
173
- print(f'попытка {attempt + 1} получить ссылку градио провалилась: {e}. пробуем ещё...')
174
  sleep(5)
175
 
176
- raise RuntimeError(f'после {max_attempts} попыток поднять туннель градио не удалось: {last_error}')
 
 
 
 
 
 
 
 
 
 
 
177
 
 
 
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  def get_mmar_url(port: int) -> str:
180
  return get_revproxy_url(
181
  bin_url='https://github.com/yusuf-musleh/mmar/releases/latest/download/mmar_Linux_x86_64.tar.gz',
@@ -183,11 +271,12 @@ def get_mmar_url(port: int) -> str:
183
  bin_path=mmar_bin,
184
  start_commands=[str(mmar_bin), 'client', '--local-port', str(port)],
185
  read_from_stderr=True,
186
- lines_to_read=10,
187
  url_pattern=r'(?P<url>https?://\S+\.mmar\.dev)',
 
188
  )
189
 
190
 
 
191
  def get_tunnelite_url(port: int) -> str:
192
  return get_revproxy_url(
193
  bin_url='https://github.com/hehepiska/tunnellite-linux/releases/download/latest/tunnelite',
@@ -195,11 +284,12 @@ def get_tunnelite_url(port: int) -> str:
195
  bin_path=tunnelite_bin,
196
  start_commands=[str(tunnelite_bin), f'http://127.0.0.1:{port}'],
197
  read_from_stderr=False,
198
- lines_to_read=60,
199
  url_pattern=r'(?P<url>https?://\S+\.tunnelite\.com)',
 
200
  )
201
 
202
 
 
203
  def get_beeceptor_url(port: int) -> str:
204
  return get_revproxy_url(
205
  bin_url='https://cdn.beeceptor.com/downloads/cli/latest/beeceptor-cli-linux-x64.tar.gz',
@@ -207,15 +297,46 @@ def get_beeceptor_url(port: int) -> str:
207
  bin_path=beeceptor_bin,
208
  start_commands=[str(beeceptor_bin), '-p', str(port), '-y'],
209
  read_from_stderr=False,
210
- lines_to_read=15,
211
  url_pattern=r'(?P<url>https?://\S+\.beeceptor\.com)',
212
- stdin_input='\n', # Симулируем нажатие клавиши Enter для выбора первого пункта
 
213
  )
214
 
215
 
216
- def get_native_url(port: int) -> str:
217
- try:
218
- from google.colab.output import eval_js # type: ignore[import]
219
- except ImportError:
220
- raise RuntimeError('get_native_url доступен только в среде Google Colab.')
221
- return eval_js(f'google.colab.kernel.proxyPort({port})')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
 
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
 
 
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
  ) -> str:
40
+ """
41
+ Универсальный запуск бинарного туннельного провайдера.
42
+
43
+ При первом вызове скачивает и устанавливает бинарник.
44
+ Использует неблокирующее чтение вывода для поиска URL.
45
+
46
+ Args:
47
+ bin_url: URL скачивания бинарника (или архива).
48
+ need_unpack: Нужно ли распаковывать архив.
49
+ bin_path: Целевой путь бинарника на диске.
50
+ start_commands: Команда запуска со всеми аргументами.
51
+ read_from_stderr: Искать URL в stderr (иначе в stdout).
52
+ url_pattern: Regex для поиска публичного URL.
53
+ timeout: Максимальное время ожидания URL (сек).
54
+ write_link: Сохранять ли ссылку в links.txt.
55
+ stdin_input: Строка для отправки в stdin процесса.
56
+
57
+ Returns:
58
+ Публичный URL туннеля.
59
+ """
60
+ # ── Установка бинарника ──────────────────────────────────────────────
61
  if not bin_path.exists():
62
+ WORK_FOLDER.mkdir(parents=True, exist_ok=True)
63
+ snapshot_before = _snapshot(WORK_FOLDER)
64
 
65
  if need_unpack:
66
+ archive = download(bin_url, save_path=WORK_FOLDER, progress=False)
67
+ unpack_archive(archive, WORK_FOLDER, rm_archive=True)
68
  else:
69
  download(bin_url, save_path=bin_path.parent, progress=False)
70
 
71
+ new_files = list(_snapshot(WORK_FOLDER) - snapshot_before)
72
+
73
+ if not new_files:
74
+ raise RuntimeError(
75
+ f'В {WORK_FOLDER} не появилось новых файлов после загрузки {bin_url}'
76
+ )
77
 
78
  if len(new_files) == 1:
79
  move_path(new_files[0], bin_path)
80
+ else:
81
+ # Несколько файлов (например, архив с доп. файлами) — берём наибольший
82
+ largest = max(new_files, key=lambda f: f.stat().st_size)
83
+ move_path(largest, bin_path)
84
  for f in new_files:
85
+ if f != largest and f.exists():
86
  f.unlink(missing_ok=True)
 
 
 
 
 
87
 
88
+ bin_path.chmod(0o755)
89
+ logger.debug(f'Установлен бинарник: {bin_path}')
90
 
91
+ # ── Завершение предыдущего экземпляра ───────────────────────────────
92
+ kill_process_by_name(bin_path.name)
93
 
94
+ # ── Запуск процесса ──────────────────────────────────────────────────
95
+ stdin_flag = PIPE if stdin_input is not None else None
96
+ process = Popen(start_commands, stdout=PIPE, stderr=PIPE, stdin=stdin_flag)
97
 
98
  if stdin_input is not None and process.stdin:
99
+ try:
100
+ process.stdin.write(stdin_input.encode())
101
+ process.stdin.flush()
102
+ finally:
103
+ process.stdin.close() # Сигнал EOF — многие утилиты ждут его перед стартом
104
+
105
+ # ── Чтение URL из вывода ─────────────────────────────────────────────
106
+ try:
107
+ url, full_output = read_until_pattern(
108
+ process=process,
109
+ url_pattern=url_pattern,
110
+ timeout=timeout,
111
+ read_from_stderr=read_from_stderr,
112
+ )
113
+ except RuntimeError:
114
+ kill_process_by_name(bin_path.name)
115
+ try:
116
+ process.wait(timeout=3)
117
+ except Exception:
118
+ process.kill()
119
+ process.wait()
120
+ raise
121
+
122
+ # Слив вывода в фоне — предотвращает блокировку из-за переполнения пайпа
123
+ drain_process_output(process)
124
+
125
+ # Проверка наличия IPv4 в выводе (пароль/IP у некоторых провайдеров)
126
+ ipv4 = next(
127
+ (ln.strip() for ln in full_output.splitlines() if is_ipv4(ln.strip())),
128
+ None,
129
  )
130
 
131
+ exit_register(terminate_process, bin_path.name, process)
132
+
133
+ if write_link:
134
+ try:
135
+ links_file.write_text(url)
136
+ except Exception as e:
137
+ logger.warning(f'Не удалось записать ссылку в {links_file}: {e}')
138
+
139
+ logger.debug(f'[{bin_path.name}] Туннель: {url}')
140
+ return f'{url}\n пароль(IP): {ipv4}' if ipv4 else url
141
+
142
 
143
  # ---------------------------------------------------------------------------
144
+ # Провайдеры
145
  # ---------------------------------------------------------------------------
146
 
147
+ @tunnel_provider('tmole')
148
  def get_tmole_url(port: int) -> str:
149
  return get_revproxy_url(
150
  bin_url='https://tunnelmole.com/downloads/tmole-linux.gz',
 
152
  bin_path=tmole_bin,
153
  start_commands=[str(tmole_bin), str(port)],
154
  read_from_stderr=False,
155
+ timeout=20.0,
156
  )
157
 
158
 
159
+ @tunnel_provider('tunwg')
160
  def get_tunwg_url(port: int) -> str:
161
  return get_revproxy_url(
162
  bin_url='https://github.com/ntnj/tunwg/releases/latest/download/tunwg',
 
164
  bin_path=tunwg_bin,
165
  start_commands=[str(tunwg_bin), f'--forward=http://127.0.0.1:{port}'],
166
  read_from_stderr=True,
167
+ timeout=20.0,
168
  )
169
 
170
 
171
+ @tunnel_provider('cloudflared')
172
  def get_cloudflared_url(port: int) -> str:
173
  return get_revproxy_url(
174
  bin_url='https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
 
176
  bin_path=cloudflare_bin,
177
  start_commands=[str(cloudflare_bin), 'tunnel', '--url', f'http://127.0.0.1:{port}'],
178
  read_from_stderr=True,
 
179
  url_pattern=r'(?P<url>https?://\S+\.trycloudflare\.com)',
180
+ timeout=25.0,
181
  )
182
 
183
 
184
+ @tunnel_provider('localt')
185
  def get_localt_url(port: int) -> str:
186
  return get_revproxy_url(
187
  bin_url='https://huggingface.co/prolapse/go_localt/resolve/main/go_localt',
 
189
  bin_path=go_localt_bin,
190
  start_commands=[str(go_localt_bin), str(port)],
191
  read_from_stderr=False,
192
+ timeout=20.0,
193
  )
194
 
195
 
196
+ @tunnel_provider('gradio')
197
  def get_gradio_url(port: int) -> str:
198
  max_attempts = 3
199
  last_error: Exception | None = None
 
217
  bin_path=gradio_bin,
218
  start_commands=cmd,
219
  read_from_stderr=False,
220
+ timeout=20.0,
221
  )
222
  except Exception as e:
223
  last_error = e
224
  if attempt < max_attempts - 1:
225
+ logger.warning(f'Попытка {attempt + 1} Gradio провалилась: {e}. Повтор через 5с...')
226
  sleep(5)
227
 
228
+ raise RuntimeError(f'После {max_attempts} попыток Gradio-туннель не запущен: {last_error}')
229
+
230
+
231
+ @tunnel_provider('native')
232
+ def get_native_url(port: int) -> str:
233
+ """
234
+ Встроенный прокси Google Colab. Результат кэшируется по номеру порта.
235
+ """
236
+ try:
237
+ from google.colab.output import eval_js # type: ignore[import]
238
+ except ImportError:
239
+ raise RuntimeError('get_native_url доступен только в среде Google Colab.')
240
 
241
+ cache_url_file = colab_native_url
242
+ cache_port_file = colab_native_url.with_suffix('.port')
243
 
244
+ if cache_url_file.exists() and cache_port_file.exists():
245
+ try:
246
+ if int(cache_port_file.read_text().strip()) == port:
247
+ cached = cache_url_file.read_text().strip()
248
+ if cached:
249
+ logger.debug(f'native URL из кэша (порт {port}): {cached}')
250
+ return cached
251
+ except Exception:
252
+ pass
253
+
254
+ url = eval_js(f'google.colab.kernel.proxyPort({port})')
255
+
256
+ try:
257
+ WORK_FOLDER.mkdir(parents=True, exist_ok=True)
258
+ cache_url_file.write_text(url)
259
+ cache_port_file.write_text(str(port))
260
+ except Exception as e:
261
+ logger.warning(f'Не удалось кэшировать native URL: {e}')
262
+
263
+ return url
264
+
265
+
266
+ @tunnel_provider('mmar')
267
  def get_mmar_url(port: int) -> str:
268
  return get_revproxy_url(
269
  bin_url='https://github.com/yusuf-musleh/mmar/releases/latest/download/mmar_Linux_x86_64.tar.gz',
 
271
  bin_path=mmar_bin,
272
  start_commands=[str(mmar_bin), 'client', '--local-port', str(port)],
273
  read_from_stderr=True,
 
274
  url_pattern=r'(?P<url>https?://\S+\.mmar\.dev)',
275
+ timeout=20.0,
276
  )
277
 
278
 
279
+ @tunnel_provider('tunnelite')
280
  def get_tunnelite_url(port: int) -> str:
281
  return get_revproxy_url(
282
  bin_url='https://github.com/hehepiska/tunnellite-linux/releases/download/latest/tunnelite',
 
284
  bin_path=tunnelite_bin,
285
  start_commands=[str(tunnelite_bin), f'http://127.0.0.1:{port}'],
286
  read_from_stderr=False,
 
287
  url_pattern=r'(?P<url>https?://\S+\.tunnelite\.com)',
288
+ timeout=45.0, # Tunnelite стартует медленно
289
  )
290
 
291
 
292
+ @tunnel_provider('beeceptor')
293
  def get_beeceptor_url(port: int) -> str:
294
  return get_revproxy_url(
295
  bin_url='https://cdn.beeceptor.com/downloads/cli/latest/beeceptor-cli-linux-x64.tar.gz',
 
297
  bin_path=beeceptor_bin,
298
  start_commands=[str(beeceptor_bin), '-p', str(port), '-y'],
299
  read_from_stderr=False,
 
300
  url_pattern=r'(?P<url>https?://\S+\.beeceptor\.com)',
301
+ timeout=20.0,
302
+ stdin_input='\n',
303
  )
304
 
305
 
306
+ @tunnel_provider('bore')
307
+ def get_bore_url(port: int) -> str:
308
+ # Для актуальной версии запрашиваем GitHub API, только если бинарника ещё нет
309
+ bin_url = 'https://github.com/ekzhang/bore/releases/download/v0.6.0/bore-v0.6.0-x86_64-unknown-linux-musl.tar.gz'
310
+ if not bore_bin.exists():
311
+ try:
312
+ bin_url = get_github_latest_release_url(
313
+ 'ekzhang/bore',
314
+ r'bore-v[\d.]+-x86_64-unknown-linux-musl\.tar\.gz',
315
+ )
316
+ logger.debug(f'Актуальный URL bore: {bin_url}')
317
+ except Exception as e:
318
+ logger.warning(f'GitHub API недоступен, используется закреплённая версия bore: {e}')
319
+
320
+ raw = get_revproxy_url(
321
+ bin_url=bin_url,
322
+ need_unpack=True,
323
+ bin_path=bore_bin,
324
+ start_commands=[str(bore_bin), 'local', str(port), '--to', 'bore.pub'],
325
+ read_from_stderr=True, # Rust логи идут в stderr
326
+ url_pattern=r'bore\.pub:\d+',
327
+ timeout=15.0,
328
+ )
329
+ return f'http://{raw}/'
330
+
331
+
332
+ @tunnel_provider('boredigital')
333
+ def get_boredigital_url(port: int) -> str:
334
+ return get_revproxy_url(
335
+ bin_url='https://github.com/jkuri/bore/releases/latest/download/bore_linux_amd64',
336
+ need_unpack=False,
337
+ bin_path=boredigital_bin,
338
+ start_commands=[str(boredigital_bin), '-lp', str(port)],
339
+ read_from_stderr=False,
340
+ url_pattern=r'(?P<url>https://\S+\.bore\.digital)',
341
+ timeout=20.0,
342
+ )
colab_tunnel/_utils.py CHANGED
@@ -1,96 +1,63 @@
 
 
 
 
 
 
 
 
1
  from os import name as os_name
2
  from pathlib import Path
3
- from re import compile, search
4
- from shutil import move
5
- from subprocess import PIPE, Popen, STDOUT, check_output, CalledProcessError
6
- from sys import stdout
7
- from time import time
 
 
8
  from urllib.parse import unquote, urlparse
9
 
10
  from requests import get as get_url, head as get_head
11
  from requests.structures import CaseInsensitiveDict
12
 
13
  from ._config import WORK_FOLDER, HEADERS
 
14
 
15
 
16
  # ---------------------------------------------------------------------------
17
- # Архивы
18
  # ---------------------------------------------------------------------------
19
 
20
- def determine_archive_format(filepath: str | Path) -> str | None:
21
- filepath = Path(filepath)
22
- zip_sig = bytes([0x50, 0x4B, 0x03, 0x04])
23
- seven_z = bytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])
24
- lzma_xz = bytes([0xFD, 0x37, 0x7A, 0x58, 0x5A])
25
- tgz = bytes([0x1F, 0x8B])
26
- tbz = bytes([0x42, 0x5A, 0x68])
27
- ustar = bytes([0x75, 0x73, 0x74, 0x61, 0x72])
28
-
29
- with filepath.open('rb') as fh:
30
- header = fh.read(262)
31
-
32
- if header.startswith(zip_sig):
33
- return 'zip'
34
- if header.startswith(seven_z) or header.startswith(lzma_xz):
35
- return '7z'
36
- if header.startswith(tgz):
37
- return 'tar.gz'
38
- if header.startswith(tbz):
39
- return 'tar.bz2'
40
- if header[0x101:0x101 + len(ustar)] == ustar:
41
- return 'tar'
42
- return None
43
-
44
-
45
- def unpack_archive(archive_path: str | Path, dest_path: str | Path, rm_archive: bool = True) -> None:
46
- if str(archive_path).startswith(('https://', 'http://')):
47
- archive_path = download(archive_path, save_path=WORK_FOLDER, progress=False)
48
-
49
- archive_path = Path(archive_path)
50
- dest_path = Path(dest_path)
51
-
52
- if not archive_path.exists():
53
- raise RuntimeError(f'архив {archive_path} не найден.')
54
-
55
- fmt = determine_archive_format(archive_path)
56
-
57
- def _rm() -> None:
58
- if rm_archive:
59
- archive_path.unlink(missing_ok=True)
60
-
61
- try:
62
- if fmt == '7z' or archive_path.suffix == '.7z':
63
- run(f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"', cwd=dest_path)
64
- elif fmt == 'tar' or archive_path.suffix == '.tar':
65
- run(f'tar -xvpf "{archive_path}" -C "{dest_path}"', cwd=dest_path)
66
- elif fmt in ('tar.gz', 'tar.bz2') or archive_path.suffix in ('.tar.gz', '.tar.bz2', '.tar.xz'):
67
- result = run(f'tar -xvzpf "{archive_path}" -C "{dest_path}"', cwd=dest_path)
68
- if result['status_code'] != 0:
69
- run(f'gzip -d "{archive_path}"', dest_path, cwd=dest_path)
70
- elif fmt == 'zip' or archive_path.suffix == '.zip':
71
- run(f'unzip "{archive_path}" -d "{dest_path}"', cwd=dest_path)
72
- else:
73
- run(f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"', cwd=dest_path)
74
- _rm()
75
- except Exception:
76
- try:
77
- run(f'7z -bso0 -bd -mmt4 -slp -y x "{archive_path}" -o"{dest_path}"', cwd=dest_path)
78
- _rm()
79
- except Exception:
80
- raise RuntimeError(
81
- f'формат архива {archive_path.suffix} не определён. '
82
- f'Задайте формат вручную через determine_archive_format.'
83
- )
84
 
85
 
86
  # ---------------------------------------------------------------------------
87
  # Процессы
88
  # ---------------------------------------------------------------------------
89
 
90
- def run(command: str, cwd: str | Path | None = None, live_output: bool = False) -> dict:
91
- process = Popen(command, shell=True, cwd=cwd, stdout=PIPE, stderr=STDOUT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  _encodings = ['utf-8', 'iso-8859-5', 'windows-1251', 'cp866', 'koi8-r', 'mac_cyrillic']
93
- _progress_pattern = r'\d+%|\d+/\d+'
94
 
95
  def _decode(data: bytes) -> str:
96
  for enc in _encodings:
@@ -100,30 +67,60 @@ def run(command: str, cwd: str | Path | None = None, live_output: bool = False)
100
  continue
101
  return data.decode('utf-8', errors='replace')
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  final_output: list[str] = []
104
  last_progress = ''
 
105
 
106
- for raw_line in iter(process.stdout.readline, b''):
 
 
 
 
 
107
  line = _decode(raw_line.strip())
108
- if search(_progress_pattern, line):
109
  last_progress = line
110
- if live_output:
111
- stdout.write('\r' + line)
112
  else:
113
  final_output.append(line)
114
- if live_output:
115
- stdout.write(line + '\n')
116
- if live_output:
117
- stdout.flush()
 
 
 
 
118
 
119
- process.wait()
120
  if last_progress:
121
  final_output.append(last_progress)
122
 
123
- return {
124
- 'status_code': process.returncode,
125
- 'output': '\n'.join(final_output),
126
- }
 
 
 
 
 
127
 
128
 
129
  def is_process_running(process_name: str | Path) -> bool:
@@ -135,27 +132,155 @@ def is_process_running(process_name: str | Path) -> bool:
135
 
136
 
137
  def terminate_process(process_name: str, process_obj: Popen) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  try:
139
- process_obj.stdout.close()
140
  except Exception:
141
  pass
142
  try:
143
- process_obj.stderr.close()
 
 
 
144
  except Exception:
145
  pass
146
- process_obj.terminate()
147
- run(f'pkill -f {process_name}')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  def move_path(old_path: Path | str, new_path: Path | str) -> None:
151
  old, new = Path(old_path), Path(new_path)
152
  if not old.exists():
153
- raise RuntimeError(f'не найден исходный путь для перемещения: {old}')
154
  try:
155
  old.replace(new)
156
  except Exception:
157
  try:
158
- move(old, new)
159
  except Exception:
160
  if os_name == 'posix':
161
  run(f'mv "{old}" "{new}"')
@@ -163,23 +288,141 @@ def move_path(old_path: Path | str, new_path: Path | str) -> None:
163
  run(f'move "{old}" "{new}"')
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  # ---------------------------------------------------------------------------
167
  # Сеть
168
  # ---------------------------------------------------------------------------
169
 
170
  def is_ipv4(address: str) -> bool:
171
- pattern = compile(
172
  r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$'
173
  )
174
  return bool(pattern.match(address))
175
 
176
 
177
  def is_valid_url(url: str, custom_headers: dict | None = None) -> bool:
 
 
 
 
 
 
178
  req_headers = HEADERS.copy()
179
  if custom_headers:
180
  req_headers.update(custom_headers)
181
 
182
- for attempt in range(2):
183
  try:
184
  with get_url(url, headers=req_headers, allow_redirects=True, stream=True, timeout=10) as resp:
185
  if 200 <= resp.status_code < 300:
@@ -200,7 +443,6 @@ def get_filename_from_headers(headers: CaseInsensitiveDict) -> str | None:
200
  cd = headers.get('content-disposition')
201
  if not cd:
202
  return headers.get('filename')
203
-
204
  for part in cd.split(';'):
205
  part = part.strip()
206
  if part.startswith('filename*='):
@@ -216,18 +458,24 @@ def download(
216
  filename: str | Path | None = None,
217
  save_path: str | Path | None = None,
218
  progress: bool = True,
 
219
  ) -> Path:
220
- extra_headers: dict | None = None
221
- url_parts = url.replace('"', '').replace("'", '').split('--header=')
222
- if len(url_parts) > 1:
223
- url = url_parts[0].strip()
224
- key, _, value = url_parts[1].partition(':')
225
- extra_headers = {key.strip(): value.strip()}
226
-
227
- # Проверка валидности с учетом переданных заголовков
228
- if not is_valid_url(url, custom_headers=extra_headers):
229
- raise RuntimeError(f'недействительная ссылка на файл или доступ ограничен: {url}')
230
-
 
 
 
 
 
231
  save_path = Path(save_path) if save_path else Path.cwd()
232
  save_path.mkdir(parents=True, exist_ok=True)
233
 
@@ -235,29 +483,72 @@ def download(
235
  if extra_headers:
236
  req_headers.update(extra_headers)
237
 
238
- with get_url(url, stream=True, allow_redirects=True, headers=req_headers) as req:
239
- file_size = int(req.headers.get('content-length', 0))
240
- file_name = filename or get_filename_from_headers(req.headers) or Path(urlparse(req.url).path).name
241
- file_path = save_path / file_name
242
- chunk_size = max(4096, file_size // 2000) if file_size else 4096
 
 
 
 
 
 
 
 
 
 
243
 
244
- downloaded = 0
245
- start = time()
246
- try:
247
- with open(file_path, 'wb') as fp:
248
- for chunk in req.iter_content(chunk_size=chunk_size):
249
- if chunk:
250
- fp.write(chunk)
251
- if progress and file_size:
252
- downloaded += len(chunk)
253
- elapsed = time() - start
254
- print(
255
- f'\rзагрузка {file_name}: {downloaded / file_size * 100:.2f}% | {elapsed:.2f} сек.',
256
- end='',
257
- )
258
- except Exception as e:
259
- raise RuntimeError(f'не удалось загрузить файл по ссылке {url}:\n{e}')
 
260
 
261
  if progress and file_size:
262
  print()
263
- return file_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
 
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:
 
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
+ Использует select() + os.set_blocking() вместо readline(),
198
+ что полностью исключает зависание при нехватке строк вывода.
199
+
200
+ Args:
201
+ process: Запущенный Popen с открытыми потоками PIPE.
202
+ url_pattern: Регулярное выражение для поиска URL.
203
+ timeout: Максимальное время ожидания в секундах.
204
+ read_from_stderr: Читать из stderr (иначе stdout).
205
+ read_both_streams: Читать из обоих потоков (для SSH).
206
+
207
+ Returns:
208
+ Кортеж (matched_url, full_output).
209
+
210
+ Raises:
211
+ RuntimeError: Паттерн не найден за время timeout.
212
+ """
213
+ if read_both_streams:
214
+ active_streams = [s for s in (process.stdout, process.stderr) if s]
215
+ elif read_from_stderr:
216
+ active_streams = [process.stderr] if process.stderr else []
217
+ else:
218
+ active_streams = [process.stdout] if process.stdout else []
219
+
220
+ if not active_streams:
221
+ raise RuntimeError('Потоки вывода процесса недоступны.')
222
+
223
+ raws: list = []
224
+ for stream in active_streams:
225
+ raw = getattr(stream, 'raw', stream)
226
+ try:
227
+ os.set_blocking(raw.fileno(), False)
228
+ except Exception:
229
+ pass
230
+ raws.append(raw)
231
+
232
+ buffer = b''
233
+ deadline = time.time() + timeout
234
+
235
+ while time.time() < deadline:
236
+ try:
237
+ ready, _, _ = select.select(raws, [], [], 0.1)
238
+ except Exception:
239
+ break
240
+
241
+ for raw in ready:
242
+ try:
243
+ chunk = raw.read(4096)
244
+ if chunk:
245
+ buffer += chunk
246
+ except (BlockingIOError, ValueError):
247
+ pass
248
+ except Exception:
249
+ pass
250
+
251
+ text = buffer.decode('utf-8', errors='ignore')
252
+ match = re.search(url_pattern, text)
253
+ if match:
254
+ return match.group(), text
255
+
256
+ if process.poll() is not None:
257
+ # Процесс завершился — финальное чтение остатков
258
+ for raw in raws:
259
+ try:
260
+ remaining = raw.read(65536)
261
+ if remaining:
262
+ buffer += remaining
263
+ except Exception:
264
+ pass
265
+ text = buffer.decode('utf-8', errors='ignore')
266
+ match = re.search(url_pattern, text)
267
+ if match:
268
+ return match.group(), text
269
+ break
270
+
271
+ output_str = buffer.decode('utf-8', errors='ignore')
272
+ raise RuntimeError(f'URL не найден за {timeout} сек. Вывод процесса:\n{output_str}')
273
 
274
 
275
  def move_path(old_path: Path | str, new_path: Path | str) -> None:
276
  old, new = Path(old_path), Path(new_path)
277
  if not old.exists():
278
+ raise RuntimeError(f'Не найден исходный путь для перемещения: {old}')
279
  try:
280
  old.replace(new)
281
  except Exception:
282
  try:
283
+ shutil_move(str(old), str(new))
284
  except Exception:
285
  if os_name == 'posix':
286
  run(f'mv "{old}" "{new}"')
 
288
  run(f'move "{old}" "{new}"')
289
 
290
 
291
+ # ---------------------------------------------------------------------------
292
+ # Файлы и архивы
293
+ # ---------------------------------------------------------------------------
294
+
295
+ def _snapshot(folder: Path) -> set[Path]:
296
+ """Рекурсивный снимок всех файлов в директории (включая вложенные папки)."""
297
+ if not folder.exists():
298
+ return set()
299
+ return {f for f in folder.rglob('*') if f.is_file()}
300
+
301
+
302
+ def determine_archive_format(filepath: str | Path) -> str | None:
303
+ """Определяет формат архива по сигнатуре байт (magic bytes)."""
304
+ filepath = Path(filepath)
305
+ zip_sig = bytes([0x50, 0x4B, 0x03, 0x04])
306
+ seven_z = bytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])
307
+ lzma_xz = bytes([0xFD, 0x37, 0x7A, 0x58, 0x5A])
308
+ tgz = bytes([0x1F, 0x8B])
309
+ tbz = bytes([0x42, 0x5A, 0x68])
310
+ ustar = bytes([0x75, 0x73, 0x74, 0x61, 0x72])
311
+
312
+ with filepath.open('rb') as fh:
313
+ header = fh.read(262)
314
+
315
+ if header.startswith(zip_sig): return 'zip'
316
+ if header.startswith(seven_z): return '7z'
317
+ if header.startswith(lzma_xz): return '7z'
318
+ if header.startswith(tgz): return 'tar.gz'
319
+ if header.startswith(tbz): return 'tar.bz2'
320
+ if header[0x101:0x101 + len(ustar)] == ustar: return 'tar'
321
+ return None
322
+
323
+
324
+ def unpack_archive(
325
+ archive_path: str | Path,
326
+ dest_path: str | Path,
327
+ rm_archive: bool = True,
328
+ ) -> None:
329
+ """
330
+ Распаковывает архив в указанную директорию.
331
+
332
+ Поддерживает: tar, tar.gz, tar.bz2, tar.xz, gz (одиночный файл), zip, 7z.
333
+ При неудаче основного метода делает fallback к 7z.
334
+ Принимает URL — скачивает архив сам.
335
+ """
336
+ if str(archive_path).startswith(('https://', 'http://')):
337
+ archive_path = download(str(archive_path), save_path=WORK_FOLDER, progress=False)
338
+
339
+ archive_path = Path(archive_path)
340
+ dest_path = Path(dest_path)
341
+
342
+ if not archive_path.exists():
343
+ raise RuntimeError(f'Архив не найден: {archive_path}')
344
+
345
+ fmt = determine_archive_format(archive_path)
346
+ dest_path.mkdir(parents=True, exist_ok=True)
347
+
348
+ def _rm() -> None:
349
+ if rm_archive:
350
+ archive_path.unlink(missing_ok=True)
351
+
352
+ def _try_7z() -> None:
353
+ result = run(
354
+ f'7z -bso0 -bd -mmt4 -slp -y x "{archive_path}" -o"{dest_path}"',
355
+ timeout=180,
356
+ )
357
+ if result['status_code'] != 0:
358
+ raise RuntimeError(
359
+ f'Не удалось распаковать {archive_path.name}.\n{result["output"]}'
360
+ )
361
+ _rm()
362
+
363
+ try:
364
+ if fmt == 'zip' or archive_path.suffix == '.zip':
365
+ r = run(f'unzip -o "{archive_path}" -d "{dest_path}"', timeout=180)
366
+ if r['status_code'] != 0:
367
+ raise RuntimeError(r['output'])
368
+ _rm()
369
+
370
+ elif fmt in ('7z',) or archive_path.suffix == '.7z':
371
+ r = run(
372
+ f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"',
373
+ timeout=180,
374
+ )
375
+ if r['status_code'] != 0:
376
+ raise RuntimeError(r['output'])
377
+ _rm()
378
+
379
+ elif fmt in ('tar', 'tar.gz', 'tar.bz2') or archive_path.suffix in (
380
+ '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz', '.txz', '.gz',
381
+ ):
382
+ # GNU tar определяет формат сжатия автоматически через -xpf
383
+ r = run(f'tar -xpf "{archive_path}" -C "{dest_path}"', timeout=180)
384
+ if r['status_code'] != 0:
385
+ # Возможно, это одиночный gzip-файл без tar-обёртки (напр. tmole-linux.gz)
386
+ gz_r = run(f'gzip -df "{archive_path}"', timeout=60)
387
+ if gz_r['status_code'] != 0:
388
+ raise RuntimeError(
389
+ f'tar: {r["output"]}\ngzip: {gz_r["output"]}'
390
+ )
391
+ # gzip -f уже удалил .gz и создал распакованный файл на месте
392
+ return
393
+ _rm()
394
+
395
+ else:
396
+ _try_7z()
397
+
398
+ except RuntimeError as e:
399
+ logger.debug(f'Основной метод распаковки не сработал ({e}), fallback к 7z.')
400
+ _try_7z()
401
+
402
+
403
  # ---------------------------------------------------------------------------
404
  # Сеть
405
  # ---------------------------------------------------------------------------
406
 
407
  def is_ipv4(address: str) -> bool:
408
+ pattern = re.compile(
409
  r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$'
410
  )
411
  return bool(pattern.match(address))
412
 
413
 
414
  def is_valid_url(url: str, custom_headers: dict | None = None) -> bool:
415
+ """
416
+ Проверяет доступность URL (HEAD или GET запрос).
417
+
418
+ Returns:
419
+ True, если сервер вернул HTTP 2xx.
420
+ """
421
  req_headers = HEADERS.copy()
422
  if custom_headers:
423
  req_headers.update(custom_headers)
424
 
425
+ for _ in range(2):
426
  try:
427
  with get_url(url, headers=req_headers, allow_redirects=True, stream=True, timeout=10) as resp:
428
  if 200 <= resp.status_code < 300:
 
443
  cd = headers.get('content-disposition')
444
  if not cd:
445
  return headers.get('filename')
 
446
  for part in cd.split(';'):
447
  part = part.strip()
448
  if part.startswith('filename*='):
 
458
  filename: str | Path | None = None,
459
  save_path: str | Path | None = None,
460
  progress: bool = True,
461
+ extra_headers: dict[str, str] | None = None,
462
  ) -> Path:
463
+ """
464
+ Скачивает файл по URL.
465
+
466
+ Args:
467
+ url: URL файла.
468
+ filename: Имя для сохранения (None — из заголовков или URL).
469
+ save_path: Папка сохранения (None — текущая директория).
470
+ progress: Показывать прогресс загрузки.
471
+ extra_headers: Дополнительные HTTP-заголовки.
472
+
473
+ Returns:
474
+ Path к скачанному файлу.
475
+
476
+ Raises:
477
+ RuntimeError: При ошибке сети или записи файла.
478
+ """
479
  save_path = Path(save_path) if save_path else Path.cwd()
480
  save_path.mkdir(parents=True, exist_ok=True)
481
 
 
483
  if extra_headers:
484
  req_headers.update(extra_headers)
485
 
486
+ try:
487
+ resp = get_url(url, stream=True, allow_redirects=True, headers=req_headers, timeout=60)
488
+ resp.raise_for_status()
489
+ except Exception as e:
490
+ raise RuntimeError(f'Не удалось скачать файл по ссылке {url}:\n{e}')
491
+
492
+ file_size = int(resp.headers.get('content-length', 0))
493
+ raw_name = (
494
+ str(filename)
495
+ if filename
496
+ else (get_filename_from_headers(resp.headers) or Path(urlparse(resp.url).path).name)
497
+ )
498
+ file_name = Path(raw_name).name
499
+ file_path = save_path / file_name
500
+ chunk_size = max(4096, file_size // 2000) if file_size else 4096
501
 
502
+ downloaded = 0
503
+ start = time.time()
504
+ try:
505
+ with open(file_path, 'wb') as fp:
506
+ for chunk in resp.iter_content(chunk_size=chunk_size):
507
+ if chunk:
508
+ fp.write(chunk)
509
+ if progress and file_size:
510
+ downloaded += len(chunk)
511
+ elapsed = time.time() - start
512
+ print(
513
+ f'\rзагрузка {file_name}: '
514
+ f'{downloaded / file_size * 100:.2f}% | {elapsed:.2f} сек.',
515
+ end='',
516
+ )
517
+ except Exception as e:
518
+ raise RuntimeError(f'Не удалось сохранить файл из {url}:\n{e}')
519
 
520
  if progress and file_size:
521
  print()
522
+
523
+ logger.debug(f'Скачан: {file_path} ({file_size} байт)')
524
+ return file_path
525
+
526
+
527
+ def get_github_latest_release_url(repo: str, asset_pattern: str) -> str:
528
+ """
529
+ Возвращает URL загрузки актуального релизного ассета с GitHub.
530
+
531
+ Args:
532
+ repo: Репозиторий в формате 'owner/repo'.
533
+ asset_pattern: Регулярное выражение для имени ассета.
534
+
535
+ Returns:
536
+ browser_download_url для найденного ассета.
537
+
538
+ Raises:
539
+ RuntimeError: Если ассет не найден или GitHub API недоступен.
540
+ """
541
+ api_url = f'https://api.github.com/repos/{repo}/releases/latest'
542
+ resp = get_url(api_url, timeout=10)
543
+ resp.raise_for_status()
544
+
545
+ assets = resp.json().get('assets', [])
546
+ for asset in assets:
547
+ if re.search(asset_pattern, asset['name']):
548
+ return asset['browser_download_url']
549
+
550
+ available = [a['name'] for a in assets]
551
+ raise RuntimeError(
552
+ f'Ассет "{asset_pattern}" не найден в {repo}. '
553
+ f'Доступные: {available}'
554
+ )
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "colab-tunnel"
7
- version = "1.0.0"
8
  description = "Утилиты для туннелирования веб-интерфейсов в Google Colab"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
@@ -13,5 +13,15 @@ dependencies = [
13
  "requests",
14
  ]
15
 
 
 
 
 
 
 
16
  [tool.setuptools.packages.find]
17
- include = ["colab_tunnel*"]
 
 
 
 
 
4
 
5
  [project]
6
  name = "colab-tunnel"
7
+ version = "1.1.0"
8
  description = "Утилиты для туннелирования веб-интерфейсов в Google Colab"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
13
  "requests",
14
  ]
15
 
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=7.0",
19
+ "pytest-timeout",
20
+ ]
21
+
22
  [tool.setuptools.packages.find]
23
+ include = ["colab_tunnel*"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ timeout = 30
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()