fix(proxy): RFC-compliant IPv6 reply, __slots__, parallel pool refill, graceful shutdown + cross-platform CLI

This commit is contained in:
lifeofcapo 2026-03-17 00:19:34 +03:00
parent cf3e3b2aec
commit bef6a4fd3e
5 changed files with 107 additions and 14 deletions

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.tg-ws-proxy</string>
<key>ProgramArguments</key>
<array>
<string>tg-ws-proxy</string>
<string>--host</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>1080</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/tg-ws-proxy.log</string>
<key>StandardErrorPath</key>
<string>/tmp/tg-ws-proxy.log</string>
</dict>
</plist>

View File

@ -0,0 +1,12 @@
[Unit]
Description=Telegram WebSocket Bridge Proxy
After=network.target
[Service]
Type=simple
ExecStart=tg-ws-proxy --host 127.0.0.1 --port 1080
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

View File

@ -467,7 +467,14 @@ def _ws_domains(dc: int, is_media) -> List[str]:
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
# __slots__ added to Stats ---
class Stats: class Stats:
__slots__ = (
'connections_total', 'connections_ws', 'connections_tcp_fallback',
'connections_http_rejected', 'connections_passthrough', 'ws_errors',
'bytes_up', 'bytes_down', 'pool_hits', 'pool_misses',
)
def __init__(self): def __init__(self):
self.connections_total = 0 self.connections_total = 0
self.connections_ws = 0 self.connections_ws = 0
@ -494,7 +501,11 @@ class Stats:
_stats = Stats() _stats = Stats()
# __slots__ added to _WsPool;
class _WsPool: class _WsPool:
__slots__ = ('_idle', '_refilling')
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, bool], list] = {} self._idle: Dict[Tuple[int, bool], list] = {}
self._refilling: Set[Tuple[int, bool]] = set() self._refilling: Set[Tuple[int, bool]] = set()
@ -535,17 +546,14 @@ class _WsPool:
needed = _WS_POOL_SIZE - len(bucket) needed = _WS_POOL_SIZE - len(bucket)
if needed <= 0: if needed <= 0:
return return
tasks = [] # using gather for true parallel connections ---
for _ in range(needed): results = await asyncio.gather(
tasks.append(asyncio.create_task( *[self._connect_one(target_ip, domains) for _ in range(needed)],
self._connect_one(target_ip, domains))) return_exceptions=True,
for t in tasks: )
try: for result in results:
ws = await t if isinstance(result, RawWebSocket):
if ws: bucket.append((result, time.monotonic()))
bucket.append((ws, time.monotonic()))
except Exception:
pass
log.debug("WS pool refilled DC%d%s: %d ready", log.debug("WS pool refilled DC%d%s: %d ready",
dc, 'm' if is_media else '', len(bucket)) dc, 'm' if is_media else '', len(bucket))
finally: finally:
@ -584,6 +592,21 @@ class _WsPool:
self._schedule_refill(key, target_ip, domains) self._schedule_refill(key, target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(dc_opt)) log.info("WS pool warmup started for %d DC(s)", len(dc_opt))
async def close(self):
"""Close all idle WebSocket connections in the pool."""
all_ws = [
ws
for bucket in self._idle.values()
for ws, _ in bucket
]
self._idle.clear()
if all_ws:
log.debug("WS pool closing %d idle connection(s)", len(all_ws))
await asyncio.gather(
*[self._quiet_close(ws) for ws in all_ws],
return_exceptions=True,
)
_ws_pool = _WsPool() _ws_pool = _WsPool()
@ -815,7 +838,7 @@ async def _handle_client(reader, writer):
"IPv6 addresses are not supported; " "IPv6 addresses are not supported; "
"disable IPv6 to continue using the proxy.", "disable IPv6 to continue using the proxy.",
label, dst, port) label, dst, port)
writer.write(_socks5_reply(0x05)) writer.write(_socks5_reply(0x08))
await writer.drain() await writer.drain()
writer.close() writer.close()
return return
@ -1069,6 +1092,7 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]],
async def wait_stop(): async def wait_stop():
await stop_event.wait() await stop_event.wait()
server.close() server.close()
await _ws_pool.close()
me = asyncio.current_task() me = asyncio.current_task()
for task in list(asyncio.all_tasks()): for task in list(asyncio.all_tasks()):
if task is not me: if task is not me:

31
pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "tg-ws-proxy"
version = "1.1.0"
description = "Local SOCKS5 proxy for bypassing partial Telegram blocking"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = [
"cryptography>=41.0",
]
[project.optional-dependencies]
windows = [
"cryptography>=41.0",
"customtkinter>=5.2.2",
"Pillow>=10.4.0",
"psutil>=5.9.8",
"pystray>=0.19.5",
"pyperclip>=1.9.0",
]
[project.scripts]
tg-ws-proxy = "proxy.tg_ws_proxy:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["proxy*"]

1
requirements-core.txt Normal file
View File

@ -0,0 +1 @@
cryptography>=41.0.7