21 Commits

Author SHA1 Message Date
Flowseal
f5d7797259 build fix 2026-03-15 05:19:15 +03:00
Flowseal
d5a3eb5157 build fix 2026-03-15 05:06:16 +03:00
Flowseal
e4891cfd53 hardcode host connection 2026-03-15 05:00:50 +03:00
Flowseal
a0a5bfbecb IPv6 warnings 2026-03-15 04:56:26 +03:00
Flowseal
1c227b924a Optimization, connections pool 2026-03-15 04:34:05 +03:00
Flowseal
72e5040e6d fix #83 2026-03-15 02:33:20 +03:00
Flowseal
0297bf8305 Unstripped build 2026-03-15 01:44:37 +03:00
Flowseal
8bcbcd2787 media dc fix on mobiles 2026-03-13 13:34:22 +03:00
Flowseal
f744e93de6 Mobiles media fix, optimizations 2026-03-12 19:36:02 +03:00
Flowseal
6147cda356 unknown behavior on mobiles with media dcs 2026-03-10 14:21:31 +03:00
Flowseal
3cf12467a7 Host configuration 2026-03-07 21:52:59 +03:00
Flowseal
48282a63d4 code cleaning 2026-03-07 21:14:17 +03:00
Flowseal
39dd71be14 Lock recode, bind error notify, clipboard cross-platform 2026-03-07 21:10:35 +03:00
Flowseal
46aec5e3b6 Win7 bundle 2026-03-07 18:08:42 +03:00
Flowseal
7e3732b04b reqs version freeze 2026-03-07 17:18:05 +03:00
Flowseal
5586d194db workflow windows path fix 2026-03-06 19:59:32 +03:00
Flowseal
f69d20ad85 Restructure 2026-03-06 19:48:12 +03:00
Flowseal
01b3aca85e code simplify 2026-03-06 19:08:46 +03:00
Flowseal
9e9448dda0 imports clear 2026-03-06 17:13:00 +03:00
Flowseal
f8a10d9940 Mapping unknown DC by IP for mobile clients 2026-03-06 02:47:59 +03:00
Flowseal
e57f61a621 unused const 2026-03-05 20:42:29 +03:00
7 changed files with 604 additions and 165 deletions

View File

