From bef6a4fd3e637a323c24322752f30f42618950e2 Mon Sep 17 00:00:00 2001 From: lifeofcapo Date: Tue, 17 Mar 2026 00:19:34 +0300 Subject: [PATCH] fix(proxy): RFC-compliant IPv6 reply, __slots__, parallel pool refill, graceful shutdown + cross-platform CLI --- packaging/com.tg-ws-proxy.plist | 25 ++++++++++++++++ packaging/tg-ws-proxy.service | 12 ++++++++ proxy/tg_ws_proxy.py | 52 ++++++++++++++++++++++++--------- pyproject.toml | 31 ++++++++++++++++++++ requirements-core.txt | 1 + 5 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 packaging/com.tg-ws-proxy.plist create mode 100644 packaging/tg-ws-proxy.service create mode 100644 pyproject.toml create mode 100644 requirements-core.txt diff --git a/packaging/com.tg-ws-proxy.plist b/packaging/com.tg-ws-proxy.plist new file mode 100644 index 0000000..cc68685 --- /dev/null +++ b/packaging/com.tg-ws-proxy.plist @@ -0,0 +1,25 @@ + + + + + Label + com.tg-ws-proxy + ProgramArguments + + tg-ws-proxy + --host + 127.0.0.1 + --port + 1080 + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/tg-ws-proxy.log + StandardErrorPath + /tmp/tg-ws-proxy.log + + \ No newline at end of file diff --git a/packaging/tg-ws-proxy.service b/packaging/tg-ws-proxy.service new file mode 100644 index 0000000..cc5b12d --- /dev/null +++ b/packaging/tg-ws-proxy.service @@ -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 \ No newline at end of file diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 7912fd8..9ebe1f9 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -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'] +# __slots__ added to 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): self.connections_total = 0 self.connections_ws = 0 @@ -494,7 +501,11 @@ class Stats: _stats = Stats() +# __slots__ added to _WsPool; + class _WsPool: + __slots__ = ('_idle', '_refilling') + def __init__(self): self._idle: Dict[Tuple[int, bool], list] = {} self._refilling: Set[Tuple[int, bool]] = set() @@ -535,17 +546,14 @@ class _WsPool: 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 + # using gather for true parallel connections --- + results = await asyncio.gather( + *[self._connect_one(target_ip, domains) for _ in range(needed)], + return_exceptions=True, + ) + for result in results: + if isinstance(result, RawWebSocket): + bucket.append((result, time.monotonic())) log.debug("WS pool refilled DC%d%s: %d ready", dc, 'm' if is_media else '', len(bucket)) finally: @@ -584,6 +592,21 @@ class _WsPool: self._schedule_refill(key, target_ip, domains) 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() @@ -815,7 +838,7 @@ async def _handle_client(reader, writer): "IPv6 addresses are not supported; " "disable IPv6 to continue using the proxy.", label, dst, port) - writer.write(_socks5_reply(0x05)) + writer.write(_socks5_reply(0x08)) await writer.drain() writer.close() return @@ -872,7 +895,7 @@ async def _handle_client(reader, writer): # -- Extract DC ID -- 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) @@ -1069,6 +1092,7 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], async def wait_stop(): await stop_event.wait() server.close() + await _ws_pool.close() me = asyncio.current_task() for task in list(asyncio.all_tasks()): if task is not me: @@ -1144,4 +1168,4 @@ def main(): if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3cd566a --- /dev/null +++ b/pyproject.toml @@ -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*"] \ No newline at end of file diff --git a/requirements-core.txt b/requirements-core.txt new file mode 100644 index 0000000..d3f1e94 --- /dev/null +++ b/requirements-core.txt @@ -0,0 +1 @@ +cryptography>=41.0.7 \ No newline at end of file