diff --git a/.github/cfproxy-domains.txt b/.github/cfproxy-domains.txt new file mode 100644 index 0000000..e10dd53 --- /dev/null +++ b/.github/cfproxy-domains.txt @@ -0,0 +1 @@ +virkgj.com diff --git a/macos.py b/macos.py index 61a20f8..78a1180 100644 --- a/macos.py +++ b/macos.py @@ -404,10 +404,9 @@ def _edit_config_dialog() -> None: cfproxy_priority = cfproxy_priority_result cfproxy_domain = _osascript_input( - "Домен CF-прокси:\n" - "DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.\n" - "pclead.co.uk готовый настроенный домен. Подробнее про настройку читайте в репозитории - docs/CfProxy.md", - cfg.get("cfproxy_domain", DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk")), + "Свой CF-домен (оставьте пустым для автоматического выбора):\n" + "DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.", + cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")), ) if cfproxy_domain is None: return @@ -425,7 +424,7 @@ def _edit_config_dialog() -> None: "check_updates": cfg.get("check_updates", True), "cfproxy": cfproxy, "cfproxy_priority": cfproxy_priority, - "cfproxy_domain": cfproxy_domain or DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk"), + "cfproxy_user_domain": cfproxy_domain, } save_config(new_cfg) log.info("Config saved: %s", new_cfg) diff --git a/proxy/bridge.py b/proxy/bridge.py index 4c93bca..b2d82fe 100644 --- a/proxy/bridge.py +++ b/proxy/bridge.py @@ -161,22 +161,33 @@ async def _cfproxy_fallback(reader, writer, relay_init, label, clt_decryptor=None, clt_encryptor=None, tg_encryptor=None, tg_decryptor=None, splitter=None): - domain = f'kws{dc}.{proxy_config.fallback_cfproxy_domain}' media_tag = ' media' if is_media else '' - ws = None - log.info("[%s] DC%d%s -> CF proxy wss://%s/apiws", - label, dc, media_tag, domain) - try: - ws = await RawWebSocket.connect(domain, domain, - timeout=10.0) - except Exception as exc: - log.warning("[%s] DC%d%s CF proxy %s failed: %s", - label, dc, media_tag, domain, exc) - + active = proxy_config.active_cfproxy_domain + others = [d for d in proxy_config.cfproxy_domains if d != active] + + ws = None + chosen_domain = None + + for base_domain in ([active] + others): + domain = f'kws{dc}.{base_domain}' + log.info("[%s] DC%d%s -> CF proxy wss", + label, dc, media_tag) + try: + ws = await RawWebSocket.connect(domain, domain, timeout=10.0) + chosen_domain = base_domain + break + except Exception as exc: + log.warning("[%s] DC%d%s CF proxy failed: %s", + label, dc, media_tag, exc) + if ws is None: return False - + + if chosen_domain and chosen_domain != proxy_config.active_cfproxy_domain: + log.info("[%s] Switching active CF domain", label) + proxy_config.active_cfproxy_domain = chosen_domain + stats.connections_cfproxy += 1 await ws.send(relay_init) await bridge_ws_reencrypt(reader, writer, ws, label, diff --git a/proxy/config.py b/proxy/config.py index 5df365c..ba0f1fa 100644 --- a/proxy/config.py +++ b/proxy/config.py @@ -1,8 +1,36 @@ +import logging import os +import random import socket as _socket +import threading -from typing import Dict, List from dataclasses import dataclass, field +from typing import Dict, List +from urllib.request import Request, urlopen + +log = logging.getLogger('tg-mtproto-proxy') + +CFPROXY_DOMAINS_URL = ( + "https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main" + "/.github/cfproxy-domains.txt" +) + +_CFPROXY_ENC: List[str] = ['virkgj.com'] +_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107)) + + +def _dd(s: str) -> str: + """Only for decoding CF proxy domains""" + if not s[-4:] == '.com': + return s + p, n = s[:-4], sum(c.isalpha() for c in s[:-4]) + return ''.join( + chr((ord(c) - (97 if c > '`' else 65) - n) % 26 + (97 if c > '`' else 65)) + if c.isalpha() else c for c in p + ) + _S + + +CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC] @dataclass @@ -15,12 +43,54 @@ class ProxyConfig: pool_size: int = 4 fallback_cfproxy: bool = True fallback_cfproxy_priority: bool = True - fallback_cfproxy_domain: str = 'pclead.co.uk' + cfproxy_user_domain: str = '' + cfproxy_domains: List[str] = field(default_factory=lambda: list(CFPROXY_DEFAULT_DOMAINS)) + active_cfproxy_domain: str = field(default_factory=lambda: random.choice(CFPROXY_DEFAULT_DOMAINS)) proxy_config = ProxyConfig() +def _fetch_cfproxy_domain_list() -> List[str]: + try: + req = Request(CFPROXY_DOMAINS_URL, headers={'User-Agent': 'tg-ws-proxy'}) + with urlopen(req, timeout=10) as resp: + text = resp.read().decode('utf-8', errors='replace') + encoded = [ + line.strip() for line in text.splitlines() + if line.strip() and not line.startswith('#') + ] + return [_dd(d) for d in encoded] + except Exception as exc: + log.warning("Failed to fetch CF proxy domain list: %s", exc) + return [] + + +def refresh_cfproxy_domains() -> None: + if proxy_config.cfproxy_user_domain: + return + + fetched = _fetch_cfproxy_domain_list() + + if fetched: + seen = set() + pool = [d for d in fetched if not (d in seen or seen.add(d))] + log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool)) + else: + pool = list(proxy_config.cfproxy_domains) or list(CFPROXY_DEFAULT_DOMAINS) + + proxy_config.cfproxy_domains = pool + proxy_config.active_cfproxy_domain = random.choice(pool) + + +def start_cfproxy_domain_refresh() -> None: + threading.Thread( + target=refresh_cfproxy_domains, + daemon=True, + name='cfproxy-domains-refresh', + ).start() + + def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: dc_redirects: Dict[int, str] = {} for entry in dc_ip_list: diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index e0461cc..4e40c0c 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -4,6 +4,7 @@ import os import sys import time import struct +import random import asyncio import hashlib import argparse @@ -24,7 +25,7 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''): from .utils import * from .stats import stats -from .config import proxy_config, parse_dc_ip_list +from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, CFPROXY_DEFAULT_DOMAINS from .bridge import MsgSplitter, do_fallback, bridge_ws_reencrypt from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts @@ -445,6 +446,16 @@ async def _run(stop_event: Optional[asyncio.Event] = None): ws_blacklist.clear() dc_fail_until.clear() + if proxy_config.fallback_cfproxy: + user = proxy_config.cfproxy_user_domain + if user: + proxy_config.cfproxy_domains = [user] + proxy_config.active_cfproxy_domain = user + else: + proxy_config.cfproxy_domains = list(CFPROXY_DEFAULT_DOMAINS) + proxy_config.active_cfproxy_domain = random.choice(CFPROXY_DEFAULT_DOMAINS) + start_cfproxy_domain_refresh() + secret_bytes = bytes.fromhex(proxy_config.secret) def client_cb(r, w): @@ -472,8 +483,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None): log.info(" DC%d: %s", dc, ip) if proxy_config.fallback_cfproxy: prio = 'CF first' if proxy_config.fallback_cfproxy_priority else 'TCP first' - log.info(" CF proxy: %s (%s)", - proxy_config.fallback_cfproxy_domain, prio) + user_domain = "user" if proxy_config.cfproxy_user_domain else "auto" + log.info(" CF proxy: enabled (%s | %s)", prio, user_domain) log.info("=" * 60) log.info(" Connect link:") log.info(" %s", tg_link) @@ -557,10 +568,9 @@ def main(): help='Socket send/recv buffer size in KB (default 256)') ap.add_argument('--pool-size', type=int, default=4, metavar='N', help='WS connection pool size per DC (default 4, min 0)') - ap.add_argument('--cfproxy-domain', type=str, default='pclead.co.uk', + ap.add_argument('--cfproxy-domain', type=str, default='', metavar='DOMAIN', - help='Cloudflare-proxied domain for WS fallback ' - '(default: pclead.co.uk)') + help='User defined Cloudflare-proxied domain for WS fallback') ap.add_argument('--no-cfproxy', action='store_true', help='Disable Cloudflare proxy fallback') ap.add_argument('--cfproxy-priority', type=bool, default=True, @@ -598,7 +608,7 @@ def main(): proxy_config.pool_size = max(0, args.pool_size) proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy_priority = args.cfproxy_priority - proxy_config.fallback_cfproxy_domain = args.cfproxy_domain + proxy_config.cfproxy_user_domain = args.cfproxy_domain log_level = logging.DEBUG if args.verbose else logging.INFO log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index 6d5538e..dddd0a6 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -6,8 +6,10 @@ from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple, Union from proxy import __version__, get_link_host, parse_dc_ip_list +from proxy.config import CFPROXY_DEFAULT_DOMAINS from utils.update_check import RELEASES_PAGE_URL, get_status + from ui.ctk_theme import ( FIRST_RUN_FRAME_PAD, CtkTheme, @@ -57,7 +59,11 @@ _TIP_CFPROXY_PRIORITY = ( "Пробовать CF-прокси раньше прямого TCP-подключения" ) _TIP_CFPROXY_DOMAIN = ( - "Домен, проксируемый через Cloudflare, для WS-подключения" + "Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n" + "Если не указан — выбирается автоматически из поддерживаемых доменов" +) +_TIP_CFPROXY_USER_DOMAIN_CB = ( + "Указать свой домен вместо автоматического выбора" ) _TIP_SAVE = "Сохранить настройки" _TIP_CANCEL = "Закрыть окно без сохранения изменений" @@ -114,6 +120,16 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict: return results +def _run_cfproxy_auto_test(domains: list) -> tuple: + last: dict = {} + for domain in domains: + res = _run_cfproxy_connectivity_test(domain) + last = res + if any(v is True for v in res.values()): + return domain, res + return None, last + + def _cfproxy_show_test_results(domain: str, results: dict) -> None: import tkinter as _tk from tkinter import messagebox as _mb @@ -144,6 +160,28 @@ def _cfproxy_show_test_results(domain: str, results: dict) -> None: _mb.showinfo(title, msg, parent=root) root.destroy() + +def _cfproxy_show_auto_test_results(ok_domain, results: dict) -> None: + import tkinter as _tk + from tkinter import messagebox as _mb + + if ok_domain is not None: + title = "CF-прокси: доступен" + ok = [dc for dc, v in results.items() if v is True] + msg = f"\u2713 CF-прокси работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны." + else: + title = "CF-прокси: недоступен" + msg = "\u2717 Ни один из автоматических CF-доменов не отвечает.\n" + msg += "Возможно, блокировка или проблемы с сетью." + root = _tk.Tk() + root.withdraw() + try: + root.attributes("-topmost", True) + except Exception: + pass + _mb.showinfo(title, msg, parent=root) + root.destroy() + _INNER_W = 396 _APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"] @@ -253,7 +291,7 @@ class TrayConfigFormWidgets: check_updates_var: Optional[Any] cfproxy_var: Optional[Any] = None cfproxy_priority_var: Optional[Any] = None - cfproxy_domain_var: Optional[Any] = None + cfproxy_user_domain_var: Optional[Any] = None appearance_var: Optional[Any] = None @@ -363,7 +401,7 @@ def install_tray_config_form( cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy") cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent") - cf_row.pack(fill="x", pady=(0, 6)) + cf_row.pack(fill="x", pady=(0, 4)) cfproxy_var = ctk.BooleanVar( value=cfg.get("cfproxy", default_config.get("cfproxy", True)) @@ -375,60 +413,76 @@ def install_tray_config_form( cfproxy_priority_var = ctk.BooleanVar( value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True)) ) - cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет CF-прокси", cfproxy_priority_var) + cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет", cfproxy_priority_var) cf_prio_cb.pack(side="left") attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY) - cf_domain_row = ctk.CTkFrame(cf_inner, fg_color="transparent") - cf_domain_row.pack(fill="x") - - cf_domain_col, cfproxy_domain_var = _labeled_entry( - ctk, cf_domain_row, theme, "Домен", - cfg.get("cfproxy_domain", default_config.get("cfproxy_domain", "pclead.co.uk")), - tip=_TIP_CFPROXY_DOMAIN, width=160, pack_fill=True, - ) - cf_domain_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) - _cf_test_btn = [None] def _on_cf_test(): - domain = cfproxy_domain_var.get().strip() - if not domain: - return + user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else "" btn = _cf_test_btn[0] if btn: btn.configure(text="...", state="disabled") import threading as _threading - def _worker(): - res = _run_cfproxy_connectivity_test(domain) - if btn: - btn.after(0, lambda: btn.configure(text="Тест", state="normal")) - btn.after(0, lambda: _cfproxy_show_test_results(domain, res)) - _threading.Thread(target=_worker, daemon=True).start() + if user_domain: + def _worker(): + res = _run_cfproxy_connectivity_test(user_domain) + if btn: + btn.after(0, lambda: btn.configure(text="Тест", state="normal")) + btn.after(0, lambda: _cfproxy_show_test_results(user_domain, res)) + _threading.Thread(target=_worker, daemon=True).start() + else: + def _worker_auto(): + ok_domain, res = _run_cfproxy_auto_test(CFPROXY_DEFAULT_DOMAINS) + if btn: + btn.after(0, lambda: btn.configure(text="Тест", state="normal")) + btn.after(0, lambda: _cfproxy_show_auto_test_results(ok_domain, res)) + _threading.Thread(target=_worker_auto, daemon=True).start() - cf_test_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent") - cf_test_col.pack(side="left", anchor="s", padx=(0, 6)) - ctk.CTkLabel(cf_test_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2)) _cf_test_widget = ctk.CTkButton( - cf_test_col, text="Тест", width=56, height=36, - font=(theme.ui_font_family, 13), corner_radius=10, + cf_row, text="Тест", width=56, height=28, + font=(theme.ui_font_family, 13), corner_radius=8, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, text_color="#ffffff", border_width=1, border_color=theme.field_border, command=_on_cf_test, ) - _cf_test_widget.pack() + _cf_test_widget.pack(side="right") _cf_test_btn[0] = _cf_test_widget - cf_help_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent") - cf_help_col.pack(side="left", anchor="s") - ctk.CTkLabel(cf_help_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2)) + cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent") + cf_custom_row.pack(fill="x") + + saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) + cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain)) + cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var) + cf_custom_cb.pack(side="left", padx=(0, 10)) + attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB) + ctk.CTkButton( - cf_help_col, text="?", width=36, height=36, - font=(theme.ui_font_family, 18), corner_radius=10, + cf_custom_row, text="?", width=28, height=32, + font=(theme.ui_font_family, 14), corner_radius=8, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, text_color="#ffffff", border_width=1, border_color=theme.field_border, command=lambda: webbrowser.open(_CFPROXY_HELP_URL), - ).pack() + ).pack(side="right") + + cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain) + cf_domain_entry = _entry( + ctk, cf_custom_row, theme, var=cfproxy_user_domain_var, + height=32, radius=8, + ) + cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) + attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN) + + def _sync_domain_entry(*_): + state = "normal" if cf_custom_cb_var.get() else "disabled" + cf_domain_entry.configure(state=state) + if not cf_custom_cb_var.get(): + cfproxy_user_domain_var.set("") + + cf_custom_cb_var.trace_add("write", _sync_domain_entry) + _sync_domain_entry() log_inner = _config_section(ctk, frame, theme, "Логи и производительность") @@ -520,7 +574,7 @@ def install_tray_config_form( autostart_var=autostart_var, check_updates_var=check_updates_var, cfproxy_var=cfproxy_var, cfproxy_priority_var=cfproxy_priority_var, - cfproxy_domain_var=cfproxy_domain_var, + cfproxy_user_domain_var=cfproxy_user_domain_var, appearance_var=appearance_var, ) @@ -602,10 +656,8 @@ def validate_config_form( new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get()) if widgets.cfproxy_priority_var is not None: new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get()) - if widgets.cfproxy_domain_var is not None: - domain = widgets.cfproxy_domain_var.get().strip() - if domain: - new_cfg["cfproxy_domain"] = domain + if widgets.cfproxy_user_domain_var is not None: + new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip() if widgets.appearance_var is not None: new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto") return new_cfg diff --git a/utils/default_config.py b/utils/default_config.py index d3c73c9..8e0dc5b 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -19,7 +19,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { "pool_size": 4, "cfproxy": True, "cfproxy_priority": True, - "cfproxy_domain": "pclead.co.uk", + "cfproxy_user_domain": "", } diff --git a/utils/tray_common.py b/utils/tray_common.py index 2019c72..53facff 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -271,7 +271,7 @@ def apply_proxy_config(cfg: dict) -> bool: pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])) pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"]) pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"]) - pc.fallback_cfproxy_domain = cfg.get("cfproxy_domain", DEFAULT_CONFIG["cfproxy_domain"]) + pc.cfproxy_user_domain = cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]) return True