@@ -27,14 +27,64 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install pyinstaller
run: pip install pyinstaller
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
run: pyinstaller tg_ws_proxy.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: TgWsProxy name: TgWsProxy
path: dist/TgWsProxy.exe path: |
dist/TgWsProxy.exe
build-win7:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.8 (last version supporting Win7)
uses: actions/setup-python@v5
with:
python-version: "3.8"
cache: "pip"
- name: Install dependencies (Win7-compatible)
run: pip install -r requirements-win7.txt
- name: Install pyinstaller
run: pip install "pyinstaller==5.13.2"
- name: Build EXE with PyInstaller (Win7)
run: pyinstaller packaging/windows.spec --noconfirm
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy-win7.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TgWsProxy-win7
path: dist/TgWsProxy-win7.exe
release:
needs: [build, build-win7]
runs-on: ubuntu-latest
steps:
- name: Download main build
uses: actions/download-artifact@v4
with:
name: TgWsProxy
path: dist
- name: Download Win7 build
uses: actions/download-artifact@v4
with:
name: TgWsProxy-win7
path: dist
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -43,8 +93,10 @@ jobs:
name: "TG WS Proxy ${{ github.event.inputs.version }}" name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: | body: |
## TG WS Proxy ${{ github.event.inputs.version }} ## TG WS Proxy ${{ github.event.inputs.version }}
files: dist/TgWsProxy.exe files: |
dist/TgWsProxy.exe
dist/TgWsProxy-win7.exe
draft: false draft: false
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,21 +18,10 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org` 4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org`
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение 5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
## Установка ## 🚀 Быстрый старт
### Из исходников ### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
```bash
pip install -r requirements.txt
```
## Использование
### Tray-приложение (рекомендуется для Windows)
```bash
python tg_ws_tray.py
```
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
@@ -43,10 +32,22 @@ python tg_ws_tray.py
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение - **Выход** — остановить прокси и закрыть приложение
## Установка из исходников
```bash
pip install -r requirements.txt
```
### Windows (Tray-приложение)
```bash
python windows.py
```
### Консольный режим ### Консольный режим
```bash ```bash
python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
``` ```
**Аргументы:** **Аргументы:**
@@ -61,13 +62,13 @@ python tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
```bash ```bash
# Стандартный запуск # Стандартный запуск
python tg_ws_proxy.py python proxy/tg_ws_proxy.py
# Другой порт и дополнительные DC # Другой порт и дополнительные DC
python tg_ws_proxy.py --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
# С подробным логированием # С подробным логированием
python tg_ws_proxy.py -v python proxy/tg_ws_proxy.py -v
``` ```
## Настройка Telegram Desktop ## Настройка Telegram Desktop
@@ -87,7 +88,7 @@ python tg_ws_proxy.py -v
## Конфигурация ## Конфигурация
Tray-приложение хранит конфигурацию в `%APPDATA%/TgWsProxy/config.json`: Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`:
```json ```json
{ {
@@ -100,20 +101,15 @@ Tray-приложение хранит конфигурацию в `%APPDATA%/Tg
} }
``` ```
Логи записываются в `%APPDATA%/TgWsProxy/proxy.log`. ## Автоматическая сборка
## Сборка exe Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
Проект содержит спецификацию PyInstaller ([`tg_ws_proxy.spec`](tg_ws_proxy.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
```bash ```bash
pip install pyinstaller pip install pyinstaller
pyinstaller tg_ws_proxy.spec pyinstaller packaging/windows.spec
``` ```
## Дисклеймер
Проект частично vibecoded by Opus 4.6. Если вы найдете баг, то создайте Issue с его описанем.
## Лицензия ## Лицензия
[MIT License](LICENSE) [MIT License](LICENSE)

View File

@@ -10,12 +10,11 @@ import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
a = Analysis( a = Analysis(
['tg_ws_tray.py'], [os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[(ctk_path, 'customtkinter/')], datas=[(ctk_path, 'customtkinter/')],
hiddenimports=[ hiddenimports=[
'tg_ws_proxy',
'pystray._win32', 'pystray._win32',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
'customtkinter', 'customtkinter',
@@ -34,7 +33,7 @@ a = Analysis(
noarchive=False, noarchive=False,
) )
icon_path = os.path.join(os.path.dirname(SPEC), 'icon.ico') icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
if os.path.exists(icon_path): if os.path.exists(icon_path):
a.datas += [('icon.ico', icon_path, 'DATA')] a.datas += [('icon.ico', icon_path, 'DATA')]

View File

@@ -15,10 +15,14 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
DEFAULT_PORT = 1080 DEFAULT_PORT = 1080
DEFAULT_TARGET_IP = '149.154.167.220' # unthrottled, works for DC2 and DC4
log = logging.getLogger('tg-ws-proxy') log = logging.getLogger('tg-ws-proxy')
_TCP_NODELAY = True
_RECV_BUF = 131072
_SEND_BUF = 131072
_WS_POOL_SIZE = 4
_WS_POOL_MAX_AGE = 120.0
_TG_RANGES = [ _TG_RANGES = [
# 185.76.151.0/24 # 185.76.151.0/24
(struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0], (struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0],
@@ -34,6 +38,36 @@ _TG_RANGES = [
struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]), struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]),
] ]
# IP -> (dc_id, is_media)
_IP_TO_DC: Dict[str, Tuple[int, bool]] = {
# DC1
'149.154.175.50': (1, False), '149.154.175.51': (1, False),
'149.154.175.53': (1, False), '149.154.175.54': (1, False),
'149.154.175.52': (1, True),
# DC2
'149.154.167.41': (2, False), '149.154.167.50': (2, False),
'149.154.167.51': (2, False), '149.154.167.220': (2, False),
'95.161.76.100': (2, False),
'149.154.167.151': (2, True), '149.154.167.222': (2, True),
'149.154.167.223': (2, True), '149.154.162.123': (2, True),
# DC3
'149.154.175.100': (3, False), '149.154.175.101': (3, False),
'149.154.175.102': (3, True),
# DC4
'149.154.167.91': (4, False), '149.154.167.92': (4, False),
'149.154.164.250': (4, True), '149.154.166.120': (4, True),
'149.154.166.121': (4, True), '149.154.167.118': (4, True),
'149.154.165.111': (4, True),
# DC5
'91.108.56.100': (5, False), '91.108.56.101': (5, False),
'91.108.56.116': (5, False), '91.108.56.126': (5, False),
'149.154.171.5': (5, False),
'91.108.56.102': (5, True), '91.108.56.128': (5, True),
'91.108.56.151': (5, True),
# DC203
'91.105.192.100': (203, False),
}
_dc_opt: Dict[int, Optional[str]] = {} _dc_opt: Dict[int, Optional[str]] = {}
# DCs where WS is known to fail (302 redirect) # DCs where WS is known to fail (302 redirect)
@@ -51,6 +85,22 @@ _ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE _ssl_ctx.verify_mode = ssl.CERT_NONE
def _set_sock_opts(transport):
sock = transport.get_extra_info('socket')
if sock is None:
return
if _TCP_NODELAY:
try:
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
except (OSError, AttributeError):
pass
try:
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, _RECV_BUF)
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, _SEND_BUF)
except OSError:
pass
class WsHandshakeError(Exception): class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str, def __init__(self, status_code: int, status_line: str,
headers: dict = None, location: str = None): headers: dict = None, location: str = None):
@@ -68,10 +118,9 @@ class WsHandshakeError(Exception):
def _xor_mask(data: bytes, mask: bytes) -> bytes: def _xor_mask(data: bytes, mask: bytes) -> bytes:
if not data: if not data:
return data return data
a = bytearray(data) n = len(data)
for i in range(len(a)): mask_rep = (mask * (n // 4 + 1))[:n]
a[i] ^= mask[i & 3] return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
return bytes(a)
class RawWebSocket: class RawWebSocket:
@@ -109,6 +158,7 @@ class RawWebSocket:
asyncio.open_connection(ip, 443, ssl=_ssl_ctx, asyncio.open_connection(ip, 443, ssl=_ssl_ctx,
server_hostname=domain), server_hostname=domain),
timeout=min(timeout, 10)) timeout=min(timeout, 10))
_set_sock_opts(writer.transport)
ws_key = base64.b64encode(os.urandom(16)).decode() ws_key = base64.b64encode(os.urandom(16)).decode()
req = ( req = (
@@ -175,6 +225,15 @@ class RawWebSocket:
self.writer.write(frame) self.writer.write(frame)
await self.writer.drain() await self.writer.drain()
async def send_batch(self, parts: List[bytes]):
"""Send multiple binary frames with a single drain (less overhead)."""
if self._closed:
raise ConnectionError("WebSocket closed")
for part in parts:
frame = self._build_frame(self.OP_BINARY, part, mask=True)
self.writer.write(frame)
await self.writer.drain()
async def recv(self) -> Optional[bytes]: async def recv(self) -> Optional[bytes]:
""" """
Receive the next data frame. Handles ping/pong/close Receive the next data frame. Handles ping/pong/close
@@ -325,6 +384,85 @@ def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]:
return None, False return None, False
def _patch_init_dc(data: bytes, dc: int) -> bytes:
"""
Patch dc_id in the 64-byte MTProto init packet.
Mobile clients with useSecret=0 leave bytes 60-61 as random.
The WS relay needs a valid dc_id to route correctly.
"""
if len(data) < 64:
return data
new_dc = struct.pack('<h', dc)
try:
key_raw = bytes(data[8:40])
iv = bytes(data[40:56])
cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv))
enc = cipher.encryptor()
ks = enc.update(b'\x00' * 64) + enc.finalize()
patched = bytearray(data[:64])
patched[60] = ks[60] ^ new_dc[0]
patched[61] = ks[61] ^ new_dc[1]
log.debug("init patched: dc_id -> %d", dc)
if len(data) > 64:
return bytes(patched) + data[64:]
return bytes(patched)
except Exception:
return data
class _MsgSplitter:
"""
Splits client TCP data into individual MTProto abridged-protocol
messages so each can be sent as a separate WebSocket frame.
The Telegram WS relay processes one MTProto message per WS frame.
Mobile clients batches multiple messages in a single TCP write (e.g.
msgs_ack + req_DH_params). If sent as one WS frame, the relay
only processes the first message DH handshake never completes.
"""
def __init__(self, init_data: bytes):
key_raw = bytes(init_data[8:40])
iv = bytes(init_data[40:56])
cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv))
self._dec = cipher.encryptor()
self._dec.update(b'\x00' * 64) # skip init packet
def split(self, chunk: bytes) -> List[bytes]:
"""Decrypt to find message boundaries, return split ciphertext."""
plain = self._dec.update(chunk)
boundaries = []
pos = 0
while pos < len(plain):
first = plain[pos]
if first == 0x7f:
if pos + 4 > len(plain):
break
msg_len = (
struct.unpack_from('<I', plain, pos + 1)[0] & 0xFFFFFF
) * 4
pos += 4
else:
msg_len = first * 4
pos += 1
if msg_len == 0 or pos + msg_len > len(plain):
break
pos += msg_len
boundaries.append(pos)
if len(boundaries) <= 1:
return [chunk]
parts = []
prev = 0
for b in boundaries:
parts.append(chunk[prev:b])
prev = b
if prev < len(chunk):
parts.append(chunk[prev:])
return parts
def _ws_domains(dc: int, is_media) -> List[str]: def _ws_domains(dc: int, is_media) -> List[str]:
""" """
Return domain names to try for WebSocket connection to a DC. Return domain names to try for WebSocket connection to a DC.
@@ -333,9 +471,7 @@ def _ws_domains(dc: int, is_media) -> List[str]:
DC >5: kws{N}[-1].telegram.org DC >5: kws{N}[-1].telegram.org
""" """
base = 'telegram.org' if dc > 5 else 'web.telegram.org' base = 'telegram.org' if dc > 5 else 'web.telegram.org'
if is_media is None: if is_media is None or is_media:
return [f'kws{dc}-1.{base}', f'kws{dc}.{base}']
if is_media:
return [f'kws{dc}-1.{base}', f'kws{dc}.{base}'] return [f'kws{dc}-1.{base}', f'kws{dc}.{base}']
return [f'kws{dc}.{base}', f'kws{dc}-1.{base}'] return [f'kws{dc}.{base}', f'kws{dc}-1.{base}']
@@ -350,6 +486,8 @@ class Stats:
self.ws_errors = 0 self.ws_errors = 0
self.bytes_up = 0 self.bytes_up = 0
self.bytes_down = 0 self.bytes_down = 0
self.pool_hits = 0
self.pool_misses = 0
def summary(self) -> str: def summary(self) -> str:
return (f"total={self.connections_total} ws={self.connections_ws} " return (f"total={self.connections_total} ws={self.connections_ws} "
@@ -357,6 +495,7 @@ class Stats:
f"http_skip={self.connections_http_rejected} " f"http_skip={self.connections_http_rejected} "
f"pass={self.connections_passthrough} " f"pass={self.connections_passthrough} "
f"err={self.ws_errors} " f"err={self.ws_errors} "
f"pool={self.pool_hits}/{self.pool_hits+self.pool_misses} "
f"up={_human_bytes(self.bytes_up)} " f"up={_human_bytes(self.bytes_up)} "
f"down={_human_bytes(self.bytes_down)}") f"down={_human_bytes(self.bytes_down)}")
@@ -364,8 +503,103 @@ class Stats:
_stats = Stats() _stats = Stats()
class _WsPool:
def __init__(self):
self._idle: Dict[Tuple[int, bool], list] = {}
self._refilling: Set[Tuple[int, bool]] = set()
async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str]
) -> Optional[RawWebSocket]:
key = (dc, is_media)
now = time.monotonic()
bucket = self._idle.get(key, [])
while bucket:
ws, created = bucket.pop(0)
age = now - created
if age > _WS_POOL_MAX_AGE or ws._closed:
asyncio.create_task(self._quiet_close(ws))
continue
_stats.pool_hits += 1
log.debug("WS pool hit for DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains)
return ws
_stats.pool_misses += 1
self._schedule_refill(key, target_ip, domains)
return None
def _schedule_refill(self, key, target_ip, domains):
if key in self._refilling:
return
self._refilling.add(key)
asyncio.create_task(self._refill(key, target_ip, domains))
async def _refill(self, key, target_ip, domains):
dc, is_media = key
try:
bucket = self._idle.setdefault(key, [])
needed = _WS_POOL_SIZE - len(bucket)
if needed <= 0:
return
tasks = []
for _ in range(needed):
tasks.append(asyncio.create_task(
self._connect_one(target_ip, domains)))
for t in tasks:
try:
ws = await t
if ws:
bucket.append((ws, time.monotonic()))
except Exception:
pass
log.debug("WS pool refilled DC%d%s: %d ready",
dc, 'm' if is_media else '', len(bucket))
finally:
self._refilling.discard(key)
@staticmethod
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
for domain in domains:
try:
ws = await RawWebSocket.connect(
target_ip, domain, timeout=8)
return ws
except WsHandshakeError as exc:
if exc.is_redirect:
continue
return None
except Exception:
return None
return None
@staticmethod
async def _quiet_close(ws):
try:
await ws.close()
except Exception:
pass
async def warmup(self, dc_opt: Dict[int, Optional[str]]):
"""Pre-fill pool for all configured DCs on startup."""
for dc, target_ip in dc_opt.items():
if target_ip is None:
continue
for is_media in (False, True):
domains = _ws_domains(dc, is_media)
key = (dc, is_media)
self._schedule_refill(key, target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(dc_opt))
_ws_pool = _WsPool()
async def _bridge_ws(reader, writer, ws: RawWebSocket, label, async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
dc=None, dst=None, port=None, is_media=False): dc=None, dst=None, port=None, is_media=False,
splitter: _MsgSplitter = None):
"""Bidirectional TCP <-> WebSocket forwarding.""" """Bidirectional TCP <-> WebSocket forwarding."""
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?" dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
dst_tag = f"{dst}:{port}" if dst else "?" dst_tag = f"{dst}:{port}" if dst else "?"
@@ -380,13 +614,20 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
nonlocal up_bytes, up_packets nonlocal up_bytes, up_packets
try: try:
while True: while True:
chunk = await reader.read(65536) chunk = await reader.read(131072)
if not chunk: if not chunk:
break break
_stats.bytes_up += len(chunk) _stats.bytes_up += len(chunk)
up_bytes += len(chunk) up_bytes += len(chunk)
up_packets += 1 up_packets += 1
await ws.send(chunk) if splitter:
parts = splitter.split(chunk)
if len(parts) > 1:
await ws.send_batch(parts)
else:
await ws.send(parts[0])
else:
await ws.send(chunk)
except (asyncio.CancelledError, ConnectionError, OSError): except (asyncio.CancelledError, ConnectionError, OSError):
return return
except Exception as e: except Exception as e:
@@ -403,7 +644,10 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label,
down_bytes += len(data) down_bytes += len(data)
down_packets += 1 down_packets += 1
writer.write(data) writer.write(data)
await writer.drain() # drain only when kernel buffer is filling up
buf = writer.transport.get_write_buffer_size()
if buf > _SEND_BUF:
await writer.drain()
except (asyncio.CancelledError, ConnectionError, OSError): except (asyncio.CancelledError, ConnectionError, OSError):
return return
except Exception as e: except Exception as e:
@@ -534,6 +778,8 @@ async def _handle_client(reader, writer):
peer = writer.get_extra_info('peername') peer = writer.get_extra_info('peername')
label = f"{peer[0]}:{peer[1]}" if peer else "?" label = f"{peer[0]}:{peer[1]}" if peer else "?"
_set_sock_opts(writer.transport)
try: try:
# -- SOCKS5 greeting -- # -- SOCKS5 greeting --
hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10) hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10)
@@ -572,6 +818,17 @@ async def _handle_client(reader, writer):
port = struct.unpack('!H', await reader.readexactly(2))[0] port = struct.unpack('!H', await reader.readexactly(2))[0]
if ':' in dst:
log.error(
"[%s] IPv6 address detected: %s:%d"
"IPv6 doesn't supported "
"Disable IPv6 to continue using the proxy.",
label, dst, port)
writer.write(_socks5_reply(0x05))
await writer.drain()
writer.close()
return
# -- Non-Telegram IP -> direct passthrough -- # -- Non-Telegram IP -> direct passthrough --
if not _is_telegram_ip(dst): if not _is_telegram_ip(dst):
_stats.connections_passthrough += 1 _stats.connections_passthrough += 1
@@ -580,7 +837,7 @@ async def _handle_client(reader, writer):
rr, rw = await asyncio.wait_for( rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10) asyncio.open_connection(dst, port), timeout=10)
except Exception as exc: except Exception as exc:
log.warning("[%s] passthrough failed: %s", label, exc) log.warning("[%s] passthrough failed to %s: %s: %s", label, dst, type(exc).__name__, str(exc) or "(no message)")
writer.write(_socks5_reply(0x05)) writer.write(_socks5_reply(0x05))
await writer.drain() await writer.drain()
writer.close() writer.close()
@@ -623,6 +880,15 @@ async def _handle_client(reader, writer):
# -- Extract DC ID -- # -- Extract DC ID --
dc, is_media = _dc_from_init(init) dc, is_media = _dc_from_init(init)
init_patched = False
# Android (may be ios too) with useSecret=0 has random dc_id bytes — patch it
if dc is None and dst in _IP_TO_DC:
dc, is_media = _IP_TO_DC.get(dst)
if dc in _dc_opt:
init = _patch_init_dc(init, dc if is_media else -dc)
init_patched = True
if dc is None or dc not in _dc_opt: if dc is None or dc not in _dc_opt:
log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough", log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough",
label, dc, dst, port) label, dc, dst, port)
@@ -665,39 +931,44 @@ async def _handle_client(reader, writer):
ws_failed_redirect = False ws_failed_redirect = False
all_redirects = True all_redirects = True
for domain in domains: ws = await _ws_pool.get(dc, is_media, target, domains)
url = f'wss://{domain}/apiws' if ws:
log.info("[%s] DC%d%s (%s:%d) -> %s via %s", log.info("[%s] DC%d%s (%s:%d) -> pool hit via %s",
label, dc, media_tag, dst, port, url, target) label, dc, media_tag, dst, port, target)
try: else:
ws = await RawWebSocket.connect(target, domain, for domain in domains:
timeout=10) url = f'wss://{domain}/apiws'
all_redirects = False log.info("[%s] DC%d%s (%s:%d) -> %s via %s",
break label, dc, media_tag, dst, port, url, target)
except WsHandshakeError as exc: try:
_stats.ws_errors += 1 ws = await RawWebSocket.connect(target, domain,
if exc.is_redirect: timeout=10)
ws_failed_redirect = True
log.warning("[%s] DC%d%s got %d from %s -> %s",
label, dc, media_tag,
exc.status_code, domain,
exc.location or '?')
continue
else:
all_redirects = False all_redirects = False
log.warning("[%s] DC%d%s WS handshake: %s", break
label, dc, media_tag, exc.status_line) except WsHandshakeError as exc:
except Exception as exc: _stats.ws_errors += 1
_stats.ws_errors += 1 if exc.is_redirect:
all_redirects = False ws_failed_redirect = True
err_str = str(exc) log.warning("[%s] DC%d%s got %d from %s -> %s",
if ('CERTIFICATE_VERIFY_FAILED' in err_str or label, dc, media_tag,
'Hostname mismatch' in err_str): exc.status_code, domain,
log.warning("[%s] DC%d%s SSL error: %s", exc.location or '?')
label, dc, media_tag, exc) continue
else: else:
log.warning("[%s] DC%d%s WS connect failed: %s", all_redirects = False
label, dc, media_tag, exc) log.warning("[%s] DC%d%s WS handshake: %s",
label, dc, media_tag, exc.status_line)
except Exception as exc:
_stats.ws_errors += 1
all_redirects = False
err_str = str(exc)
if ('CERTIFICATE_VERIFY_FAILED' in err_str or
'Hostname mismatch' in err_str):
log.warning("[%s] DC%d%s SSL error: %s",
label, dc, media_tag, exc)
else:
log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, exc)
# -- WS failed -> fallback -- # -- WS failed -> fallback --
if ws is None: if ws is None:
@@ -726,12 +997,20 @@ async def _handle_client(reader, writer):
_dc_fail_until.pop(dc_key, None) _dc_fail_until.pop(dc_key, None)
_stats.connections_ws += 1 _stats.connections_ws += 1
splitter = None
if init_patched:
try:
splitter = _MsgSplitter(init)
except Exception:
pass
# Send the buffered init packet # Send the buffered init packet
await ws.send(init) await ws.send(init)
# Bidirectional bridge # Bidirectional bridge
await _bridge_ws(reader, writer, ws, label, await _bridge_ws(reader, writer, ws, label,
dc=dc, dst=dst, port=port, is_media=is_media) dc=dc, dst=dst, port=port, is_media=is_media,
splitter=splitter)
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warning("[%s] timeout during SOCKS5 handshake", label) log.warning("[%s] timeout during SOCKS5 handshake", label)
@@ -755,25 +1034,32 @@ _server_stop_event = None
async def _run(port: int, dc_opt: Dict[int, Optional[str]], async def _run(port: int, dc_opt: Dict[int, Optional[str]],
stop_event: Optional[asyncio.Event] = None): stop_event: Optional[asyncio.Event] = None,
host: str = '127.0.0.1'):
global _dc_opt, _server_instance, _server_stop_event global _dc_opt, _server_instance, _server_stop_event
_dc_opt = dc_opt _dc_opt = dc_opt
_server_stop_event = stop_event _server_stop_event = stop_event
server = await asyncio.start_server( server = await asyncio.start_server(
_handle_client, '127.0.0.1', port) _handle_client, host, port)
_server_instance = server _server_instance = server
for sock in server.sockets:
try:
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
except (OSError, AttributeError):
pass
log.info("=" * 60) log.info("=" * 60)
log.info(" Telegram WS Bridge Proxy") log.info(" Telegram WS Bridge Proxy")
log.info(" Listening on 127.0.0.1:%d", port) log.info(" Listening on %s:%d", host, port)
log.info(" Target DC IPs:") log.info(" Target DC IPs:")
for dc in dc_opt.keys(): for dc in dc_opt.keys():
ip = dc_opt.get(dc) ip = dc_opt.get(dc)
log.info(" DC%d: %s", dc, ip) log.info(" DC%d: %s", dc, ip)
log.info("=" * 60) log.info("=" * 60)
log.info(" Configure Telegram Desktop:") log.info(" Configure Telegram Desktop:")
log.info(" SOCKS5 proxy -> 127.0.0.1:%d (no user/pass)", port) log.info(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port)
log.info("=" * 60) log.info("=" * 60)
async def log_stats(): async def log_stats():
@@ -786,6 +1072,8 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]],
asyncio.create_task(log_stats()) asyncio.create_task(log_stats())
await _ws_pool.warmup(dc_opt)
if stop_event: if stop_event:
async def wait_stop(): async def wait_stop():
await stop_event.wait() await stop_event.wait()
@@ -825,9 +1113,10 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
def run_proxy(port: int, dc_opt: Dict[int, str], def run_proxy(port: int, dc_opt: Dict[int, str],
stop_event: Optional[asyncio.Event] = None): stop_event: Optional[asyncio.Event] = None,
host: str = '127.0.0.1'):
"""Run the proxy (blocking). Can be called from threads.""" """Run the proxy (blocking). Can be called from threads."""
asyncio.run(_run(port, dc_opt, stop_event)) asyncio.run(_run(port, dc_opt, stop_event, host))
def main(): def main():
@@ -835,6 +1124,8 @@ def main():
description='Telegram Desktop WebSocket Bridge Proxy') description='Telegram Desktop WebSocket Bridge Proxy')
ap.add_argument('--port', type=int, default=DEFAULT_PORT, ap.add_argument('--port', type=int, default=DEFAULT_PORT,
help=f'Listen port (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('--dc-ip', metavar='DC:IP', action='append', ap.add_argument('--dc-ip', metavar='DC:IP', action='append',
default=['2:149.154.167.220', '4:149.154.167.220'], 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' help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205'
@@ -856,7 +1147,7 @@ def main():
) )
try: try:
asyncio.run(_run(args.port, dc_opt)) asyncio.run(_run(args.port, dc_opt, host=args.host))
except KeyboardInterrupt: except KeyboardInterrupt:
log.info("Shutting down. Final stats: %s", _stats.summary()) log.info("Shutting down. Final stats: %s", _stats.summary())

