Поддержка тестового окружения Telegram (тестовые DC) (#1087)

This commit is contained in:
Danil
2026-07-07 20:56:24 +02:00
committed by GitHub
parent e9b74d7d7d
commit c3309ed11b
13 changed files with 73 additions and 17 deletions
+1
View File
@@ -46,6 +46,7 @@
- **[Docker](./README.docker.md)** - **[Docker](./README.docker.md)**
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md) - [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md)
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md) - [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
- [Тестовое окружение Telegram (тестовые DC)](./TestDc.md)
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md) - [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
- [Файлы конфигурации Tray-приложения](./TrayConfig.md) - [Файлы конфигурации Tray-приложения](./TrayConfig.md)
- [Установка из исходников](./BuildFromSource.md) - [Установка из исходников](./BuildFromSource.md)
+23
View File
@@ -0,0 +1,23 @@
# Тестовое окружение Telegram (тестовые DC)
Маршрутизация трафика к тестовым дата-центрам Telegram (test environment).
Нужна для разработки/тестирования ботов и клиентов в тестовой среде Telegram.
## Как включается
**Автоматически.** Telegram Desktop помечает тестовые DC сдвигом +10000
(1000110003). Прокси распознаёт сдвиг сам — включать ничего не нужно, можно
одновременно пользоваться продовым и тестовым аккаунтом в одном клиенте.
**Принудительно.** Для клиентов, которые сообщают тестовые 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)).
+1
View File
@@ -23,6 +23,7 @@ Tray-приложение хранит данные в:
"cfproxy": true, "cfproxy": true,
"cfproxy_user_domain": "", "cfproxy_user_domain": "",
"cfproxy_worker_domain": "", "cfproxy_worker_domain": "",
"force_test_dc": false,
"appearance": "auto" "appearance": "auto"
} }
``` ```
+2
View File
@@ -188,6 +188,8 @@ def _edit_config_dialog() -> None:
messagebox.showerror(t("app.error_title"), merged, parent=root) messagebox.showerror(t("app.error_title"), merged, parent=root)
return return
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
_ui_only_keys = {"appearance", "check_updates", "language"} _ui_only_keys = {"appearance", "check_updates", "language"}
config_changed = any(merged.get(k) != _config.get(k) for k in merged) 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) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
+1
View File
@@ -475,6 +475,7 @@ def _edit_config_dialog() -> None:
"cfproxy": cfproxy, "cfproxy": cfproxy,
"cfproxy_user_domain": cfproxy_domains, "cfproxy_user_domain": cfproxy_domains,
"cfproxy_worker_domain": cfworker_domains, "cfproxy_worker_domain": cfworker_domains,
"force_test_dc": cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]),
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
+8 -7
View File
@@ -130,10 +130,11 @@ class MsgSplitter:
async def do_fallback(reader, writer, relay_init, label, 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): ctx: CryptoCtx, splitter=None):
fallback_dst = DC_DEFAULT_IPS.get(dc) ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
use_cf = proxy_config.fallback_cfproxy fallback_dst = ip_table.get(dc)
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
worker_domains = proxy_config.cfproxy_worker_domains worker_domains = proxy_config.cfproxy_worker_domains
methods: List[str] = [] methods: List[str] = []
@@ -149,8 +150,8 @@ async def do_fallback(reader, writer, relay_init, label,
if method == 'cf_worker' and fallback_dst: if method == 'cf_worker' and fallback_dst:
ok = await _cfproxy_worker_fallback( ok = await _cfproxy_worker_fallback(
reader, writer, relay_init, label, ctx, reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media, fallback_dst=fallback_dst, dc=dc, is_test_dc=is_test_dc, is_media=is_media,
splitter=splitter) fallback_dst=fallback_dst, splitter=splitter)
if ok: if ok:
return True return True
elif method == 'cf': 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, async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
ctx: CryptoCtx, ctx: CryptoCtx,
dc: int, is_media: bool, dc: int, is_test_dc: bool, is_media: bool,
fallback_dst: str, fallback_dst: str,
splitter=None): splitter=None):
media_tag = ' media' if is_media else '' 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) random.shuffle(worker_domains)
for worker_domain in 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: if ws:
log.info("[%s] DC%d%s -> CF worker pool hit for %s", log.info("[%s] DC%d%s -> CF worker pool hit for %s",
label, dc, media_tag, fallback_dst) label, dc, media_tag, fallback_dst)
+1
View File
@@ -72,6 +72,7 @@ class ProxyConfig:
cfproxy_worker_domains: List[str] = field(default_factory=list) cfproxy_worker_domains: List[str] = field(default_factory=list)
fake_tls_domain: str = '' fake_tls_domain: str = ''
proxy_protocol: bool = False proxy_protocol: bool = False
force_test_dc: bool = False
proxy_config = ProxyConfig() proxy_config = ProxyConfig()
+1 -1
View File
@@ -213,4 +213,4 @@ class _CfWorkerPool:
ws_pool = _WsPool() ws_pool = _WsPool()
cf_worker_pool = _CfWorkerPool() cf_worker_pool = _CfWorkerPool()
+22 -9
View File
@@ -280,6 +280,11 @@ async def _handle_client(reader, writer, secret: bytes):
dc, is_media, proto_tag, client_dec_prekey_iv = result 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: if proto_tag == PROTO_TAG_ABRIDGED:
proto_int = PROTO_ABRIDGED_INT proto_int = PROTO_ABRIDGED_INT
elif proto_tag == PROTO_TAG_INTERMEDIATE: 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) relay_init = _generate_relay_init(proto_tag, dc_idx)
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init) 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 "" media_tag = " media" if is_media else ""
now = time.monotonic() now = time.monotonic()
ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
target = proxy_config.dc_redirects.get(dc) target = proxy_config.dc_redirects.get(dc)
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains 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 # 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 dc_key in ws_blacklist
or now < ip_fail_until.get(target, 0) and is_any_cf_fallback): 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 pass
ok = await do_fallback( ok = await do_fallback(
clt_reader, clt_writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter) ctx, splitter=splitter)
if not ok: if not ok:
log.warning("[%s] DC%d%s no fallback available", 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 ws_timed_out = False
all_redirects = True 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: if ws:
log.info("[%s] DC%d%s -> pool hit via %s", log.info("[%s] DC%d%s -> pool hit via %s",
label, dc, media_tag, target) label, dc, media_tag, target)
@@ -348,7 +354,7 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, media_tag, domains[0]) label, dc, media_tag, domains[0])
try: try:
ws = await RawWebSocket.connect(target, domains[0], ws = await RawWebSocket.connect(target, domains[0],
timeout=5.0, timeout=5.0, path=ws_path,
sni="sprinthost.ru") sni="sprinthost.ru")
except Exception as exc: except Exception as exc:
stats.ws_errors += 1 stats.ws_errors += 1
@@ -363,12 +369,13 @@ async def _handle_client(reader, writer, secret: bytes):
ws_pool.fronting_until = 0.0 ws_pool.fronting_until = 0.0
else: else:
for domain in domains: for domain in domains:
url = f'wss://{domain}/apiws' url = f'wss://{domain}{ws_path}'
log.info("[%s] DC%d%s -> %s via %s", log.info("[%s] DC%d%s -> %s via %s",
label, dc, media_tag, url, target) label, dc, media_tag, url, target)
try: try:
ws = await RawWebSocket.connect(target, domain, ws = await RawWebSocket.connect(target, domain,
timeout=ws_timeout) timeout=ws_timeout,
path=ws_path)
all_redirects = False all_redirects = False
break break
except WsHandshakeError as exc: except WsHandshakeError as exc:
@@ -404,7 +411,7 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, media_tag, domains[0]) label, dc, media_tag, domains[0])
try: try:
ws = await RawWebSocket.connect(target, domains[0], ws = await RawWebSocket.connect(target, domains[0],
timeout=5.0, timeout=5.0, path=ws_path,
sni="sprinthost.ru") sni="sprinthost.ru")
except Exception as exc: except Exception as exc:
stats.ws_errors += 1 stats.ws_errors += 1
@@ -440,7 +447,7 @@ async def _handle_client(reader, writer, secret: bytes):
pass pass
ok = await do_fallback( ok = await do_fallback(
clt_reader, clt_writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter_fb) ctx, splitter=splitter_fb)
if ok: if ok:
log.info("[%s] DC%d%s fallback closed", log.info("[%s] DC%d%s fallback closed",
@@ -703,6 +710,11 @@ def main():
metavar='DOMAIN', metavar='DOMAIN',
help='Enable Fake TLS (ee-secret) masking with the given ' help='Enable Fake TLS (ee-secret) masking with the given '
'SNI domain, e.g. example.com') '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', ap.add_argument('--proxy-protocol', action='store_true',
help='Accept PROXY protocol v1 header ' help='Accept PROXY protocol v1 header '
'(for use behind nginx/haproxy with proxy_protocol on)') '(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.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.fake_tls_domain = args.fake_tls_domain.strip() proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol 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_level = logging.DEBUG if args.verbose else logging.INFO
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
+9
View File
@@ -43,6 +43,15 @@ DC_DEFAULT_IPS: Dict[int, str] = {
203: '91.105.192.100' 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]: def ws_domains(dc: int, is_media) -> List[str]:
if dc == 203: if dc == 203:
+1
View File
@@ -22,6 +22,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"cfproxy": True, "cfproxy": True,
"cfproxy_user_domain": [], "cfproxy_user_domain": [],
"cfproxy_worker_domain": [], "cfproxy_worker_domain": [],
"force_test_dc": False,
"ws_keepalive_interval": 30, "ws_keepalive_interval": 30,
} }
+1
View File
@@ -337,6 +337,7 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"]) 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_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.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"])) pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
return True return True
+2
View File
@@ -522,6 +522,8 @@ def _edit_config_dialog() -> None:
messagebox.showerror(t("app.error_title"), merged, parent=root) messagebox.showerror(t("app.error_title"), merged, parent=root)
return return
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"} _ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
config_changed = any(merged.get(k) != _config.get(k) for k in merged) 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) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)