diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b5f4d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +.git +.github +.gitignore +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +dist/ +build/ +packaging/ +windows.py +icon.ico +*.spec +*.spec.bak +*.manifest +*.log +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db +Desktop.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dae44d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1.7 + +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + VIRTUAL_ENV=/opt/venv + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential cargo libffi-dev libssl-dev \ + && python -m venv "$VIRTUAL_ENV" \ + && "$VIRTUAL_ENV/bin/pip" install --upgrade pip setuptools wheel \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/opt/venv/bin:$PATH \ + TG_WS_PROXY_HOST=0.0.0.0 \ + TG_WS_PROXY_PORT=1080 \ + TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system app \ + && useradd --system --gid app --create-home --home-dir /home/app app + +WORKDIR /app +COPY --from=builder /opt/venv /opt/venv +COPY proxy ./proxy +COPY README.md LICENSE ./ + +USER app + +EXPOSE 1080/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] +CMD [] diff --git a/README.md b/README.md index 984ce40..8e93b94 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ > [!CAUTION] > > ### Реакция антивирусов -> > Windows Defender часто ошибочно помечает приложение как **Wacatac**. > Если вы не можете скачать из-за блокировки, то: -> > 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала) > 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно > @@ -12,138 +10,77 @@ # TG WS Proxy -**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. +Локальный SOCKS5-прокси для Telegram Desktop, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram. + +**Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа. image ## Как это работает ``` -Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC +Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.telegram.org) → Telegram DC ``` 1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080` 2. Перехватывает подключения к IP-адресам Telegram 3. Извлекает DC ID из MTProto obfuscation init-пакета -4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram +4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org` 5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение ## 🚀 Быстрый старт ### Windows - -Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. +Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. **Меню трея:** - - **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку - **Перезапустить прокси** — перезапуск без выхода из приложения - **Настройки...** — GUI-редактор конфигурации - **Открыть логи** — открыть файл логов - **Выход** — остановить прокси и закрыть приложение -### macOS - -Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel. - -1. Открыть образ -2. Перенести **TG WS Proxy.app** в папку **Applications** -3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть** - -### Linux - -Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**. - -Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64). - -```bash -chmod +x TgWsProxy_linux_amd64 -./TgWsProxy_linux_amd64 -``` - -При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator). - ## Установка из исходников -### Консольный proxy - -Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки: - ```bash -pip install -e . -tg-ws-proxy +pip install -r requirements.txt ``` -### Windows 10+ +### Windows (Tray-приложение) ```bash -pip install -e ".[win10]" -tg-ws-proxy-tray-win +python windows.py ``` -### Windows 7 +### Консольный режим ```bash -pip install -e ".[win7]" -tg-ws-proxy-tray-win -``` - -### macOS - -```bash -pip install -e ".[macos]" -tg-ws-proxy-tray-macos -``` - -### Linux - -```bash -pip install -e ".[linux]" -tg-ws-proxy-tray-linux -``` - -### Консольный режим из исходников - -```bash -tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v] +python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] ``` **Аргументы:** -| Аргумент | По умолчанию | Описание | -|---|---|---| -| `--port` | `1080` | Порт SOCKS5-прокси | -| `--host` | `127.0.0.1` | Хост SOCKS5-прокси | -| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | -| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | +| Аргумент | По умолчанию | Описание | +|-------------------------------------|------------------------------------------|------------------------------------------------| +| `--host` | `127.0.0.1` | IP-адрес SOCKS5-прокси | +| `--port` | `1080` | Порт SOCKS5-прокси | +| `-u`, `-user`
`-P`,`--password` | выкл. | Логин и Пароль для авторизации в SOCKS5-прокси | +| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз)| +| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | **Примеры:** ```bash # Стандартный запуск -tg-ws-proxy +python proxy/tg_ws_proxy.py # Другой порт и дополнительные DC -tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 +python proxy/tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 # С подробным логированием -tg-ws-proxy -v -``` - -## CLI-скрипты (pyproject.toml) - -CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`. - -Пример: - -```toml -[project.scripts] -tg-ws-proxy = "proxy.tg_ws_proxy:main" -tg-ws-proxy-tray-win = "windows:main" -tg-ws-proxy-tray-macos = "macos:main" -tg-ws-proxy-tray-linux = "linux:main" +python proxy/tg_ws_proxy.py -v ``` ## Настройка Telegram Desktop @@ -163,11 +100,7 @@ tg-ws-proxy-tray-linux = "linux:main" ## Конфигурация -Tray-приложение хранит данные в: - -- **Windows:** `%APPDATA%/TgWsProxy` -- **macOS:** `~/Library/Application Support/TgWsProxy` -- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`) +Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`: ```json { @@ -182,15 +115,12 @@ Tray-приложение хранит данные в: ## Автоматическая сборка -Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. +Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. -Минимально поддерживаемые версии ОС для текущих бинарных сборок: - -- Windows 10+ для `TgWsProxy_windows.exe` -- Windows 7 для `TgWsProxy_windows_7.exe` -- Intel macOS 10.15+ -- Apple Silicon macOS 11.0+ -- Linux x86_64 (требуется AppIndicator для системного трея) +```bash +pip install pyinstaller +pyinstaller packaging/windows.spec +``` ## Лицензия diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 8bd0c45..65f7ac3 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -12,14 +12,15 @@ import sys import time from typing import Dict, List, Optional, Set, Tuple from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from urllib.parse import quote DEFAULT_PORT = 1080 log = logging.getLogger('tg-ws-proxy') _TCP_NODELAY = True -_RECV_BUF = 256 * 1024 -_SEND_BUF = 256 * 1024 +_RECV_BUF = 131072 +_SEND_BUF = 131072 _WS_POOL_SIZE = 4 _WS_POOL_MAX_AGE = 120.0 @@ -68,13 +69,11 @@ _IP_TO_DC: Dict[str, Tuple[int, bool]] = { '91.105.192.100': (203, False), } -# This case might work but not actually sure -_DC_OVERRIDES: Dict[int, int] = { - 203: 2 -} - _dc_opt: Dict[int, Optional[str]] = {} +_auth_user = '' +_auth_password = '' + # DCs where WS is known to fail (302 redirect) # Raw TCP fallback will be used instead # Keyed by (dc, is_media) @@ -82,8 +81,7 @@ _ws_blacklist: Set[Tuple[int, bool]] = set() # Rate-limit re-attempts per (dc, is_media) _dc_fail_until: Dict[Tuple[int, bool], float] = {} -_DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure -_WS_FAIL_TIMEOUT = 2.0 # quick-retry timeout after a recent WS failure +_DC_FAIL_COOLDOWN = 60.0 # seconds _ssl_ctx = ssl.create_default_context() @@ -383,7 +381,7 @@ def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: proto, dc_raw, plain.hex()) if proto in (0xEFEFEFEF, 0xEEEEEEEE, 0xDDDDDDDD): dc = abs(dc_raw) - if 1 <= dc <= 5 or dc == 203: + if 1 <= dc <= 1000: return dc, (dc_raw < 0) except Exception as exc: log.debug("DC extraction failed: %s", exc) @@ -470,10 +468,16 @@ class _MsgSplitter: def _ws_domains(dc: int, is_media) -> List[str]: - dc = _DC_OVERRIDES.get(dc, dc) + """ + Return domain names to try for WebSocket connection to a DC. + + DC 1-5: kws{N}[-1].web.telegram.org + DC >5: kws{N}[-1].telegram.org + """ + base = 'telegram.org' if dc > 5 else 'web.telegram.org' if is_media is None or is_media: - return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] - return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] + return [f'kws{dc}-1.{base}', f'kws{dc}.{base}'] + return [f'kws{dc}.{base}', f'kws{dc}-1.{base}'] class Stats: @@ -614,7 +618,7 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, nonlocal up_bytes, up_packets try: while True: - chunk = await reader.read(65536) + chunk = await reader.read(131072) if not chunk: break _stats.bytes_up += len(chunk) @@ -787,10 +791,53 @@ async def _handle_client(reader, writer): log.debug("[%s] not SOCKS5 (ver=%d)", label, hdr[0]) writer.close() return + + # Auth check nmethods = hdr[1] - await reader.readexactly(nmethods) - writer.write(b'\x05\x00') # no-auth - await writer.drain() + auth_methods = await reader.readexactly(nmethods) + + if not _auth_user and not _auth_password: + writer.write(b'\x05\x00') # no-auth + await writer.drain() + elif _auth_user and _auth_password: + if b'\x02' not in auth_methods: + writer.write(b'\x05\xff') + await writer.drain() + writer.close() + return + writer.write(b'\x05\x02') # username/password required + log.debug("[%s] auth required", label) + + # Username/password subnegotiation + auth_hdr = await asyncio.wait_for(reader.readexactly(1), timeout=10) + if auth_hdr[0] != 1: + log.debug("[%s] bad auth request ver: %s", label, auth_hdr) + writer.write(b'\x01\x01') + await writer.drain() + writer.close() + return + + ulen = (await reader.readexactly(1))[0] + client_username = (await reader.readexactly(ulen)).decode('utf-8', errors='ignore') + plen = (await reader.readexactly(1))[0] + client_password = (await reader.readexactly(plen)).decode('utf-8', errors='ignore') + + if client_username != _auth_user or client_password != _auth_password: + log.warning("[%s] auth failed with creds: %s/%s", label, client_username, client_password) + writer.write(b'\x01\x01') + await writer.drain() + writer.close() + return + + log.debug("[%s] auth success", label) + writer.write(b'\x01\x00') # Success + else: + # If have some problems + writer.write(b'\x05\xff') + await writer.drain() + writer.close() + return + # -- SOCKS5 CONNECT request -- req = await asyncio.wait_for(reader.readexactly(4), timeout=10) @@ -911,10 +958,20 @@ async def _handle_client(reader, writer): label, dc, media_tag) return - # -- Try WebSocket via direct connection -- + # -- Cooldown check -- fail_until = _dc_fail_until.get(dc_key, 0) - ws_timeout = _WS_FAIL_TIMEOUT if now < fail_until else 10.0 + if now < fail_until: + remaining = fail_until - now + log.debug("[%s] DC%d%s WS cooldown (%.0fs) -> TCP", + label, dc, media_tag, remaining) + ok = await _tcp_fallback(reader, writer, dst, port, init, + label, dc=dc, is_media=is_media) + if ok: + log.info("[%s] DC%d%s TCP fallback closed", + label, dc, media_tag) + return + # -- Try WebSocket via direct connection -- domains = _ws_domains(dc, is_media) target = _dc_opt[dc] ws = None @@ -932,7 +989,7 @@ async def _handle_client(reader, writer): label, dc, media_tag, dst, port, url, target) try: ws = await RawWebSocket.connect(target, domain, - timeout=ws_timeout) + timeout=10) all_redirects = False break except WsHandshakeError as exc: @@ -1050,6 +1107,7 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], log.info("=" * 60) log.info(" Configure Telegram Desktop:") log.info(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port) + log.info(f" tg://socks/?server={host}&port={port}&user={quote(_auth_user)}&pass={quote(_auth_password)}") log.info("=" * 60) async def log_stats(): @@ -1086,6 +1144,20 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], _server_instance = None +def set_auth_credentials(username: str, password: str): + """Validate 'user' and 'password': both are specified, or both aren't""" + empty_user = username is None or not str(username).strip() + empty_pass = password is None or not str(password).strip() + + if empty_user != empty_pass: + raise ValueError( + "Both --username (-u) and --password (-P) must be specified together, or neither should be used") + + global _auth_user, _auth_password + _auth_user = username + _auth_password = password + + def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: """Parse list of 'DC:IP' strings into {dc: ip} dict.""" dc_opt: Dict[int, str] = {} @@ -1112,23 +1184,25 @@ def run_proxy(port: int, dc_opt: Dict[int, str], def main(): ap = argparse.ArgumentParser( description='Telegram Desktop WebSocket Bridge Proxy') - ap.add_argument('--port', type=int, default=DEFAULT_PORT, - help=f'Listen port (default {DEFAULT_PORT})') ap.add_argument('--host', type=str, default='127.0.0.1', help='Listen host (default 127.0.0.1)') + ap.add_argument('--port', type=int, default=DEFAULT_PORT, + help=f'Listen port (default {DEFAULT_PORT})') + ap.add_argument('-u', '--user', type=str, default='', + help='User for proxy') + ap.add_argument('-P', '--password', type=str, default='', + help='Password for proxy') ap.add_argument('--dc-ip', metavar='DC:IP', action='append', - default=[], + default=['2:149.154.167.220', '4:149.154.167.220'], help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205' ' --dc-ip 2:149.154.167.220') ap.add_argument('-v', '--verbose', action='store_true', help='Debug logging') args = ap.parse_args() - if not args.dc_ip: - args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] - try: dc_opt = parse_dc_ip_list(args.dc_ip) + set_auth_credentials(args.user, args.password) except ValueError as e: log.error(str(e)) sys.exit(1)