6
requirements-win7.txt Normal file
View File

@@ -0,0 +1,6 @@
cryptography==41.0.7
customtkinter==5.2.2
Pillow==10.4.0
psutil==5.9.8
pystray==0.19.5
pyperclip==1.9.0

View File

@@ -1,6 +1,6 @@
cryptography cryptography==46.0.5
pystray customtkinter==5.2.2
Pillow Pillow==12.1.1
customtkinter psutil==7.0.0
pyinstaller pystray==0.19.5
psutil pyperclip==1.9.0

View File

@@ -9,27 +9,15 @@ import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import pystray
import pyperclip
import asyncio as _asyncio import asyncio as _asyncio
import customtkinter as ctk
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, Optional
from PIL import Image, ImageDraw, ImageFont
try: import proxy.tg_ws_proxy as tg_ws_proxy
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = ImageDraw = ImageFont = None # type: ignore
try:
import pystray
except ImportError:
pystray = None # type: ignore
try:
import customtkinter as ctk
except ImportError:
ctk = None # type: ignore
# Proxy engine
import tg_ws_proxy
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
@@ -37,32 +25,98 @@ APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done" FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"port": 1080, "port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False, "verbose": False,
} }
_proxy_thread: Optional[threading.Thread] = None _proxy_thread: Optional[threading.Thread] = None
_stop_event: Optional[threading.Event] = None
_async_stop: Optional[object] = None _async_stop: Optional[object] = None
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
def is_already_running(): def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
current_proc = os.path.basename(sys.argv[0]) try:
count = 0 lock_ct = float(lock_meta.get("create_time", 0.0))
for process in psutil.process_iter(['name']): proc_ct = float(proc.create_time())
if process.info['name'] == current_proc: if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
count += 1 return False
return count > 2 except Exception:
return False
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return os.path.basename(sys.executable) == proc.name()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs(): def _ensure_dirs():
@@ -75,7 +129,6 @@ def load_config() -> dict:
try: try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f: with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
# Merge with defaults for missing keys
for k, v in DEFAULT_CONFIG.items(): for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v) data.setdefault(k, v)
return data return data
@@ -112,18 +165,15 @@ def setup_logging(verbose: bool = False):
def _make_icon_image(size: int = 64): def _make_icon_image(size: int = 64):
"""Create a simple tray icon: blue circle with a white 'T' letter."""
if Image is None: if Image is None:
raise RuntimeError("Pillow is required for tray icon") raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Blue circle
margin = 2 margin = 2
draw.ellipse([margin, margin, size - margin, size - margin], draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 136, 204, 255)) fill=(0, 136, 204, 255))
# White "T"
try: try:
font = ImageFont.truetype("arial.ttf", size=int(size * 0.55)) font = ImageFont.truetype("arial.ttf", size=int(size * 0.55))
except Exception: except Exception:
@@ -138,7 +188,6 @@ def _make_icon_image(size: int = 64):
def _load_icon(): def _load_icon():
"""Load icon from file or generate one."""
icon_path = Path(__file__).parent / "icon.ico" icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image: if icon_path.exists() and Image:
try: try:
@@ -149,8 +198,8 @@ def _load_icon():
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool): def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
"""Target for the proxy thread — runs asyncio event loop.""" host: str = '127.0.0.1'):
global _async_stop global _async_stop
loop = _asyncio.new_event_loop() loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop) _asyncio.set_event_loop(loop)
@@ -159,9 +208,11 @@ def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool):
try: try:
loop.run_until_complete( loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev)) tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
if "10048" in str(exc) or "Address already in use" in str(exc):
_show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.")
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -175,6 +226,7 @@ def start_proxy():
cfg = _config cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"]) port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False) verbose = cfg.get("verbose", False)
@@ -185,10 +237,10 @@ def start_proxy():
_show_error(f"Ошибка конфигурации:\n{e}") _show_error(f"Ошибка конфигурации:\n{e}")
return return
log.info("Starting proxy on port %d ...", port) log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread( _proxy_thread = threading.Thread(
target=_run_proxy_thread, target=_run_proxy_thread,
args=(port, dc_opt, verbose), args=(port, dc_opt, verbose, host),
daemon=True, name="proxy") daemon=True, name="proxy")
_proxy_thread.start() _proxy_thread.start()
@@ -230,7 +282,7 @@ def _on_open_in_telegram(icon=None, item=None):
except Exception: except Exception:
log.info("Browser open failed, copying to clipboard") log.info("Browser open failed, copying to clipboard")
try: try:
_copy_to_clipboard(url) pyperclip.copy(url)
_show_info( _show_info(
f"Не удалось открыть Telegram автоматически.\n\n" f"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в телеграмм и нажмите по ней ЛКМ:\n{url}", f"Ссылка скопирована в буфер обмена, отправьте её в телеграмм и нажмите по ней ЛКМ:\n{url}",
@@ -240,31 +292,11 @@ def _on_open_in_telegram(icon=None, item=None):
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _copy_to_clipboard(text: str):
"""Copy text to Windows clipboard using ctypes."""
import ctypes.wintypes
CF_UNICODETEXT = 13
kernel32 = ctypes.windll.kernel32
user32 = ctypes.windll.user32
user32.OpenClipboard(0)
user32.EmptyClipboard()
encoded = text.encode("utf-16-le") + b"\x00\x00"
h = kernel32.GlobalAlloc(0x0042, len(encoded)) # GMEM_MOVEABLE | GMEM_ZEROINIT
p = kernel32.GlobalLock(h)
ctypes.memmove(p, encoded, len(encoded))
kernel32.GlobalUnlock(h)
user32.SetClipboardData(CF_UNICODETEXT, h)
user32.CloseClipboard()
def _on_restart(icon=None, item=None): def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start() threading.Thread(target=restart_proxy, daemon=True).start()
def _on_edit_config(icon=None, item=None): def _on_edit_config(icon=None, item=None):
"""Open a simple dialog to edit config."""
threading.Thread(target=_edit_config_dialog, daemon=True).start() threading.Thread(target=_edit_config_dialog, daemon=True).start()
@@ -292,7 +324,7 @@ def _edit_config_dialog():
TEXT_SECONDARY = "#707579" TEXT_SECONDARY = "#707579"
FONT_FAMILY = "Segoe UI" FONT_FAMILY = "Segoe UI"
w, h = 420, 400 w, h = 420, 480
sw = root.winfo_screenwidth() sw = root.winfo_screenwidth()
sh = root.winfo_screenheight() sh = root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
@@ -301,6 +333,17 @@ def _edit_config_dialog():
frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0)
frame.pack(fill="both", expand=True, padx=24, pady=20) frame.pack(fill="both", expand=True, padx=24, pady=20)
# Host
ctk.CTkLabel(frame, text="IP-адрес прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
anchor="w").pack(anchor="w", pady=(0, 4))
host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1"))
host_entry = ctk.CTkEntry(frame, textvariable=host_var, width=200, height=36,
font=(FONT_FAMILY, 13), corner_radius=10,
fg_color=FIELD_BG, border_color=FIELD_BORDER,
border_width=1, text_color=TEXT_PRIMARY)
host_entry.pack(anchor="w", pady=(0, 12))
# Port # Port
ctk.CTkLabel(frame, text="Порт прокси", ctk.CTkLabel(frame, text="Порт прокси",
font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY,
@@ -338,6 +381,14 @@ def _edit_config_dialog():
anchor="w").pack(anchor="w", pady=(0, 16)) anchor="w").pack(anchor="w", pady=(0, 16))
def on_save(): def on_save():
import socket as _sock
host_val = host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
_show_error("Некорректный IP-адрес.")
return
try: try:
port_val = int(port_var.get().strip()) port_val = int(port_var.get().strip())
if not (1 <= port_val <= 65535): if not (1 <= port_val <= 65535):
@@ -355,6 +406,7 @@ def _edit_config_dialog():
return return
new_cfg = { new_cfg = {
"host": host_val,
"port": port_val, "port": port_val,
"dc_ip": lines, "dc_ip": lines,
"verbose": verbose_var.get(), "verbose": verbose_var.get(),
@@ -363,6 +415,8 @@ def _edit_config_dialog():
_config.update(new_cfg) _config.update(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox from tkinter import messagebox
if messagebox.askyesno("Перезапустить?", if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n" "Настройки сохранены.\n\n"
@@ -424,8 +478,9 @@ def _show_first_run():
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server=127.0.0.1&port={port}" tg_url = f"tg://socks?server={host}&port={port}"
if ctk is None: if ctk is None:
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
@@ -477,7 +532,7 @@ def _show_first_run():
(f" Или ссылка: {tg_url}", False), (f" Или ссылка: {tg_url}", False),
("\n Вручную:", True), ("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False), (" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → 127.0.0.1 : {port} (без логина/пароля)", False), (f" SOCKS5 → {host} : {port} (без логина/пароля)", False),
] ]
for text, bold in sections: for text, bold in sections:
@@ -520,13 +575,57 @@ def _show_first_run():
root.mainloop() root.mainloop()
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включён IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах видны ошибки связанные с IPv6, "
"то проверьте в настройках Telegram, что рядом с настройкой прокси не включён "
"пунукт про IPv6. Если это не поможет, то выключите IPv6 в системе\n\n"
"Это предупрждение будет показано только один раз.",
"TG WS Proxy")
def _build_menu(): def _build_menu():
if pystray is None: if pystray is None:
return None return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( pystray.MenuItem(
f"Открыть в Telegram (:{port})", f"Открыть в Telegram ({host}:{port})",
_on_open_in_telegram, _on_open_in_telegram,
default=True), default=True),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
@@ -569,6 +668,7 @@ def run_tray():
start_proxy() start_proxy()
_show_first_run() _show_first_run()
_check_ipv6_warning()
icon_image = _load_icon() icon_image = _load_icon()
_tray_icon = pystray.Icon( _tray_icon = pystray.Icon(
@@ -585,19 +685,14 @@ def run_tray():
def main(): def main():
if is_already_running(): if not _acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
# Hide console window if running as frozen exe try:
if getattr(sys, "frozen", False): run_tray()
try: finally:
ctypes.windll.user32.ShowWindow( _release_lock()
ctypes.windll.kernel32.GetConsoleWindow(), 0)
except Exception:
pass
run_tray()
if __name__ == "__main__": if __name__ == "__main__":