From c3309ed11b2f20b632adca04d6309666cdb7097b Mon Sep 17 00:00:00 2001 From: Danil Date: Tue, 7 Jul 2026 20:56:24 +0200 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20Telegram=20(=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20DC)=20(#1087)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/TestDc.md | 23 +++++++++++++++++++++++ docs/TrayConfig.md | 1 + linux.py | 2 ++ macos.py | 1 + proxy/bridge.py | 15 ++++++++------- proxy/config.py | 1 + proxy/pool.py | 2 +- proxy/tg_ws_proxy.py | 31 ++++++++++++++++++++++--------- proxy/utils.py | 9 +++++++++ utils/default_config.py | 1 + utils/tray_common.py | 1 + windows.py | 2 ++ 13 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 docs/TestDc.md diff --git a/docs/README.md b/docs/README.md index b3c84d3..d1b0f8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ - **[Docker](./README.docker.md)** - [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md) - [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md) +- [Тестовое окружение Telegram (тестовые DC)](./TestDc.md) - [Fake TLS + upstream в Nginx](./FakeTlsNginx.md) - [Файлы конфигурации Tray-приложения](./TrayConfig.md) - [Установка из исходников](./BuildFromSource.md) diff --git a/docs/TestDc.md b/docs/TestDc.md new file mode 100644 index 0000000..be39b21 --- /dev/null +++ b/docs/TestDc.md @@ -0,0 +1,23 @@ +# Тестовое окружение Telegram (тестовые DC) + +Маршрутизация трафика к тестовым дата-центрам Telegram (test environment). +Нужна для разработки/тестирования ботов и клиентов в тестовой среде Telegram. + +## Как включается + +**Автоматически.** Telegram Desktop помечает тестовые DC сдвигом +10000 +(10001–10003). Прокси распознаёт сдвиг сам — включать ничего не нужно, можно +одновременно пользоваться продовым и тестовым аккаунтом в одном клиенте. + +**Принудительно.** Для клиентов, которые сообщают тестовые DC как обычные 1-3 +(Telethon, TDLib) — распознать их автоматически нельзя. Тогда весь трафик +принудительно направляется на тестовые DC (продовые аккаунты через этот прокси +работать перестанут). Для принудительной работы используйте флаг `--force-test-dc` в CLI: + +```bash +tg-ws-proxy --force-test-dc # + ваши --secret / --port +``` + +## Ограничения + +Работает только для маршрутов **прямой DC → IP** и **Cloudflare Worker** (см. [Настройка Cloudflare Worker'а](./CfWorker.md)). diff --git a/docs/TrayConfig.md b/docs/TrayConfig.md index d9c2947..89ca46d 100644 --- a/docs/TrayConfig.md +++ b/docs/TrayConfig.md @@ -23,6 +23,7 @@ Tray-приложение хранит данные в: "cfproxy": true, "cfproxy_user_domain": "", "cfproxy_worker_domain": "", + "force_test_dc": false, "appearance": "auto" } ``` diff --git a/linux.py b/linux.py index 46b4d83..09f4035 100644 --- a/linux.py +++ b/linux.py @@ -188,6 +188,8 @@ def _edit_config_dialog() -> None: messagebox.showerror(t("app.error_title"), merged, parent=root) return + merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) + _ui_only_keys = {"appearance", "check_updates", "language"} config_changed = any(merged.get(k) != _config.get(k) for k in merged) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys) diff --git a/macos.py b/macos.py index 45950cb..2f8dea4 100644 --- a/macos.py +++ b/macos.py @@ -475,6 +475,7 @@ def _edit_config_dialog() -> None: "cfproxy": cfproxy, "cfproxy_user_domain": cfproxy_domains, "cfproxy_worker_domain": cfworker_domains, + "force_test_dc": cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]), } save_config(new_cfg) log.info("Config saved: %s", new_cfg) diff --git a/proxy/bridge.py b/proxy/bridge.py index 7452df6..7c439cb 100644 --- a/proxy/bridge.py +++ b/proxy/bridge.py @@ -130,10 +130,11 @@ class MsgSplitter: async def do_fallback(reader, writer, relay_init, label, - dc: int, is_media: bool, media_tag: str, + dc: int, is_test_dc: bool, is_media: bool, media_tag: str, ctx: CryptoCtx, splitter=None): - fallback_dst = DC_DEFAULT_IPS.get(dc) - use_cf = proxy_config.fallback_cfproxy + ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS + fallback_dst = ip_table.get(dc) + use_cf = proxy_config.fallback_cfproxy and not is_test_dc worker_domains = proxy_config.cfproxy_worker_domains methods: List[str] = [] @@ -149,8 +150,8 @@ async def do_fallback(reader, writer, relay_init, label, if method == 'cf_worker' and fallback_dst: ok = await _cfproxy_worker_fallback( reader, writer, relay_init, label, ctx, - dc=dc, is_media=is_media, fallback_dst=fallback_dst, - splitter=splitter) + dc=dc, is_test_dc=is_test_dc, is_media=is_media, + fallback_dst=fallback_dst, splitter=splitter) if ok: return True elif method == 'cf': @@ -173,7 +174,7 @@ async def do_fallback(reader, writer, relay_init, label, async def _cfproxy_worker_fallback(reader, writer, relay_init, label, ctx: CryptoCtx, - dc: int, is_media: bool, + dc: int, is_test_dc: bool, is_media: bool, fallback_dst: str, splitter=None): media_tag = ' media' if is_media else '' @@ -184,7 +185,7 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label, random.shuffle(worker_domains) for worker_domain in worker_domains: - ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst) + ws = None if is_test_dc else await cf_worker_pool.get(dc, worker_domain, fallback_dst) if ws: log.info("[%s] DC%d%s -> CF worker pool hit for %s", label, dc, media_tag, fallback_dst) diff --git a/proxy/config.py b/proxy/config.py index e8c0680..e9f0058 100644 --- a/proxy/config.py +++ b/proxy/config.py @@ -72,6 +72,7 @@ class ProxyConfig: cfproxy_worker_domains: List[str] = field(default_factory=list) fake_tls_domain: str = '' proxy_protocol: bool = False + force_test_dc: bool = False proxy_config = ProxyConfig() diff --git a/proxy/pool.py b/proxy/pool.py index 46abd11..7a6ef56 100644 --- a/proxy/pool.py +++ b/proxy/pool.py @@ -213,4 +213,4 @@ class _CfWorkerPool: ws_pool = _WsPool() -cf_worker_pool = _CfWorkerPool() \ No newline at end of file +cf_worker_pool = _CfWorkerPool() diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index e0c5a49..59c9b4d 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -280,6 +280,11 @@ async def _handle_client(reader, writer, secret: bytes): dc, is_media, proto_tag, client_dec_prekey_iv = result + is_test_dc = proxy_config.force_test_dc or dc >= 10000 + if dc >= 10000: + log.info("[%s] test DC%d -> DC%d", label, dc, dc - 10000) + dc -= 10000 + if proto_tag == PROTO_TAG_ABRIDGED: proto_int = PROTO_ABRIDGED_INT elif proto_tag == PROTO_TAG_INTERMEDIATE: @@ -295,14 +300,15 @@ async def _handle_client(reader, writer, secret: bytes): relay_init = _generate_relay_init(proto_tag, dc_idx) ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init) - dc_key = f'{dc}{"m" if is_media else ""}' + dc_key = f'{dc}{"t" if is_test_dc else ""}{"m" if is_media else ""}' media_tag = " media" if is_media else "" now = time.monotonic() + ws_path = WS_PATH_TEST if is_test_dc else WS_PATH target = proxy_config.dc_redirects.get(dc) is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains # Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out - if (dc not in proxy_config.dc_redirects + if (dc not in proxy_config.dc_redirects or dc_key in ws_blacklist or now < ip_fail_until.get(target, 0) and is_any_cf_fallback): @@ -322,7 +328,7 @@ async def _handle_client(reader, writer, secret: bytes): pass ok = await do_fallback( clt_reader, clt_writer, relay_init, label, - dc, is_media, media_tag, + dc, is_test_dc, is_media, media_tag, ctx, splitter=splitter) if not ok: log.warning("[%s] DC%d%s no fallback available", @@ -338,7 +344,7 @@ async def _handle_client(reader, writer, secret: bytes): ws_timed_out = False all_redirects = True - ws = await ws_pool.get(dc, is_media, target, domains) + ws = await ws_pool.get(dc, is_media, target, domains, path=ws_path) if not is_test_dc else None if ws: log.info("[%s] DC%d%s -> pool hit via %s", label, dc, media_tag, target) @@ -348,7 +354,7 @@ async def _handle_client(reader, writer, secret: bytes): label, dc, media_tag, domains[0]) try: ws = await RawWebSocket.connect(target, domains[0], - timeout=5.0, + timeout=5.0, path=ws_path, sni="sprinthost.ru") except Exception as exc: stats.ws_errors += 1 @@ -363,12 +369,13 @@ async def _handle_client(reader, writer, secret: bytes): ws_pool.fronting_until = 0.0 else: for domain in domains: - url = f'wss://{domain}/apiws' + url = f'wss://{domain}{ws_path}' log.info("[%s] DC%d%s -> %s via %s", label, dc, media_tag, url, target) try: ws = await RawWebSocket.connect(target, domain, - timeout=ws_timeout) + timeout=ws_timeout, + path=ws_path) all_redirects = False break except WsHandshakeError as exc: @@ -404,7 +411,7 @@ async def _handle_client(reader, writer, secret: bytes): label, dc, media_tag, domains[0]) try: ws = await RawWebSocket.connect(target, domains[0], - timeout=5.0, + timeout=5.0, path=ws_path, sni="sprinthost.ru") except Exception as exc: stats.ws_errors += 1 @@ -440,7 +447,7 @@ async def _handle_client(reader, writer, secret: bytes): pass ok = await do_fallback( clt_reader, clt_writer, relay_init, label, - dc, is_media, media_tag, + dc, is_test_dc, is_media, media_tag, ctx, splitter=splitter_fb) if ok: log.info("[%s] DC%d%s fallback closed", @@ -703,6 +710,11 @@ def main(): metavar='DOMAIN', help='Enable Fake TLS (ee-secret) masking with the given ' 'SNI domain, e.g. example.com') + ap.add_argument('--force-test-dc', action='store_true', + help='Force ALL traffic to Telegram TEST datacenters. ' + 'Not needed for Telegram Desktop (test DCs 10001+ ' + 'are detected automatically); use for clients that ' + 'signal test DCs as plain 1-3') ap.add_argument('--proxy-protocol', action='store_true', help='Accept PROXY protocol v1 header ' '(for use behind nginx/haproxy with proxy_protocol on)') @@ -742,6 +754,7 @@ def main(): proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain) proxy_config.fake_tls_domain = args.fake_tls_domain.strip() proxy_config.proxy_protocol = args.proxy_protocol + proxy_config.force_test_dc = args.force_test_dc log_level = logging.DEBUG if args.verbose else logging.INFO log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', diff --git a/proxy/utils.py b/proxy/utils.py index ba1a14d..05867b0 100644 --- a/proxy/utils.py +++ b/proxy/utils.py @@ -43,6 +43,15 @@ DC_DEFAULT_IPS: Dict[int, str] = { 203: '91.105.192.100' } +DC_TEST_IPS: Dict[int, str] = { + 1: '149.154.175.10', + 2: '149.154.167.40', + 3: '149.154.175.117', +} + +WS_PATH = '/apiws' +WS_PATH_TEST = WS_PATH + '_test' + def ws_domains(dc: int, is_media) -> List[str]: if dc == 203: diff --git a/utils/default_config.py b/utils/default_config.py index 3d244b9..d8b3316 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -22,6 +22,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { "cfproxy": True, "cfproxy_user_domain": [], "cfproxy_worker_domain": [], + "force_test_dc": False, "ws_keepalive_interval": 30, } diff --git a/utils/tray_common.py b/utils/tray_common.py index d4bc21b..6f5bfb3 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -337,6 +337,7 @@ def apply_proxy_config(cfg: dict) -> bool: pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"]) pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])) pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"])) + pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"])) return True diff --git a/windows.py b/windows.py index 35495b9..d5e1b3b 100644 --- a/windows.py +++ b/windows.py @@ -522,6 +522,8 @@ def _edit_config_dialog() -> None: messagebox.showerror(t("app.error_title"), merged, parent=root) return + merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) + _ui_only_keys = {"appearance", "autostart", "check_updates", "language"} config_changed = any(merged.get(k) != _config.get(k) for k in merged) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)