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**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа.
## Как это работает
```
-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)