diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 878da76..783fc78 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,7 +306,7 @@ jobs: Maintainer: Flowseal Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk Description: Telegram Desktop WebSocket Bridge Proxy - SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI. + MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI. EOF dpkg-deb --build --root-owner-group \ diff --git a/Dockerfile b/Dockerfile index dae44d2..b0d9462 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH=/opt/venv/bin:$PATH \ TG_WS_PROXY_HOST=0.0.0.0 \ - TG_WS_PROXY_PORT=1080 \ + TG_WS_PROXY_PORT=1443 \ TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" RUN apt-get update \ @@ -39,7 +39,7 @@ COPY README.md LICENSE ./ USER app -EXPOSE 1080/tcp +EXPOSE 1443/tcp ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] CMD [] diff --git a/README.md b/README.md index 121d240..207ba38 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ # TG WS Proxy -**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. +**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. image ## Как это работает ``` -Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC +Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC ``` -1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080` +1. Приложение поднимает MTProto прокси на `127.0.0.1:1443` 2. Перехватывает подключения к IP-адресам Telegram 3. Извлекает DC ID из MTProto obfuscation init-пакета 4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram @@ -38,7 +38,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra **Меню трея:** -- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку +- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку - **Перезапустить прокси** — перезапуск без выхода из приложения - **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub) - **Открыть логи** — открыть файл логов @@ -69,8 +69,9 @@ makepkg -si # При помощи AUR-helper paru -S tg-ws-proxy-bin -# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси: -sudo systemctl start tg-ws-proxy-cli@8888 +# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта, +# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16 +sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f ``` Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64). @@ -103,7 +104,7 @@ chmod +x TgWsProxy_linux_amd64 ### Консольный proxy -Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки: +Для запуска только proxy без tray-интерфейса достаточно базовой установки: ```bash pip install -e . @@ -193,9 +194,15 @@ android/app/build/outputs/apk/legacy32/release/app-legacy32-release.apk | Аргумент | По умолчанию | Описание | |---|---|---| -| `--port` | `1080` | Порт SOCKS5-прокси | -| `--host` | `127.0.0.1` | Хост SOCKS5-прокси | +| `--port` | `1443` | Порт прокси | +| `--host` | `127.0.0.1` | Хост прокси | +| `--secret` | `random` | 32 hex chars secret для авторизации клиентов | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | +| `--buf-kb` | `256` | Размер буфера в КБ +| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC +| `--log-file` | выкл. | Путь до файла, в который сохранять логи +| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) +| `--log-backups` | `0` | Количество сохранений логов после перезаписи | `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | **Примеры:** @@ -235,10 +242,10 @@ tg-ws-proxy-tray-linux = "linux:main" 1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** 2. Добавить прокси: - - **Тип:** SOCKS5 - - **Сервер:** `127.0.0.1` - - **Порт:** `1080` - - **Логин/Пароль:** оставить пустыми + - **Тип:** MTProto + - **Сервер:** `127.0.0.1` (или переопределенный вами) + - **Порт:** `1443` (или переопределенный вами) + - **Secret:** из настроек или логов ## Настройка Telegram Android @@ -271,7 +278,8 @@ Tray-приложение хранит данные в: ```json { "host": "127.0.0.1", - "port": 1080, + "port": 1443, + "secret": "...", "dc_ip": [ "2:149.154.167.220", "4:149.154.167.220" diff --git a/icon.ico b/icon.ico index 8aacb76..ab0f155 100644 Binary files a/icon.ico and b/icon.ico differ diff --git a/linux.py b/linux.py index e6f221c..39cf6c3 100644 --- a/linux.py +++ b/linux.py @@ -1,241 +1,44 @@ from __future__ import annotations -import json -import logging -import logging.handlers import os import subprocess import sys import threading -import webbrowser import time -from pathlib import Path from typing import Optional import customtkinter as ctk -import psutil import pyperclip import pystray -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageTk import proxy.tg_ws_proxy as tg_ws_proxy -from proxy.app_runtime import ProxyAppRuntime -from proxy import __version__ -from utils.default_config import default_tray_config + +from utils.tray_common import ( + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, + acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + maybe_notify_update, quit_ctk, release_lock, restart_proxy, + save_config, start_proxy, stop_proxy, tg_proxy_url, +) from ui.ctk_tray_ui import ( - install_tray_config_buttons, - install_tray_config_form, - populate_first_run_window, - tray_settings_scroll_and_footer, + install_tray_config_buttons, install_tray_config_form, + populate_first_run_window, tray_settings_scroll_and_footer, validate_config_form, ) from ui.ctk_theme import ( - CONFIG_DIALOG_FRAME_PAD, - CONFIG_DIALOG_SIZE, - FIRST_RUN_SIZE, - create_ctk_root, - ctk_theme_for_platform, - main_content_frame, + CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, + create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - - -DEFAULT_CONFIG = default_tray_config() - - _tray_icon: Optional[object] = None _config: dict = {} -_exiting: bool = False -_lock_file_path: Optional[Path] = None +_exiting = False -log = logging.getLogger("tg-ws-tray") -_runtime = ProxyAppRuntime( - APP_DIR, - default_config=DEFAULT_CONFIG, - logger_name="tg-ws-tray", - on_error=lambda text: _show_error(text), -) -CONFIG_FILE = _runtime.config_file -LOG_FILE = _runtime.log_file +# dialogs (tkinter messagebox) -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - cmdline = proc.cmdline() - for arg in cmdline: - if "linux.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -def _ensure_dirs(): - _runtime.ensure_dirs() - - -def load_config() -> dict: - return _runtime.load_config() - - -def save_config(cfg: dict): - _runtime.save_config(cfg) - - -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _runtime.setup_logging(verbose, log_max_mb=log_max_mb) - - -def _make_icon_image(size: int = 64): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse( - [margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255) - ) - - try: - font = ImageFont.truetype( - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - size=int(size * 0.55), - ) - except Exception: - try: - font = ImageFont.truetype( - "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55) - ) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - pass - return _make_icon_image() - - -def start_proxy(): - _runtime.start_proxy(_config) - - -def stop_proxy(): - _runtime.stop_proxy() - - -def restart_proxy(): - _runtime.restart_proxy() - - -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - _mb.showerror(title, text, parent=root) - root.destroy() - - -def _show_info(text: str, title: str = "TG WS Proxy"): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - _mb.showinfo(title, text, parent=root) - root.destroy() - - -def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool: +def _msgbox(kind: str, text: str, title: str, **kw): import tkinter as _tk from tkinter import messagebox as _mb @@ -245,273 +48,189 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool: root.attributes("-topmost", True) except Exception: pass - r = _mb.askyesno(title, text, parent=root) + result = getattr(_mb, kind)(title, text, parent=root, **kw) root.destroy() - return bool(r) + return result -def _maybe_notify_update_async(): - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - text = ( - f"Доступна новая версия: {ver}\n\n" - f"Открыть страницу релиза в браузере?" - ) - if _ask_yes_no_dialog(text, "TG WS Proxy — обновление"): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) - - threading.Thread(target=_work, daemon=True, name="update-check").start() +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: + _msgbox("showerror", text, title) -def _on_open_in_telegram(icon=None, item=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _msgbox("showinfo", text, title) + + +def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: + return bool(_msgbox("askyesno", text, title)) + + +def _apply_window_icon(root) -> None: + icon_img = load_icon() + if icon_img: + root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) + root.iconphoto(False, root._ctk_icon_photo) + + +# tray callbacks + + +def _on_open_in_telegram(icon=None, item=None) -> None: + url = tg_proxy_url(_config) log.info("Copying %s", url) - try: pyperclip.copy(url) _show_info( - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", - "TG WS Proxy", + f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_restart(icon=None, item=None): - threading.Thread(target=restart_proxy, daemon=True).start() +def _on_copy_link(icon=None, item=None) -> None: + url = tg_proxy_url(_config) + log.info("Copying link: %s", url) + try: + pyperclip.copy(url) + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_edit_config(icon=None, item=None): +def _on_restart(icon=None, item=None) -> None: + threading.Thread( + target=lambda: restart_proxy(_config, _show_error), daemon=True + ).start() + + +def _on_edit_config(icon=None, item=None) -> None: threading.Thread(target=_edit_config_dialog, daemon=True).start() -def _edit_config_dialog(): - if ctk is None: - _show_error("customtkinter не установлен.") - return - - cfg = dict(_config) - - theme = ctk_theme_for_platform() - w, h = CONFIG_DIALOG_SIZE - - root = create_ctk_root( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - theme=theme, - after_create=_apply_linux_ctk_window_icon, - ) - - fpx, fpy = CONFIG_DIALOG_FRAME_PAD - frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) - - scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - - widgets = install_tray_config_form( - ctk, scroll, theme, cfg, DEFAULT_CONFIG, - show_autostart=False, - ) - - def on_save(): - merged = validate_config_form( - widgets, DEFAULT_CONFIG, include_autostart=False) - if isinstance(merged, str): - _show_error(merged) - return - - new_cfg = merged - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - - _tray_icon.menu = _build_menu() - - from tkinter import messagebox - - if messagebox.askyesno( - "Перезапустить?", - "Настройки сохранены.\n\nПерезапустить прокси сейчас?", - parent=root, - ): - root.destroy() - restart_proxy() - else: - root.destroy() - - def on_cancel(): - root.destroy() - - install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) - - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass - - -def _on_open_logs(icon=None, item=None): +def _on_open_logs(icon=None, item=None) -> None: log.info("Opening log file: %s", LOG_FILE) if LOG_FILE.exists(): - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONPATH", None) - env.pop("PYTHONHOME", None) - + env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")} subprocess.Popen( - ["xdg-open", str(LOG_FILE)], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - start_new_session=True, + ["xdg-open", str(LOG_FILE)], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, ) else: - _show_info("Файл логов ещё не создан.", "TG WS Proxy") + _show_info("Файл логов ещё не создан.") -def _on_exit(icon=None, item=None): +def _on_exit(icon=None, item=None) -> None: global _exiting if _exiting: os._exit(0) return _exiting = True log.info("User requested exit") - - def _force_exit(): - time.sleep(3) - os._exit(0) - - threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() - + quit_ctk() + threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start() if icon: icon.stop() -def _show_first_run(): - _ensure_dirs() +# settings dialog + + +def _edit_config_dialog() -> None: + if not ensure_ctk_thread(ctk): + _show_error("customtkinter не установлен.") + return + + cfg = dict(_config) + + def _build(done: threading.Event) -> None: + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE + root = create_ctk_toplevel( + ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, + after_create=_apply_window_icon, + ) + fpx, fpy = CONFIG_DIALOG_FRAME_PAD + frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) + scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) + widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False) + + def _finish() -> None: + root.destroy() + done.set() + + def on_save() -> None: + from tkinter import messagebox + merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) + if isinstance(merged, str): + messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) + return + save_config(merged) + _config.update(merged) + log.info("Config saved: %s", merged) + _tray_icon.menu = _build_menu() + + do_restart = messagebox.askyesno( + "Перезапустить?", + "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + parent=root, + ) + _finish() + if do_restart: + threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() + + root.protocol("WM_DELETE_WINDOW", _finish) + install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish) + + ctk_run_dialog(_build) + + +# first run + + +def _show_first_run() -> None: + ensure_dirs() if FIRST_RUN_MARKER.exists(): return + if not ensure_ctk_thread(ctk): + FIRST_RUN_MARKER.touch() + return host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - if ctk is None: - FIRST_RUN_MARKER.touch() - return + def _build(done: threading.Event) -> None: + theme = ctk_theme_for_platform() + w, h = FIRST_RUN_SIZE + root = create_ctk_toplevel( + ctk, title="TG WS Proxy", width=w, height=h, theme=theme, + after_create=_apply_window_icon, + ) - theme = ctk_theme_for_platform() - w, h = FIRST_RUN_SIZE + def on_done(open_tg: bool) -> None: + FIRST_RUN_MARKER.touch() + root.destroy() + done.set() + if open_tg: + _on_open_in_telegram() - root = create_ctk_root( - ctk, - title="TG WS Proxy", - width=w, - height=h, - theme=theme, - after_create=_apply_linux_ctk_window_icon, - ) + populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done) - def on_done(open_tg: bool): - FIRST_RUN_MARKER.touch() - root.destroy() - if open_tg: - _on_open_in_telegram() - - populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) - - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + ctk_run_dialog(_build) -def _has_ipv6_enabled() -> bool: - import socket as _sock - - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(("::1", 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - threading.Thread(target=_show_ipv6_dialog, daemon=True).start() - - -def _show_ipv6_dialog(): - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз.", - "TG WS Proxy", - ) +# tray menu def _build_menu(): - if pystray is None: - return None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) return pystray.Menu( - pystray.MenuItem( - f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True - ), + pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), + pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), @@ -521,21 +240,18 @@ def _build_menu(): ) -def run_tray(): +# entry point + + +def run_tray() -> None: global _tray_icon, _config - _config = _runtime.prepare() - _runtime.reset_log_file() - - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy версия %s, tray app starting", __version__) - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) + _config = load_config() + bootstrap(_config) if pystray is None or Image is None: log.error("pystray or Pillow not installed; running in console mode") - start_proxy() + start_proxy(_config, _show_error) try: while True: time.sleep(1) @@ -543,16 +259,12 @@ def run_tray(): stop_proxy() return - start_proxy() - - _maybe_notify_update_async() - + start_proxy(_config, _show_error) + maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _show_first_run() - _check_ipv6_warning() - - icon_image = _load_icon() - _tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu()) + check_ipv6_warning(_show_info) + _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) log.info("Tray icon running") _tray_icon.run() @@ -560,15 +272,14 @@ def run_tray(): log.info("Tray app exited") -def main(): - if not _acquire_lock(): +def main() -> None: + if not acquire_lock("linux.py"): _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) return - try: run_tray() finally: - _release_lock() + release_lock() if __name__ == "__main__": diff --git a/macos.py b/macos.py index 7d724e7..c7c0b22 100644 --- a/macos.py +++ b/macos.py @@ -1,10 +1,6 @@ from __future__ import annotations -import json -import logging -import logging.handlers import os -import psutil import subprocess import sys import threading @@ -29,241 +25,189 @@ except ImportError: pyperclip = None import proxy.tg_ws_proxy as tg_ws_proxy -from proxy.app_runtime import ProxyAppRuntime from proxy import __version__ -from utils.default_config import default_tray_config -APP_NAME = "TgWsProxy" -APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" +from utils.tray_common import ( + APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER, + LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, + log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, +) + MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" -DEFAULT_CONFIG = default_tray_config() - +_proxy_thread: Optional[threading.Thread] = None +_async_stop: Optional[object] = None _app: Optional[object] = None _config: dict = {} _exiting: bool = False -_lock_file_path: Optional[Path] = None -log = logging.getLogger("tg-ws-tray") -_runtime = ProxyAppRuntime( - APP_DIR, - default_config=DEFAULT_CONFIG, - logger_name="tg-ws-tray", - on_error=lambda text: _show_error(text), -) -CONFIG_FILE = _runtime.config_file -LOG_FILE = _runtime.log_file +# osascript dialogs -# Single-instance lock +def _esc(text: str) -> str: + return text.replace("\\", "\\\\").replace('"', '\\"') -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: + +def _osascript(script: str) -> str: + r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) + return r.stdout.strip() + + +def _show_error(text: str, title: str = "TG WS Proxy") -> None: + _osascript( + f'display dialog "{_esc(text)}" with title "{_esc(title)}" ' + f'buttons {{"OK"}} default button "OK" with icon stop' + ) + + +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _osascript( + f'display dialog "{_esc(text)}" with title "{_esc(title)}" ' + f'buttons {{"OK"}} default button "OK" with icon note' + ) + + +def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: + return _ask_yes_no_close(text, title) is True + + +def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]: + r = subprocess.run( + [ + "osascript", "-e", + f'button returned of (display dialog "{_esc(text)}" ' + f'with title "{_esc(title)}" ' + f'buttons {{"Закрыть", "Нет", "Да"}} ' + f'default button "Да" cancel button "Закрыть" with icon note)', + ], + capture_output=True, text=True, + ) + if r.returncode != 0: + return None + btn = r.stdout.strip() + if btn == "Да": + return True + if btn == "Нет": return False - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - return False + return None -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None +def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> Optional[str]: + r = subprocess.run( + [ + "osascript", "-e", + f'text returned of (display dialog "{_esc(prompt)}" ' + f'default answer "{_esc(default)}" ' + f'with title "{_esc(title)}" ' + f'buttons {{"Закрыть", "OK"}} ' + f'default button "OK" cancel button "Закрыть")', + ], + capture_output=True, text=True, + ) + if r.returncode != 0: + return None + return r.stdout.rstrip("\r\n") -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) +# menubar icon - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = {"create_time": proc.create_time()} - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -# Filesystem helpers - -def _ensure_dirs(): - _runtime.ensure_dirs() - - -def load_config() -> dict: - return _runtime.load_config() - - -def save_config(cfg: dict): - _runtime.save_config(cfg) - - -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _runtime.setup_logging(verbose, log_max_mb=log_max_mb) - - -# Menubar icon def _make_menubar_icon(size: int = 44): if Image is None: return None img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) - margin = size // 11 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 0, 0, 255)) - + draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255)) try: - font = ImageFont.truetype( - "/System/Library/Fonts/Helvetica.ttc", - size=int(size * 0.55)) + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55)) except Exception: font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) + draw.text( + ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]), + "T", fill=(255, 255, 255, 255), font=font, + ) return img -# Generate menubar icon PNG if it does not exist. -def _ensure_menubar_icon(): + +def _ensure_menubar_icon() -> None: if MENUBAR_ICON_PATH.exists(): return - _ensure_dirs() + ensure_dirs() img = _make_menubar_icon(44) if img: img.save(str(MENUBAR_ICON_PATH), "PNG") -# Native macOS dialogs +# proxy lifecycle (macOS-local) -def _escape_osascript_text(text: str) -> str: - return text.replace('\\', '\\\\').replace('"', '\\"') +import asyncio as _asyncio -def _osascript(script: str) -> str: - r = subprocess.run( - ['osascript', '-e', script], - capture_output=True, text=True) - return r.stdout.strip() +def _run_proxy_thread() -> None: + global _async_stop + loop = _asyncio.new_event_loop() + _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() + _async_stop = (loop, stop_ev) + try: + loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev)) + except Exception as exc: + log.error("Proxy thread crashed: %s", exc) + if "Address already in use" in str(exc): + _show_error( + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите." + ) + finally: + loop.close() + _async_stop = None -def _show_error(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon stop') +def _start_proxy() -> None: + global _proxy_thread + if _proxy_thread and _proxy_thread.is_alive(): + log.info("Proxy already running") + return + if not apply_proxy_config(_config): + _show_error("Ошибка конфигурации DC → IP.") + return + pc = tg_ws_proxy.proxy_config + log.info("Starting proxy on %s:%d ...", pc.host, pc.port) + _proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy") + _proxy_thread.start() -def _show_info(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon note') +def _stop_proxy() -> None: + global _proxy_thread, _async_stop + if _async_stop: + loop, stop_ev = _async_stop + loop.call_soon_threadsafe(stop_ev.set) + if _proxy_thread: + _proxy_thread.join(timeout=2) + _proxy_thread = None + log.info("Proxy stopped") -def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - result = _ask_yes_no_close(text, title) - return result is True +def _restart_proxy() -> None: + log.info("Restarting proxy...") + _stop_proxy() + time.sleep(0.3) + _start_proxy() -def _ask_yes_no_close(text: str, - title: str = "TG WS Proxy") -> Optional[bool]: - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'button returned of (display dialog "{text_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "Нет", "Да"}} ' - f'default button "Да" cancel button "Закрыть" with icon note)'], - capture_output=True, text=True) - if r.returncode != 0: - return None - - result = r.stdout.strip() - if result == "Да": - return True - if result == "Нет": - return False - return None +# menu callbacks -# Proxy lifecycle - -def start_proxy(): - _runtime.start_proxy(_config) - - -def stop_proxy(): - _runtime.stop_proxy() - - -def restart_proxy(): - _runtime.restart_proxy() - - -# Menu callbacks - -def _on_open_in_telegram(_=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" +def _on_open_in_telegram(_=None) -> None: + url = tg_proxy_url(_config) log.info("Opening %s", url) try: - result = subprocess.call(['open', url]) + result = subprocess.call(["open", url]) if result != 0: raise RuntimeError("open command failed") except Exception: @@ -277,67 +221,58 @@ def _on_open_in_telegram(_=None): if pyperclip: pyperclip.copy(url) else: - subprocess.run(['pbcopy'], input=url.encode(), - check=True) + subprocess.run(["pbcopy"], input=url.encode(), check=True) _show_info( "Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена:\n{url}") + f"Ссылка скопирована в буфер обмена:\n{url}" + ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_restart(_=None): - def _do_restart(): +def _on_copy_link(_=None) -> None: + url = tg_proxy_url(_config) + log.info("Copying link: %s", url) + try: + if pyperclip: + pyperclip.copy(url) + else: + subprocess.run(["pbcopy"], input=url.encode(), check=True) + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") + + +def _on_restart(_=None) -> None: + def _do(): global _config _config = load_config() if _app: _app.update_menu_title() - restart_proxy() + _restart_proxy() - threading.Thread(target=_do_restart, daemon=True).start() + threading.Thread(target=_do, daemon=True).start() -def _on_open_logs(_=None): +def _on_open_logs(_=None) -> None: log.info("Opening log file: %s", LOG_FILE) if LOG_FILE.exists(): - subprocess.call(['open', str(LOG_FILE)]) + subprocess.call(["open", str(LOG_FILE)]) else: _show_info("Файл логов ещё не создан.") -# Show a native text input dialog. Returns None if cancelled. -def _osascript_input(prompt: str, default: str, - title: str = "TG WS Proxy") -> Optional[str]: - prompt_esc = _escape_osascript_text(prompt) - default_esc = _escape_osascript_text(default) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'text returned of (display dialog "{prompt_esc}" ' - f'default answer "{default_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Закрыть", "OK"}} ' - f'default button "OK" cancel button "Закрыть")'], - capture_output=True, text=True) - if r.returncode != 0: - return None - return r.stdout.rstrip("\r\n") - -def _on_edit_config(_=None): +def _on_edit_config(_=None) -> None: threading.Thread(target=_edit_config_dialog, daemon=True).start() def _check_updates_menu_title() -> str: on = bool(_config.get("check_updates", True)) - return ( - "✓ Проверять обновления при запуске" - if on - else "Проверять обновления при запуске (выкл)" - ) + return "✓ Проверять обновления при запуске" if on else "Проверять обновления при запуске (выкл)" -def _toggle_check_updates(_=None): +def _toggle_check_updates(_=None) -> None: global _config _config["check_updates"] = not bool(_config.get("check_updates", True)) save_config(_config) @@ -345,12 +280,15 @@ def _toggle_check_updates(_=None): _app._check_updates_item.title = _check_updates_menu_title() -def _on_open_release_page(_=None): +def _on_open_release_page(_=None) -> None: from utils.update_check import RELEASES_PAGE_URL webbrowser.open(RELEASES_PAGE_URL) -def _maybe_notify_update_async(): +# update check + + +def _maybe_notify_update_async() -> None: def _work(): time.sleep(1.5) if _exiting: @@ -366,8 +304,7 @@ def _maybe_notify_update_async(): url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL ver = st.get("latest") or "?" if _ask_yes_no( - f"Доступна новая версия: {ver}\n\n" - f"Открыть страницу релиза в браузере?", + f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", "TG WS Proxy — обновление", ): webbrowser.open(url) @@ -377,18 +314,16 @@ def _maybe_notify_update_async(): threading.Thread(target=_work, daemon=True, name="update-check").start() -# Settings via native macOS dialogs -def _edit_config_dialog(): +# settings dialog + + +def _edit_config_dialog() -> None: cfg = load_config() - # Host - host = _osascript_input( - "IP-адрес прокси:", - cfg.get("host", DEFAULT_CONFIG["host"])) + host = _osascript_input("IP-адрес прокси:", cfg.get("host", DEFAULT_CONFIG["host"])) if host is None: return host = host.strip() - import socket as _sock try: _sock.inet_aton(host) @@ -396,10 +331,7 @@ def _edit_config_dialog(): _show_error("Некорректный IP-адрес.") return - # Port - port_str = _osascript_input( - "Порт прокси:", - str(cfg.get("port", DEFAULT_CONFIG["port"]))) + port_str = _osascript_input("Порт прокси:", str(cfg.get("port", DEFAULT_CONFIG["port"]))) if port_str is None: return try: @@ -410,42 +342,49 @@ def _edit_config_dialog(): _show_error("Порт должен быть числом 1-65535") return - # DC-IP mappings + secret_str = _osascript_input( + "MTProto Secret (32 hex символа):", cfg.get("secret", DEFAULT_CONFIG["secret"]) + ) + if secret_str is None: + return + secret_str = secret_str.strip().lower() + if len(secret_str) != 32 or not all(c in "0123456789abcdef" for c in secret_str): + _show_error("Secret должен быть строкой из 32 шестнадцатеричных символов.") + return + dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) dc_str = _osascript_input( "DC → IP маппинги (через запятую, формат DC:IP):\n" "Например: 2:149.154.167.220, 4:149.154.167.220", - dc_default) + dc_default, + ) if dc_str is None: return - dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() - if s.strip()] + dc_lines = [s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()] try: tg_ws_proxy.parse_dc_ip_list(dc_lines) except ValueError as e: _show_error(str(e)) return - # Verbose verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?") if verbose is None: return - # Advanced settings adv_str = _osascript_input( "Расширенные настройки (буфер KB, WS пул, лог MB):\n" "Формат: buf_kb,pool_size,log_max_mb", f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," - f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") + f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}", + ) if adv_str is None: return adv = {} if adv_str: - parts = [s.strip() for s in adv_str.split(',')] - keys = [("buf_kb", int), ("pool_size", int), - ("log_max_mb", float)] + parts = [s.strip() for s in adv_str.split(",")] + keys = [("buf_kb", int), ("pool_size", int), ("log_max_mb", float)] for i, (k, typ) in enumerate(keys): if i < len(parts): try: @@ -456,11 +395,13 @@ def _edit_config_dialog(): new_cfg = { "host": host, "port": port, + "secret": secret_str, "dc_ip": dc_lines, "verbose": verbose, "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), "pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), + "check_updates": cfg.get("check_updates", True), } save_config(new_cfg) log.info("Config saved: %s", new_cfg) @@ -470,21 +411,23 @@ def _edit_config_dialog(): if _app: _app.update_menu_title() - if _ask_yes_no_close( - "Настройки сохранены.\n\nПерезапустить прокси сейчас?"): - restart_proxy() + if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"): + _restart_proxy() -# First-run & IPv6 dialogs +# first run & ipv6 -def _show_first_run(): - _ensure_dirs() + +def _show_first_run() -> None: + ensure_dirs() if FIRST_RUN_MARKER.exists(): return host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) + tg_url = tg_proxy_url(_config) + link_host = tg_ws_proxy.get_link_host(host) text = ( f"Прокси запущен и работает в строке меню.\n\n" @@ -494,54 +437,54 @@ def _show_first_run(): f" Или ссылка: {tg_url}\n\n" f"Вручную:\n" f" Настройки → Продвинутые → Тип подключения → Прокси\n" - f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n" + f" MTProto → {link_host} : {port} \n" + f" Secret: dd{secret} \n\n" f"Открыть прокси в Telegram сейчас?" ) FIRST_RUN_MARKER.touch() - if _ask_yes_no(text, "TG WS Proxy"): _on_open_in_telegram() -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() +def _check_ipv6_warning() -> None: + ensure_dirs() if IPV6_WARN_MARKER.exists(): return - if not _has_ipv6_enabled(): + + import socket as _sock + has = False + try: + for addr in _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6): + ip = addr[4][0] + if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): + has = True + break + except Exception: + pass + if not has: + try: + s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) + s.bind(("::1", 0)) + s.close() + has = True + except Exception: + pass + if not has: return IPV6_WARN_MARKER.touch() - _show_info( "На вашем компьютере включена поддержка подключения по IPv6.\n\n" "Telegram может пытаться подключаться через IPv6, " "что не поддерживается и может привести к ошибкам.\n\n" "Если прокси не работает, попробуйте отключить " "попытку соединения по IPv6 в настройках прокси Telegram.\n\n" - "Это предупреждение будет показано только один раз.") + "Это предупреждение будет показано только один раз." + ) -# rumps menubar app +# rumps app _TgWsProxyAppBase = rumps.App if rumps else object @@ -549,33 +492,26 @@ _TgWsProxyAppBase = rumps.App if rumps else object class TgWsProxyApp(_TgWsProxyAppBase): def __init__(self): _ensure_menubar_icon() - icon_path = (str(MENUBAR_ICON_PATH) - if MENUBAR_ICON_PATH.exists() else None) + icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) self._open_tg_item = rumps.MenuItem( - f"Открыть в Telegram ({host}:{port})", - callback=_on_open_in_telegram) - self._restart_item = rumps.MenuItem( - "Перезапустить прокси", - callback=_on_restart) - self._settings_item = rumps.MenuItem( - "Настройки...", - callback=_on_edit_config) - self._logs_item = rumps.MenuItem( - "Открыть логи", - callback=_on_open_logs) + f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram + ) + self._copy_link_item = rumps.MenuItem("Скопировать ссылку", callback=_on_copy_link) + self._restart_item = rumps.MenuItem("Перезапустить прокси", callback=_on_restart) + self._settings_item = rumps.MenuItem("Настройки...", callback=_on_edit_config) + self._logs_item = rumps.MenuItem("Открыть логи", callback=_on_open_logs) self._release_page_item = rumps.MenuItem( - "Страница релиза на GitHub…", - callback=_on_open_release_page) + "Страница релиза на GitHub…", callback=_on_open_release_page + ) self._check_updates_item = rumps.MenuItem( - _check_updates_menu_title(), - callback=_toggle_check_updates) - self._version_item = rumps.MenuItem( - f"Версия {__version__}", - callback=lambda _: None) + _check_updates_menu_title(), callback=_toggle_check_updates + ) + self._version_item = rumps.MenuItem(f"Версия {__version__}", callback=lambda _: None) super().__init__( "TG WS Proxy", @@ -584,6 +520,7 @@ class TgWsProxyApp(_TgWsProxyAppBase): quit_button="Выход", menu=[ self._open_tg_item, + self._copy_link_item, None, self._restart_item, self._settings_item, @@ -593,41 +530,51 @@ class TgWsProxyApp(_TgWsProxyAppBase): self._check_updates_item, None, self._version_item, - ]) + ], + ) - def update_menu_title(self): + def update_menu_title(self) -> None: host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - self._open_tg_item.title = ( - f"Открыть в Telegram ({host}:{port})") + link_host = tg_ws_proxy.get_link_host(host) + self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})" -def run_menubar(): +# entry point + + +def run_menubar() -> None: global _app, _config - _config = _runtime.prepare() - _runtime.reset_log_file() + _config = load_config() + save_config(_config) - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) + if LOG_FILE.exists(): + try: + LOG_FILE.unlink() + except Exception: + pass + + setup_logging( + _config.get("verbose", False), + log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) log.info("TG WS Proxy версия %s, menubar app starting", __version__) log.info("Config: %s", _config) log.info("Log file: %s", LOG_FILE) if rumps is None or Image is None: log.error("rumps or Pillow not installed; running in console mode") - start_proxy() + _start_proxy() try: while True: time.sleep(1) except KeyboardInterrupt: - stop_proxy() + _stop_proxy() return - start_proxy() - + _start_proxy() _maybe_notify_update_async() - _show_first_run() _check_ipv6_warning() @@ -635,19 +582,18 @@ def run_menubar(): log.info("Menubar app running") _app.run() - stop_proxy() + _stop_proxy() log.info("Menubar app exited") -def main(): - if not _acquire_lock(): +def main() -> None: + if not acquire_lock("macos.py"): _show_info("Приложение уже запущено.") return - try: run_menubar() finally: - _release_lock() + release_lock() if __name__ == "__main__": diff --git a/proxy/__init__.py b/proxy/__init__.py index 9e2406e..d60e0c1 100644 --- a/proxy/__init__.py +++ b/proxy/__init__.py @@ -1 +1 @@ -__version__ = "1.3.0" \ No newline at end of file +__version__ = "1.4.0" \ No newline at end of file diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index cac4594..21ba025 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -1,95 +1,86 @@ from __future__ import annotations -import argparse -import asyncio -import base64 -import logging -from collections import deque -import logging.handlers import os -import socket as _socket import ssl -import struct import sys import time +import base64 +import struct +import asyncio +import hashlib +import argparse +import logging +import logging.handlers +import socket as _socket + +from collections import deque +from dataclasses import dataclass, field from typing import Dict, List, Optional, Set, Tuple -from proxy.crypto_backend import create_aes_ctr_transform +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -DEFAULT_PORT = 1080 -log = logging.getLogger('tg-ws-proxy') +@dataclass +class ProxyConfig: + port: int = 1443 + host: str = '127.0.0.1' + secret: str = field(default_factory=lambda: os.urandom(16).hex()) + dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'}) + dc_overrides: Dict[int, int] = field(default_factory=lambda: {203: 2}) + buffer_size: int = 256 * 1024 + pool_size: int = 4 -_TCP_NODELAY = True -_RECV_BUF = 256 * 1024 -_SEND_BUF = 256 * 1024 -_WS_POOL_SIZE = 4 -_WS_POOL_MAX_AGE = 120.0 -_TG_RANGES = [ - # 185.76.151.0/24 - (struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0], - struct.unpack('!I', _socket.inet_aton('185.76.151.255'))[0]), - # 149.154.160.0/20 - (struct.unpack('!I', _socket.inet_aton('149.154.160.0'))[0], - struct.unpack('!I', _socket.inet_aton('149.154.175.255'))[0]), - # 91.105.192.0/23 - (struct.unpack('!I', _socket.inet_aton('91.105.192.0'))[0], - struct.unpack('!I', _socket.inet_aton('91.105.193.255'))[0]), - # 91.108.0.0/16 - (struct.unpack('!I', _socket.inet_aton('91.108.0.0'))[0], - struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]), -] +proxy_config = ProxyConfig() +log = logging.getLogger('tg-mtproto-proxy') -# IP -> (dc_id, is_media) -_IP_TO_DC: Dict[str, Tuple[int, bool]] = { - # DC1 - '149.154.175.50': (1, False), '149.154.175.51': (1, False), - '149.154.175.53': (1, False), '149.154.175.54': (1, False), - '149.154.175.52': (1, True), - # DC2 - '149.154.167.41': (2, False), '149.154.167.50': (2, False), - '149.154.167.51': (2, False), '149.154.167.220': (2, False), - '95.161.76.100': (2, False), - '149.154.167.151': (2, True), '149.154.167.222': (2, True), - '149.154.167.223': (2, True), '149.154.162.123': (2, True), - # DC3 - '149.154.175.100': (3, False), '149.154.175.101': (3, False), - '149.154.175.102': (3, True), - # DC4 - '149.154.167.91': (4, False), '149.154.167.92': (4, False), - '149.154.164.250': (4, True), '149.154.166.120': (4, True), - '149.154.166.121': (4, True), '149.154.167.118': (4, True), - '149.154.165.111': (4, True), - # DC5 - '91.108.56.100': (5, False), '91.108.56.101': (5, False), - '91.108.56.116': (5, False), '91.108.56.126': (5, False), - '149.154.171.5': (5, False), - '91.108.56.102': (5, True), '91.108.56.128': (5, True), - '91.108.56.151': (5, True), - # DC203 - '91.105.192.100': (203, False), +DC_DEFAULT_IPS: Dict[int, str] = { + 1: '149.154.175.50', + 2: '149.154.167.51', + 3: '149.154.175.100', + 4: '149.154.167.91', + 5: '149.154.171.5', + 203: '91.105.192.100' } -# This case might work but not actually sure -_DC_OVERRIDES: Dict[int, int] = { - 203: 2 -} +HANDSHAKE_LEN = 64 +SKIP_LEN = 8 +PREKEY_LEN = 32 +KEY_LEN = 32 +IV_LEN = 16 +PROTO_TAG_POS = 56 +DC_IDX_POS = 60 -_dc_opt: Dict[int, Optional[str]] = {} +PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef' +PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee' +PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd' -# DCs where WS is known to fail (302 redirect) -# Raw TCP fallback will be used instead -# Keyed by (dc, is_media) -_ws_blacklist: Set[Tuple[int, bool]] = set() +PROTO_ABRIDGED_INT = 0xEFEFEFEF +PROTO_INTERMEDIATE_INT = 0xEEEEEEEE +PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD -# Rate-limit re-attempts per (dc, is_media) -_dc_fail_until: Dict[Tuple[int, bool], float] = {} -_DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure -_WS_FAIL_TIMEOUT = 2.0 # quick-retry timeout after a recent WS failure +RESERVED_FIRST_BYTES = {0xEF} +RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54', + b'\x47\x45\x54\x20', b'\xee\xee\xee\xee', + b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'} +RESERVED_CONTINUE = b'\x00\x00\x00\x00' -_ZERO_64 = b'\x00' * 64 +DC_FAIL_COOLDOWN = 30.0 +WS_FAIL_TIMEOUT = 2.0 +ws_blacklist: Set[Tuple[int, bool]] = set() +dc_fail_until: Dict[Tuple[int, bool], float] = {} +_st_BB = struct.Struct('>BB') +_st_BBH = struct.Struct('>BBH') +_st_BBQ = struct.Struct('>BBQ') +_st_BB4s = struct.Struct('>BB4s') +_st_BBH4s = struct.Struct('>BBH4s') +_st_BBQ4s = struct.Struct('>BBQ4s') +_st_H = struct.Struct('>H') +_st_Q = struct.Struct('>Q') +_st_I_le = struct.Struct(' bytes: + if not data: + return data + n = len(data) + mask_rep = (mask * (n // 4 + 1))[:n] + return (int.from_bytes(data, 'big') ^ + int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') + + +def get_link_host(host: str) -> Optional[str]: + if host == '0.0.0.0': + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s: + _s.connect(('8.8.8.8', 80)) + link_host = _s.getsockname()[0] + except OSError: + link_host = '127.0.0.1' + return link_host + else: + return host + + class WsHandshakeError(Exception): def __init__(self, status_code: int, status_line: str, headers: dict = None, location: str = None): @@ -126,48 +140,9 @@ class WsHandshakeError(Exception): return self.status_code in (301, 302, 303, 307, 308) -def _xor_mask(data: bytes, mask: bytes) -> bytes: - if not data: - return data - n = len(data) - mask_rep = (mask * (n // 4 + 1))[:n] - return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') - - -# Pre-compiled struct formats -_st_BB = struct.Struct('>BB') -_st_BBH = struct.Struct('>BBH') -_st_BBQ = struct.Struct('>BBQ') -_st_BB4s = struct.Struct('>BB4s') -_st_BBH4s = struct.Struct('>BBH4s') -_st_BBQ4s = struct.Struct('>BBQ4s') -_st_H = struct.Struct('>H') -_st_Q = struct.Struct('>Q') -_st_I_net = struct.Struct('!I') -_st_Ih = struct.Struct(' 'RawWebSocket': - """ - Connect via TLS to the given IP, - perform WebSocket upgrade, return a RawWebSocket. - - Raises WsHandshakeError on non-101 response. - """ reader, writer = await asyncio.wait_for( asyncio.open_connection(ip, 443, ssl=_ssl_ctx, server_hostname=domain), @@ -212,8 +181,6 @@ class RawWebSocket: writer.write(req.encode()) await writer.drain() - # Read HTTP response headers line-by-line so the reader stays - # positioned right at the start of WebSocket frames. response_lines: list[str] = [] try: while True: @@ -252,7 +219,6 @@ class RawWebSocket: location=headers.get('location')) async def send(self, data: bytes): - """Send a masked binary WebSocket frame.""" if self._closed: raise ConnectionError("WebSocket closed") frame = self._build_frame(self.OP_BINARY, data, mask=True) @@ -260,30 +226,23 @@ class RawWebSocket: await self.writer.drain() async def send_batch(self, parts: List[bytes]): - """Send multiple binary frames with a single drain (less overhead).""" if self._closed: raise ConnectionError("WebSocket closed") for part in parts: - frame = self._build_frame(self.OP_BINARY, part, mask=True) - self.writer.write(frame) + self.writer.write( + self._build_frame(self.OP_BINARY, part, mask=True)) await self.writer.drain() async def recv(self) -> Optional[bytes]: - """ - Receive the next data frame. Handles ping/pong/close - internally. Returns payload bytes, or None on clean close. - """ while not self._closed: opcode, payload = await self._read_frame() if opcode == self.OP_CLOSE: self._closed = True try: - reply = self._build_frame( + self.writer.write(self._build_frame( self.OP_CLOSE, - payload[:2] if payload else b'', - mask=True) - self.writer.write(reply) + payload[:2] if payload else b'', mask=True)) await self.writer.drain() except Exception: pass @@ -291,9 +250,8 @@ class RawWebSocket: if opcode == self.OP_PING: try: - pong = self._build_frame(self.OP_PONG, payload, - mask=True) - self.writer.write(pong) + self.writer.write( + self._build_frame(self.OP_PONG, payload, mask=True)) await self.writer.drain() except Exception: pass @@ -302,16 +260,12 @@ class RawWebSocket: if opcode == self.OP_PONG: continue - if opcode in (self.OP_TEXT, self.OP_BINARY): + if opcode in (0x1, 0x2): return payload - - # Unknown opcode — skip continue - return None async def close(self): - """Send close frame and shut down the transport.""" if self._closed: return self._closed = True @@ -332,14 +286,12 @@ class RawWebSocket: mask: bool = False) -> bytes: length = len(data) fb = 0x80 | opcode - if not mask: if length < 126: return _st_BB.pack(fb, length) + data if length < 65536: return _st_BBH.pack(fb, 126, length) + data return _st_BBQ.pack(fb, 127, length) + data - mask_key = os.urandom(4) masked = _xor_mask(data, mask_key) if length < 126: @@ -352,19 +304,14 @@ class RawWebSocket: hdr = await self.reader.readexactly(2) opcode = hdr[0] & 0x0F length = hdr[1] & 0x7F - if length == 126: - length = _st_H.unpack( - await self.reader.readexactly(2))[0] + length = _st_H.unpack(await self.reader.readexactly(2))[0] elif length == 127: - length = _st_Q.unpack( - await self.reader.readexactly(8))[0] - + length = _st_Q.unpack(await self.reader.readexactly(8))[0] if hdr[1] & 0x80: mask_key = await self.reader.readexactly(4) payload = await self.reader.readexactly(length) return opcode, _xor_mask(payload, mask_key) - payload = await self.reader.readexactly(length) return opcode, payload @@ -377,95 +324,85 @@ def _human_bytes(n: int) -> str: return f"{n:.1f}TB" -def _is_telegram_ip(ip: str) -> bool: - try: - n = _st_I_net.unpack(_socket.inet_aton(ip))[0] - return any(lo <= n <= hi for lo, hi in _TG_RANGES) - except OSError: - return False +def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]: + dec_prekey_and_iv = handshake[SKIP_LEN:SKIP_LEN + PREKEY_LEN + IV_LEN] + dec_prekey = dec_prekey_and_iv[:PREKEY_LEN] + dec_iv = dec_prekey_and_iv[PREKEY_LEN:] + + dec_key = hashlib.sha256(dec_prekey + secret).digest() + + dec_iv_int = int.from_bytes(dec_iv, 'big') + decryptor = Cipher( + algorithms.AES(dec_key), modes.CTR(dec_iv_int.to_bytes(16, 'big')) + ).encryptor() + decrypted = decryptor.update(handshake) + + proto_tag = decrypted[PROTO_TAG_POS:PROTO_TAG_POS + 4] + if proto_tag not in (PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, + PROTO_TAG_SECURE): + return None + + dc_idx = int.from_bytes( + decrypted[DC_IDX_POS:DC_IDX_POS + 2], 'little', signed=True) + + dc_id = abs(dc_idx) + is_media = dc_idx < 0 + + return dc_id, is_media, proto_tag, dec_prekey_and_iv -def _is_http_transport(data: bytes) -> bool: - return (data[:5] == b'POST ' or data[:4] == b'GET ' or - data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ') +def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes: + while True: + rnd = bytearray(os.urandom(HANDSHAKE_LEN)) + if rnd[0] in RESERVED_FIRST_BYTES: + continue + if bytes(rnd[:4]) in RESERVED_STARTS: + continue + if rnd[4:8] == RESERVED_CONTINUE: + continue + break + rnd_bytes = bytes(rnd) -def _dc_from_init(data: bytes, *, return_proto: bool = False): - try: - key = bytes(data[8:40]) - iv = bytes(data[40:56]) - encryptor = create_aes_ctr_transform(key, iv) - keystream = encryptor.update(b'\x00' * 64) + encryptor.finalize() - plain = bytes(a ^ b for a, b in zip(data[56:64], keystream[56:64])) - proto = struct.unpack(' bytes: - """ - Patch dc_id in the 64-byte MTProto init packet. + encryptor = Cipher( + algorithms.AES(enc_key), modes.CTR(enc_iv) + ).encryptor() - Mobile clients with useSecret=0 leave bytes 60-61 as random. - The WS relay needs a valid dc_id to route correctly. - """ - if len(data) < 64: - return data + dc_bytes = struct.pack(' %d", dc) - if len(data) > 64: - return bytes(patched) + data[64:] - return bytes(patched) - except Exception: - return data + encrypted_full = encryptor.update(rnd_bytes) + keystream_tail = bytes( + encrypted_full[i] ^ rnd_bytes[i] for i in range(56, 64)) + encrypted_tail = bytes( + tail_plain[i] ^ keystream_tail[i] for i in range(8)) + + result = bytearray(rnd_bytes) + result[PROTO_TAG_POS:HANDSHAKE_LEN] = encrypted_tail + return bytes(result) class _MsgSplitter: """ - Splits client TCP data into individual MTProto transport packets so - each can be sent as a separate WebSocket frame. - - Some mobile clients coalesce multiple MTProto packets into one TCP - write, and TCP reads may also cut a packet in half. Keep a rolling - buffer so incomplete packets are not forwarded as standalone frames. + Splits TCP stream data into individual MTProto transport packets + so each can be sent as a separate WS frame. """ - __slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled') - def __init__(self, init_data: bytes, proto: Optional[int] = None): - if proto is None: - _, _, proto = _dc_from_init(init_data, return_proto=True) - key_raw = bytes(init_data[8:40]) - iv = bytes(init_data[40:56]) - self._dec = create_aes_ctr_transform(key_raw, iv) - self._dec.update(b'\x00' * 64) # skip init packet - self._proto = proto + def __init__(self, relay_init: bytes, proto_int: int): + cipher = Cipher(algorithms.AES(relay_init[8:40]), + modes.CTR(relay_init[40:56])) + self._dec = cipher.encryptor() + self._dec.update(ZERO_64) + self._proto = proto_int self._cipher_buf = bytearray() self._plain_buf = bytearray() self._disabled = False def split(self, chunk: bytes) -> List[bytes]: - """Decrypt to find packet boundaries, return complete ciphertext packets.""" if not chunk: return [] if self._disabled: @@ -501,9 +438,10 @@ class _MsgSplitter: def _next_packet_len(self) -> Optional[int]: if not self._plain_buf: return None - if self._proto == _PROTO_ABRIDGED: + if self._proto == PROTO_ABRIDGED_INT: return self._next_abridged_len() - if self._proto in (_PROTO_INTERMEDIATE, _PROTO_PADDED_INTERMEDIATE): + if self._proto in (PROTO_INTERMEDIATE_INT, + PROTO_PADDED_INTERMEDIATE_INT): return self._next_intermediate_len() return 0 @@ -517,10 +455,8 @@ class _MsgSplitter: else: payload_len = (first & 0x7F) * 4 header_len = 1 - if payload_len <= 0: return 0 - packet_len = header_len + payload_len if len(self._plain_buf) < packet_len: return None @@ -529,11 +465,9 @@ class _MsgSplitter: def _next_intermediate_len(self) -> Optional[int]: if len(self._plain_buf) < 4: return None - payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF if payload_len <= 0: return 0 - packet_len = 4 + payload_len if len(self._plain_buf) < packet_len: return None @@ -541,7 +475,7 @@ class _MsgSplitter: def _ws_domains(dc: int, is_media) -> List[str]: - dc = _DC_OVERRIDES.get(dc, dc) + dc = proxy_config.dc_overrides.get(dc, dc) if is_media is None or is_media: return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] @@ -550,10 +484,10 @@ def _ws_domains(dc: int, is_media) -> List[str]: class Stats: def __init__(self): self.connections_total = 0 + self.connections_active = 0 self.connections_ws = 0 self.connections_tcp_fallback = 0 - self.connections_http_rejected = 0 - self.connections_passthrough = 0 + self.connections_bad = 0 self.ws_errors = 0 self.bytes_up = 0 self.bytes_down = 0 @@ -562,37 +496,24 @@ class Stats: def summary(self) -> str: pool_total = self.pool_hits + self.pool_misses - pool_s = ( - f"{self.pool_hits}/{pool_total}" if pool_total else "n/a") - return (f"total={self.connections_total} ws={self.connections_ws} " + pool_s = (f"{self.pool_hits}/{pool_total}" + if pool_total else "n/a") + return (f"total={self.connections_total} " + f"active={self.connections_active} " + f"ws={self.connections_ws} " f"tcp_fb={self.connections_tcp_fallback} " - f"http_skip={self.connections_http_rejected} " - f"pass={self.connections_passthrough} " + f"bad={self.connections_bad} " f"err={self.ws_errors} " f"pool={pool_s} " f"up={_human_bytes(self.bytes_up)} " f"down={_human_bytes(self.bytes_down)}") - _stats = Stats() -def reset_stats() -> None: - global _stats - _stats = Stats() - - -def get_stats_snapshot() -> Dict[str, int]: - return { - "bytes_up": _stats.bytes_up, - "bytes_down": _stats.bytes_down, - "connections_total": _stats.connections_total, - "connections_ws": _stats.connections_ws, - "connections_tcp_fallback": _stats.connections_tcp_fallback, - } - - class _WsPool: + WS_POOL_MAX_AGE = 120.0 + def __init__(self): self._idle: Dict[Tuple[int, bool], deque] = {} self._refilling: Set[Tuple[int, bool]] = set() @@ -610,11 +531,12 @@ class _WsPool: while bucket: ws, created = bucket.popleft() age = now - created - if age > _WS_POOL_MAX_AGE or ws._closed: + if (age > self.WS_POOL_MAX_AGE or ws._closed + or ws.writer.transport.is_closing()): asyncio.create_task(self._quiet_close(ws)) continue _stats.pool_hits += 1 - log.debug("WS pool hit for DC%d%s (age=%.1fs, left=%d)", + log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)", dc, 'm' if is_media else '', age, len(bucket)) self._schedule_refill(key, target_ip, domains) return ws @@ -633,13 +555,12 @@ class _WsPool: dc, is_media = key try: bucket = self._idle.setdefault(key, deque()) - needed = _WS_POOL_SIZE - len(bucket) + needed = proxy_config.pool_size - len(bucket) if needed <= 0: return - tasks = [] - for _ in range(needed): - tasks.append(asyncio.create_task( - self._connect_one(target_ip, domains))) + tasks = [asyncio.create_task( + self._connect_one(target_ip, domains)) + for _ in range(needed)] for t in tasks: try: ws = await t @@ -656,9 +577,8 @@ class _WsPool: async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]: for domain in domains: try: - ws = await RawWebSocket.connect( + return await RawWebSocket.connect( target_ip, domain, timeout=8) - return ws except WsHandshakeError as exc: if exc.is_redirect: continue @@ -674,33 +594,35 @@ class _WsPool: except Exception: pass - async def warmup(self, dc_opt: Dict[int, Optional[str]]): - """Pre-fill pool for all configured DCs on startup.""" - for dc, target_ip in dc_opt.items(): + async def warmup(self, dc_redirects: Dict[int, Optional[str]]): + for dc, target_ip in dc_redirects.items(): if target_ip is None: continue for is_media in (False, True): domains = _ws_domains(dc, is_media) - key = (dc, is_media) - self._schedule_refill(key, target_ip, domains) - log.info("WS pool warmup started for %d DC(s)", len(dc_opt)) - + self._schedule_refill((dc, is_media), target_ip, domains) + log.info("WS pool warmup started for %d DC(s)", len(dc_redirects)) _ws_pool = _WsPool() -async def _bridge_ws(reader, writer, ws: RawWebSocket, label, - dc=None, dst=None, port=None, is_media=False, - splitter: _MsgSplitter = None): - """Bidirectional TCP <-> WebSocket forwarding.""" +async def _bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, + dc=None, is_media=False, + clt_decryptor=None, clt_encryptor=None, + tg_encryptor=None, tg_decryptor=None, + splitter: _MsgSplitter = None): + """ + Bidirectional TCP(client) <-> WS(telegram) with re-encryption. + client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS + WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP + """ dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?" - dst_tag = f"{dst}:{port}" if dst else "?" up_bytes = 0 down_bytes = 0 up_packets = 0 down_packets = 0 - start_time = asyncio.get_event_loop().time() + start_time = asyncio.get_running_loop().time() async def tcp_to_ws(): nonlocal up_bytes, up_packets @@ -717,6 +639,8 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, _stats.bytes_up += n up_bytes += n up_packets += 1 + plain = clt_decryptor.update(chunk) + chunk = tg_encryptor.update(plain) if splitter: parts = splitter.split(chunk) if not parts: @@ -743,6 +667,8 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, _stats.bytes_down += n down_bytes += n down_packets += 1 + plain = tg_decryptor.update(data) + data = clt_encryptor.update(plain) writer.write(data) await writer.drain() except (asyncio.CancelledError, ConnectionError, OSError): @@ -762,10 +688,10 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, await t except BaseException: pass - elapsed = asyncio.get_event_loop().time() - start_time - log.info("[%s] %s (%s) WS session closed: " + elapsed = asyncio.get_running_loop().time() - start_time + log.info("[%s] %s WS session closed: " "^%s (%d pkts) v%s (%d pkts) in %.1fs", - label, dc_tag, dst_tag, + label, dc_tag, _human_bytes(up_bytes), up_packets, _human_bytes(down_bytes), down_packets, elapsed) @@ -780,10 +706,12 @@ async def _bridge_ws(reader, writer, ws: RawWebSocket, label, pass -async def _bridge_tcp(reader, writer, remote_reader, remote_writer, - label, dc=None, dst=None, port=None, - is_media=False): - """Bidirectional TCP <-> TCP forwarding (for fallback).""" +async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer, + label, dc=None, is_media=False, + clt_decryptor=None, clt_encryptor=None, + tg_encryptor=None, tg_decryptor=None): + """Bidirectional TCP <-> TCP with re-encryption.""" + async def forward(src, dst_w, is_up): try: while True: @@ -793,8 +721,12 @@ async def _bridge_tcp(reader, writer, remote_reader, remote_writer, n = len(data) if is_up: _stats.bytes_up += n + plain = clt_decryptor.update(data) + data = tg_encryptor.update(plain) else: _stats.bytes_down += n + plain = tg_decryptor.update(data) + data = clt_encryptor.update(plain) dst_w.write(data) await dst_w.drain() except asyncio.CancelledError: @@ -824,214 +756,161 @@ async def _bridge_tcp(reader, writer, remote_reader, remote_writer, pass -async def _pipe(r, w): - """Plain TCP relay for non-Telegram traffic.""" - try: - while True: - data = await r.read(65536) - if not data: - break - w.write(data) - await w.drain() - except asyncio.CancelledError: - pass - except Exception: - pass - finally: - try: - w.close() - await w.wait_closed() - except Exception: - pass - - -_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) - for s in (0x00, 0x05, 0x07, 0x08)} - - -def _socks5_reply(status): - return _SOCKS5_REPLIES[status] - - -async def _tcp_fallback(reader, writer, dst, port, init, label, - dc=None, is_media=False): - """ - Fall back to direct TCP to the original DC IP. - Throttled by ISP, but functional. Returns True on success. - """ +async def _tcp_fallback(reader, writer, dst, port, relay_init, label, + dc=None, is_media=False, + clt_decryptor=None, clt_encryptor=None, + tg_encryptor=None, tg_decryptor=None): try: rr, rw = await asyncio.wait_for( asyncio.open_connection(dst, port), timeout=10) except Exception as exc: - log.warning("[%s] TCP fallback connect to %s:%d failed: %s", + log.warning("[%s] TCP fallback to %s:%d failed: %s", label, dst, port, exc) return False _stats.connections_tcp_fallback += 1 - rw.write(init) + rw.write(relay_init) await rw.drain() - await _bridge_tcp(reader, writer, rr, rw, label, - dc=dc, dst=dst, port=port, is_media=is_media) + await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, + dc=dc, is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor) return True -async def _handle_client(reader, writer): +def _fallback_ip(dc: int) -> Optional[str]: + return DC_DEFAULT_IPS.get(dc) + + +async def _handle_client(reader, writer, secret: bytes): _stats.connections_total += 1 + _stats.connections_active += 1 peer = writer.get_extra_info('peername') label = f"{peer[0]}:{peer[1]}" if peer else "?" _set_sock_opts(writer.transport) try: - # -- SOCKS5 greeting -- - hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10) - if hdr[0] != 5: - log.debug("[%s] not SOCKS5 (ver=%d)", label, hdr[0]) - writer.close() - return - nmethods = hdr[1] - await reader.readexactly(nmethods) - writer.write(b'\x05\x00') # no-auth - await writer.drain() - - # -- SOCKS5 CONNECT request -- - req = await asyncio.wait_for(reader.readexactly(4), timeout=10) - _ver, cmd, _rsv, atyp = req - if cmd != 1: - writer.write(_socks5_reply(0x07)) - await writer.drain() - writer.close() - return - - if atyp == 1: # IPv4 - raw = await reader.readexactly(4) - dst = _socket.inet_ntoa(raw) - elif atyp == 3: # domain - dlen = (await reader.readexactly(1))[0] - dst = (await reader.readexactly(dlen)).decode() - elif atyp == 4: # IPv6 - raw = await reader.readexactly(16) - dst = _socket.inet_ntop(_socket.AF_INET6, raw) - else: - writer.write(_socks5_reply(0x08)) - await writer.drain() - writer.close() - return - - port = _st_H.unpack(await reader.readexactly(2))[0] - - if ':' in dst: - log.error( - "[%s] IPv6 address detected: %s:%d — " - "IPv6 addresses are not supported; " - "disable IPv6 to continue using the proxy.", - label, dst, port) - writer.write(_socks5_reply(0x05)) - await writer.drain() - writer.close() - return - - # -- Non-Telegram IP -> direct passthrough -- - if not _is_telegram_ip(dst): - _stats.connections_passthrough += 1 - log.debug("[%s] passthrough -> %s:%d", label, dst, port) - try: - rr, rw = await asyncio.wait_for( - asyncio.open_connection(dst, port), timeout=10) - except Exception as exc: - log.warning("[%s] passthrough failed to %s: %s: %s", label, dst, type(exc).__name__, str(exc) or "(no message)") - writer.write(_socks5_reply(0x05)) - await writer.drain() - writer.close() - return - - writer.write(_socks5_reply(0x00)) - await writer.drain() - - tasks = [asyncio.create_task(_pipe(reader, rw)), - asyncio.create_task(_pipe(rr, writer))] - await asyncio.wait(tasks, - return_when=asyncio.FIRST_COMPLETED) - for t in tasks: - t.cancel() - for t in tasks: - try: - await t - except BaseException: - pass - return - - # -- Telegram DC: accept SOCKS, read init -- - writer.write(_socks5_reply(0x00)) - await writer.drain() - try: - init = await asyncio.wait_for( - reader.readexactly(64), timeout=15) + handshake = await asyncio.wait_for( + reader.readexactly(HANDSHAKE_LEN), timeout=10) except asyncio.IncompleteReadError: - log.debug("[%s] client disconnected before init", label) + log.debug("[%s] client disconnected before handshake", label) return - # HTTP transport -> reject - if _is_http_transport(init): - _stats.connections_http_rejected += 1 - log.debug("[%s] HTTP transport to %s:%d (rejected)", - label, dst, port) - writer.close() + result = _try_handshake(handshake, secret) + if result is None: + _stats.connections_bad += 1 + log.debug("[%s] bad handshake (wrong secret or proto)", label) + try: + while await reader.read(4096): + pass + except Exception: + pass return - # -- Extract DC ID -- - dc, is_media, proto = _dc_from_init(init) + dc, is_media, proto_tag, client_dec_prekey_iv = result + + if proto_tag == PROTO_TAG_ABRIDGED: + proto_int = PROTO_ABRIDGED_INT + elif proto_tag == PROTO_TAG_INTERMEDIATE: + proto_int = PROTO_INTERMEDIATE_INT + else: + proto_int = PROTO_PADDED_INTERMEDIATE_INT + + dc_idx = -dc if is_media else dc + + log.debug("[%s] handshake ok: DC%d%s proto=0x%08X", + label, dc, ' media' if is_media else '', proto_int) + + relay_init = _generate_relay_init(proto_tag, dc_idx) + + # key = SHA256(prekey + secret), iv from handshake + # "dec" = decrypt data from client; "enc" = encrypt data to client + clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN] + clt_dec_iv = client_dec_prekey_iv[PREKEY_LEN:] + clt_dec_key = hashlib.sha256(clt_dec_prekey + secret).digest() + + clt_enc_prekey_iv = client_dec_prekey_iv[::-1] + clt_enc_key = hashlib.sha256( + clt_enc_prekey_iv[:PREKEY_LEN] + secret).digest() + clt_enc_iv = clt_enc_prekey_iv[PREKEY_LEN:] + + clt_decryptor = Cipher( + algorithms.AES(clt_dec_key), modes.CTR(clt_dec_iv) + ).encryptor() + clt_encryptor = Cipher( + algorithms.AES(clt_enc_key), modes.CTR(clt_enc_iv) + ).encryptor() + + # fast-forward client decryptor past the 64-byte init + clt_decryptor.update(ZERO_64) + + # relay side: standard obfuscation (no secret hash, raw key) + relay_enc_key = relay_init[SKIP_LEN:SKIP_LEN + PREKEY_LEN] + relay_enc_iv = relay_init[SKIP_LEN + PREKEY_LEN: + SKIP_LEN + PREKEY_LEN + IV_LEN] + + relay_dec_prekey_iv = relay_init[SKIP_LEN: + SKIP_LEN + PREKEY_LEN + IV_LEN][::-1] + relay_dec_key = relay_dec_prekey_iv[:KEY_LEN] + relay_dec_iv = relay_dec_prekey_iv[KEY_LEN:] + + tg_encryptor = Cipher( + algorithms.AES(relay_enc_key), modes.CTR(relay_enc_iv) + ).encryptor() + tg_decryptor = Cipher( + algorithms.AES(relay_dec_key), modes.CTR(relay_dec_iv) + ).encryptor() - 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) - if dc in _dc_opt: - init = _patch_init_dc(init, -dc if is_media else dc) - init_patched = True + tg_encryptor.update(ZERO_64) - if dc is None or dc not in _dc_opt: - log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough", - label, dc, dst, port) - await _tcp_fallback(reader, writer, dst, port, init, label) + dc_key = (dc, is_media) + media_tag = " media" if is_media else "" + + # Fallback if DC not in config or WS blacklisted for this DC/is_media + if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist: + fallback_dst = _fallback_ip(dc) + if fallback_dst: + if dc not in proxy_config.dc_redirects: + log.info("[%s] DC%d not in config -> TCP fallback %s:443", + label, dc, fallback_dst) + else: + log.info("[%s] DC%d%s WS blacklisted -> TCP fallback %s:443", + label, dc, media_tag, fallback_dst) + await _tcp_fallback(reader, writer, fallback_dst, 443, + relay_init, label, dc=dc, + is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor) + else: + log.warning("[%s] DC%d%s no fallback available", + label, dc, media_tag) return - dc_key = (dc, is_media if is_media is not None else True) now = time.monotonic() - media_tag = (" media" if is_media - else (" media?" if is_media is None else "")) - - # -- WS blacklist check -- - if dc_key in _ws_blacklist: - log.debug("[%s] DC%d%s WS blacklisted -> TCP %s:%d", - label, dc, media_tag, dst, port) - ok = await _tcp_fallback(reader, writer, dst, port, init, - label, dc=dc, is_media=is_media) - if ok: - log.info("[%s] DC%d%s TCP fallback closed", - label, dc, media_tag) - return - - # -- Try WebSocket via direct connection -- - fail_until = _dc_fail_until.get(dc_key, 0) - ws_timeout = _WS_FAIL_TIMEOUT if now < fail_until else 10.0 + fail_until = dc_fail_until.get(dc_key, 0) + ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0 domains = _ws_domains(dc, is_media) - target = _dc_opt[dc] + target = proxy_config.dc_redirects[dc] ws = None ws_failed_redirect = False all_redirects = True ws = await _ws_pool.get(dc, is_media, target, domains) if ws: - log.info("[%s] DC%d%s (%s:%d) -> pool hit via %s", - label, dc, media_tag, dst, port, target) + log.info("[%s] DC%d%s -> pool hit via %s", + label, dc, media_tag, target) else: for domain in domains: url = f'wss://{domain}/apiws' - log.info("[%s] DC%d%s (%s:%d) -> %s via %s", - label, dc, media_tag, dst, port, url, target) + 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) @@ -1053,62 +932,60 @@ async def _handle_client(reader, writer): except Exception as exc: _stats.ws_errors += 1 all_redirects = False - err_str = str(exc) - if ('CERTIFICATE_VERIFY_FAILED' in err_str or - 'Hostname mismatch' in err_str): - log.warning("[%s] DC%d%s SSL error: %s", - label, dc, media_tag, exc) - else: - log.warning("[%s] DC%d%s WS connect failed: %s", - label, dc, media_tag, exc) + log.warning("[%s] DC%d%s WS connect failed: %s", + label, dc, media_tag, exc) - # -- WS failed -> fallback -- + # WS failed -> fallback if ws is None: if ws_failed_redirect and all_redirects: - _ws_blacklist.add(dc_key) - log.warning( - "[%s] DC%d%s blacklisted for WS (all 302)", - label, dc, media_tag) + ws_blacklist.add(dc_key) + log.warning("[%s] DC%d%s blacklisted for WS (all 302)", + label, dc, media_tag) elif ws_failed_redirect: - _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN + dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN else: - _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN + dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN log.info("[%s] DC%d%s WS cooldown for %ds", - label, dc, media_tag, int(_DC_FAIL_COOLDOWN)) + label, dc, media_tag, int(DC_FAIL_COOLDOWN)) - log.info("[%s] DC%d%s -> TCP fallback to %s:%d", - label, dc, media_tag, dst, port) - ok = await _tcp_fallback(reader, writer, dst, port, init, - label, dc=dc, is_media=is_media) + fallback_dst = _fallback_ip(dc) or target + log.info("[%s] DC%d%s -> TCP fallback to %s:443", + label, dc, media_tag, fallback_dst) + ok = await _tcp_fallback(reader, writer, fallback_dst, 443, + relay_init, label, dc=dc, + is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor) if ok: log.info("[%s] DC%d%s TCP fallback closed", label, dc, media_tag) return - # -- WS success -- - _dc_fail_until.pop(dc_key, None) + dc_fail_until.pop(dc_key, None) _stats.connections_ws += 1 splitter = None + try: + splitter = _MsgSplitter(relay_init, proto_int) + log.debug("[%s] MsgSplitter activated for proto 0x%08X", + label, proto_int) + except Exception: + pass - # Turning splitter on for mobile clients or media-connections, so as the big files don't get fragmented by the TCP socket. - if proto is not None and (init_patched or is_media or proto != _PROTO_INTERMEDIATE): - try: - splitter = _MsgSplitter(init, proto) - log.debug("[%s] MsgSplitter activated for proto 0x%08X", label, proto) - except Exception: - pass + await ws.send(relay_init) - # Send the buffered init packet - await ws.send(init) - - # Bidirectional bridge - await _bridge_ws(reader, writer, ws, label, - dc=dc, dst=dst, port=port, is_media=is_media, - splitter=splitter) + await _bridge_ws_reencrypt(reader, writer, ws, label, + dc=dc, is_media=is_media, + clt_decryptor=clt_decryptor, + clt_encryptor=clt_encryptor, + tg_encryptor=tg_encryptor, + tg_decryptor=tg_decryptor, + splitter=splitter) except asyncio.TimeoutError: - log.warning("[%s] timeout during SOCKS5 handshake", label) + log.warning("[%s] timeout during handshake", label) except asyncio.IncompleteReadError: log.debug("[%s] client disconnected", label) except asyncio.CancelledError: @@ -1119,10 +996,11 @@ async def _handle_client(reader, writer): if getattr(exc, 'winerror', None) == 1236: log.debug("[%s] connection aborted by local system", label) else: - log.error("[%s] unexpected os error: %s", label, exc) + log.error("[%s] unexpected OS error: %s", label, exc) except Exception as exc: - log.error("[%s] unexpected: %s", label, exc) + log.error("[%s] unexpected: %s", label, exc, exc_info=True) finally: + _stats.connections_active -= 1 try: writer.close() except BaseException: @@ -1133,15 +1011,16 @@ _server_instance = None _server_stop_event = None -async def _run(port: int, dc_opt: Dict[int, Optional[str]], - stop_event: Optional[asyncio.Event] = None, - host: str = '127.0.0.1'): - global _dc_opt, _server_instance, _server_stop_event - _dc_opt = dc_opt +async def _run(stop_event: Optional[asyncio.Event] = None): + global _server_instance, _server_stop_event _server_stop_event = stop_event - server = await asyncio.start_server( - _handle_client, host, port) + secret_bytes = bytes.fromhex(proxy_config.secret) + + def client_cb(r, w): + asyncio.create_task(_handle_client(r, w, secret_bytes)) + + server = await asyncio.start_server(client_cb, proxy_config.host, proxy_config.port) _server_instance = server for sock in server.sockets: @@ -1150,16 +1029,20 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], except (OSError, AttributeError): pass + link_host = get_link_host(proxy_config.host) + tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}" + log.info("=" * 60) - log.info(" Telegram WS Bridge Proxy") - log.info(" Listening on %s:%d", host, port) + log.info(" Telegram MTProto WS Bridge Proxy") + log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port) + log.info(" Secret: %s", proxy_config.secret) log.info(" Target DC IPs:") - for dc in dc_opt.keys(): - ip = dc_opt.get(dc) + for dc in sorted(proxy_config.dc_redirects.keys()): + ip = proxy_config.dc_redirects.get(dc) log.info(" DC%d: %s", dc, ip) log.info("=" * 60) - log.info(" Configure Telegram Desktop:") - log.info(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port) + log.info(" Connect link:") + log.info(" %s", tg_link) log.info("=" * 60) async def log_stats(): @@ -1168,21 +1051,21 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], await asyncio.sleep(60) bl = ', '.join( f'DC{d}{"m" if m else ""}' - for d, m in sorted(_ws_blacklist)) or 'none' + for d, m in sorted(ws_blacklist)) or 'none' log.info("stats: %s | ws_bl: %s", _stats.summary(), bl) except asyncio.CancelledError: raise log_stats_task = asyncio.create_task(log_stats()) - await _ws_pool.warmup(dc_opt) + await _ws_pool.warmup(proxy_config.dc_redirects) try: async with server: if stop_event: serve_task = asyncio.create_task(server.serve_forever()) stop_task = asyncio.create_task(stop_event.wait()) - done, _pending = await asyncio.wait( + done, _ = await asyncio.wait( (serve_task, stop_task), return_when=asyncio.FIRST_COMPLETED, ) @@ -1213,39 +1096,37 @@ async def _run(port: int, dc_opt: Dict[int, Optional[str]], def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: - """Parse list of 'DC:IP' strings into {dc: ip} dict.""" - dc_opt: Dict[int, str] = {} + dc_redirects: Dict[int, str] = {} for entry in dc_ip_list: if ':' not in entry: - raise ValueError(f"Invalid --dc-ip format {entry!r}, expected DC:IP") + raise ValueError( + f"Invalid --dc-ip format {entry!r}, expected DC:IP") dc_s, ip_s = entry.split(':', 1) try: dc_n = int(dc_s) _socket.inet_aton(ip_s) except (ValueError, OSError): raise ValueError(f"Invalid --dc-ip {entry!r}") - dc_opt[dc_n] = ip_s - return dc_opt + dc_redirects[dc_n] = ip_s + return dc_redirects -def run_proxy(port: int, dc_opt: Dict[int, str], - stop_event: Optional[asyncio.Event] = None, - host: str = '127.0.0.1'): - """Run the proxy (blocking). Can be called from threads.""" - asyncio.run(_run(port, dc_opt, stop_event, host)) +def run_proxy(stop_event: Optional[asyncio.Event] = None): + asyncio.run(_run(stop_event,)) def main(): ap = argparse.ArgumentParser( - description='Telegram Desktop WebSocket Bridge Proxy') - ap.add_argument('--port', type=int, default=DEFAULT_PORT, - help=f'Listen port (default {DEFAULT_PORT})') + description='Telegram MTProto WebSocket Bridge Proxy') + ap.add_argument('--port', type=int, default=1443, + help=f'Listen port (default 1443)') ap.add_argument('--host', type=str, default='127.0.0.1', help='Listen host (default 127.0.0.1)') + ap.add_argument('--secret', type=str, default=None, + help='MTProto proxy secret (32 hex chars). ' + 'Auto-generated if not provided.') ap.add_argument('--dc-ip', metavar='DC:IP', action='append', - default=[], - help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205' - ' --dc-ip 2:149.154.167.220') + help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220') ap.add_argument('-v', '--verbose', action='store_true', help='Debug logging') ap.add_argument('--log-file', type=str, default=None, metavar='PATH', @@ -1264,11 +1145,35 @@ def main(): args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] try: - dc_opt = parse_dc_ip_list(args.dc_ip) + dc_redirects = parse_dc_ip_list(args.dc_ip) except ValueError as e: log.error(str(e)) sys.exit(1) + if args.secret: + secret_hex = args.secret.strip() + if len(secret_hex) != 32: + log.error("Secret must be exactly 32 hex characters") + sys.exit(1) + try: + bytes.fromhex(secret_hex) + except ValueError: + log.error("Secret must be valid hex") + sys.exit(1) + else: + secret_hex = os.urandom(16).hex() + log.info("Generated secret: %s", secret_hex) + + global proxy_config + proxy_config = ProxyConfig( + port=args.port, + host=args.host, + secret=secret_hex, + dc_redirects=dc_redirects, + buffer_size=max(4, args.buf_kb) * 1024, + pool_size=max(0, args.pool_size) + ) + log_level = logging.DEBUG if args.verbose else logging.INFO log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', datefmt='%H:%M:%S') @@ -1282,20 +1187,15 @@ def main(): if args.log_file: fh = logging.handlers.RotatingFileHandler( args.log_file, - maxBytes=max(32 * 1024, args.log_max_mb * 1024 * 1024), + maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)), backupCount=max(0, args.log_backups), encoding='utf-8', ) fh.setFormatter(log_fmt) root.addHandler(fh) - global _RECV_BUF, _SEND_BUF, _WS_POOL_SIZE - _RECV_BUF = max(4, args.buf_kb) * 1024 - _SEND_BUF = _RECV_BUF - _WS_POOL_SIZE = max(0, args.pool_size) - try: - asyncio.run(_run(args.port, dc_opt, host=args.host)) + asyncio.run(_run()) except KeyboardInterrupt: log.info("Shutting down. Final stats: %s", _stats.summary()) diff --git a/pyproject.toml b/pyproject.toml index 5e440a1..607ecce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ keywords = [ "proxy", "bypass", "websocket", - "socks5", + "mtproto", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py index 47a3cdd..e1f23c3 100644 --- a/ui/ctk_theme.py +++ b/ui/ctk_theme.py @@ -1,8 +1,3 @@ -""" -Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux). -Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам. -""" - from __future__ import annotations import sys @@ -13,11 +8,7 @@ from typing import Any, Callable, Optional, Tuple _tk_variable_del_guard_installed = False -def _install_tkinter_variable_del_guard() -> None: - """ - Убирает «Exception ignored» при выходе процесса: Tcl уже разрушен, а GC ещё - вызывает Variable.__del__ (StringVar и т.д.) — напр. окно CTk в фоновом потоке. - """ +def install_tkinter_variable_del_guard() -> None: global _tk_variable_del_guard_installed if _tk_variable_del_guard_installed: return @@ -32,24 +23,24 @@ def _install_tkinter_variable_del_guard() -> None: tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment] _tk_variable_del_guard_installed = True -# Размеры и отступы (единые для диалогов настроек и первого запуска) CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560) CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14) -FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) +FIRST_RUN_SIZE: Tuple[int, int] = (520, 480) FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) @dataclass(frozen=True) class CtkTheme: - """Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста.""" + tg_blue: tuple = ("#3390ec", "#3390ec") + tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4") + + bg: tuple = ("#ffffff", "#1e1e1e") + field_bg: tuple = ("#f0f2f5", "#2b2b2b") + field_border: tuple = ("#d6d9dc", "#3a3a3a") + + text_primary: tuple = ("#000000", "#ffffff") + text_secondary: tuple = ("#707579", "#aaaaaa") - tg_blue: str = "#3390ec" - tg_blue_hover: str = "#2b7cd4" - bg: str = "#ffffff" - field_bg: str = "#f0f2f5" - field_border: str = "#d6d9dc" - text_primary: str = "#000000" - text_secondary: str = "#707579" ui_font_family: str = "Sans" mono_font_family: str = "Monospace" @@ -61,17 +52,16 @@ def ctk_theme_for_platform() -> CtkTheme: def apply_ctk_appearance(ctk: Any) -> None: - ctk.set_appearance_mode("light") + ctk.set_appearance_mode("auto") ctk.set_default_color_theme("blue") - def center_ctk_geometry(root: Any, width: int, height: int) -> None: sw = root.winfo_screenwidth() sh = root.winfo_screenheight() root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}") -def create_ctk_root( +def create_ctk_toplevel( ctk: Any, *, title: str, @@ -81,21 +71,27 @@ def create_ctk_root( topmost: bool = True, after_create: Optional[Callable[[Any], None]] = None, ) -> Any: - """ - Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры. - after_create — опционально: установка иконки окна (различается по ОС). - """ - _install_tkinter_variable_del_guard() - apply_ctk_appearance(ctk) - root = ctk.CTk() + root = ctk.CTkToplevel() root.title(title) root.resizable(False, False) - if topmost: - root.attributes("-topmost", True) center_ctk_geometry(root, width, height) root.configure(fg_color=theme.bg) + if topmost: + root.attributes("-topmost", True) + root.lift() + root.focus_force() if after_create: - after_create(root) + _after_id = root.after(300, lambda: after_create(root)) + _orig_destroy = root.destroy + + def _safe_destroy(): + try: + root.after_cancel(_after_id) + except Exception: + pass + _orig_destroy() + + root.destroy = _safe_destroy return root @@ -109,4 +105,4 @@ def main_content_frame( ) -> Any: frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0) frame.pack(fill="both", expand=True, padx=padx, pady=pady) - return frame + return frame \ No newline at end of file diff --git a/ui/ctk_tooltip.py b/ui/ctk_tooltip.py index 16c6da7..d6d74ed 100644 --- a/ui/ctk_tooltip.py +++ b/ui/ctk_tooltip.py @@ -1,7 +1,3 @@ -""" -Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap. -""" - from __future__ import annotations import tkinter as tk @@ -9,8 +5,6 @@ from typing import Any, List, Optional class CtkTooltip: - """Показ текста при наведении на виджет.""" - def __init__( self, widget: Any, @@ -31,6 +25,8 @@ class CtkTooltip: widget.bind("", self._on_destroy, add="+") def _schedule(self, _event: Any = None) -> None: + if self.widget is None: + return self._cancel_after() self._after_id = self.widget.after(self.delay_ms, self._show) @@ -89,6 +85,7 @@ class CtkTooltip: def _on_destroy(self, _event: Any = None) -> None: self._hide() + self.widget = None def _is_windows() -> bool: @@ -104,11 +101,9 @@ def attach_ctk_tooltip( delay_ms: int = 450, wraplength: int = 320, ) -> None: - """Повесить подсказку на виджет (CTk или tk).""" CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength) def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None: - """Одна и та же подсказка на несколько виджетов (подпись + поле).""" for w in widgets: attach_ctk_tooltip(w, text, **kwargs) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index fc5b63e..06bf7e3 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -1,10 +1,6 @@ -""" -Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск. -Логика сохранения и колбэки остаются в платформенных модулях. -""" - from __future__ import annotations +import os import webbrowser from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -20,15 +16,15 @@ from ui.ctk_theme import ( ) from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets -# Подсказки для формы настроек (новые пользователи) _TIP_HOST = ( - "Адрес, на котором прокси принимает SOCKS5-подключения.\n" + "Адрес, на котором прокси принимает подключения.\n" "Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы" ) _TIP_PORT = ( - "Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть " + "Порт прокси. В Telegram Desktop в настройках прокси должен быть " "указан тот же порт" ) +_TIP_SECRET = "Секретный ключ для авторизации клиентов" _TIP_DC = ( "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" "Каждая строка: «номер:IP», например 2:149.154.167.220. " @@ -53,14 +49,60 @@ _TIP_AUTOSTART = ( "Запускать TG WS Proxy при входе в Windows. " "Если вы переместите программу в другую папку, автозапуск сбросится" ) -_TIP_CHECK_UPDATES = ( - "При запуске проверять наличие обновлений" -) +_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений" _TIP_SAVE = "Сохранить настройки" _TIP_CANCEL = "Закрыть окно без сохранения изменений" -# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE) -_CONFIG_FORM_INNER_WIDTH = 396 +_INNER_W = 396 + + +def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw): + opts = dict( + font=(theme.ui_font_family, 13), corner_radius=radius, + fg_color=theme.bg, border_color=theme.field_border, + border_width=1, text_color=theme.text_primary, + ) + if var is not None: + opts["textvariable"] = var + if width: + opts["width"] = width + opts["height"] = height + opts.update(kw) + return ctk.CTkEntry(parent, **opts) + + +def _checkbox(ctk, parent, theme, text, variable): + return ctk.CTkCheckBox( + parent, text=text, variable=variable, + font=(theme.ui_font_family, 13), text_color=theme.text_primary, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + corner_radius=6, border_width=2, border_color=theme.field_border, + ) + + +def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw): + weight = "bold" if bold else "normal" + return ctk.CTkLabel( + parent, text=text, + font=(theme.ui_font_family, size, weight), + text_color=theme.text_secondary if secondary else theme.text_primary, + anchor="w", **kw, + ) + + +def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False): + col = ctk.CTkFrame(parent, fg_color="transparent") + lbl = _label(ctk, col, theme, label_text) + lbl.pack(anchor="w", pady=(0, 2)) + var = ctk.StringVar(value=str(value)) + ent = _entry(ctk, col, theme, var=var, width=width) + if pack_fill: + ent.pack(fill="x") + else: + ent.pack(anchor="w") + if tip: + attach_tooltip_to_widgets([lbl, ent, col], tip) + return col, var def tray_settings_scroll_and_footer( @@ -68,10 +110,6 @@ def tray_settings_scroll_and_footer( content_parent: Any, theme: CtkTheme, ) -> Tuple[Any, Any]: - """ - Нижняя панель под кнопки и прокручиваемая область для формы (форма не обрезает кнопки). - Возвращает (scroll_frame, footer_frame). - """ footer = ctk.CTkFrame(content_parent, fg_color=theme.bg) footer.pack(side="bottom", fill="x") scroll = ctk.CTkScrollableFrame( @@ -93,22 +131,12 @@ def _config_section( *, bottom_spacer: int = 6, ) -> Any: - """Заголовок секции и карточка с рамкой для группировки полей.""" wrap = ctk.CTkFrame(parent, fg_color="transparent") wrap.pack(fill="x", pady=(0, bottom_spacer)) - ctk.CTkLabel( - wrap, - text=title, - font=(theme.ui_font_family, 12, "bold"), - text_color=theme.text_primary, - anchor="w", - ).pack(anchor="w", pady=(0, 2)) + _label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2)) card = ctk.CTkFrame( - wrap, - fg_color=theme.field_bg, - corner_radius=10, - border_width=1, - border_color=theme.field_border, + wrap, fg_color=theme.field_bg, corner_radius=10, + border_width=1, border_color=theme.field_border, ) card.pack(fill="x") inner = ctk.CTkFrame(card, fg_color="transparent") @@ -120,6 +148,7 @@ def _config_section( class TrayConfigFormWidgets: host_var: Any port_var: Any + secret_var: Any dc_textbox: Any verbose_var: Any adv_entries: List[Any] @@ -138,102 +167,67 @@ def install_tray_config_form( show_autostart: bool = False, autostart_value: bool = False, ) -> TrayConfigFormWidgets: - """Поля настроек прокси внутри уже созданного `frame`.""" header = ctk.CTkFrame(frame, fg_color="transparent") header.pack(fill="x", pady=(0, 2)) ctk.CTkLabel( - header, - text="Настройки прокси", + header, text="Настройки прокси", font=(theme.ui_font_family, 17, "bold"), - text_color=theme.text_primary, - anchor="w", + text_color=theme.text_primary, anchor="w", ).pack(side="left") ctk.CTkLabel( - header, - text=f"v{__version__}", + header, text=f"v{__version__}", font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - anchor="e", + text_color=theme.text_secondary, anchor="e", ).pack(side="right") - inner_w = _CONFIG_FORM_INNER_WIDTH - - conn = _config_section(ctk, frame, theme, "Подключение SOCKS5") + conn = _config_section(ctk, frame, theme, "Подключение MTProto") host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row.pack(fill="x") - host_col = ctk.CTkFrame(host_row, fg_color="transparent") + host_col, host_var = _labeled_entry( + ctk, host_row, theme, "IP-адрес", + cfg.get("host", default_config["host"]), + tip=_TIP_HOST, width=160, pack_fill=True, + ) host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) - host_lbl = ctk.CTkLabel( - host_col, - text="IP-адрес", - font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - anchor="w", - ) - host_lbl.pack(anchor="w", pady=(0, 2)) - host_var = ctk.StringVar(value=cfg.get("host", default_config["host"])) - host_entry = ctk.CTkEntry( - host_col, - textvariable=host_var, - width=160, - height=36, - font=(theme.ui_font_family, 13), - corner_radius=10, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, - ) - host_entry.pack(fill="x", pady=(0, 0)) - attach_tooltip_to_widgets([host_lbl, host_entry, host_col], _TIP_HOST) - port_col = ctk.CTkFrame(host_row, fg_color="transparent") + port_col, port_var = _labeled_entry( + ctk, host_row, theme, "Порт", + cfg.get("port", default_config["port"]), + tip=_TIP_PORT, width=100, + ) port_col.pack(side="left") - port_lbl = ctk.CTkLabel( - port_col, - text="Порт", - font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - anchor="w", + + secret_row = ctk.CTkFrame(conn, fg_color="transparent") + secret_row.pack(fill="x") + + secret_col, secret_var = _labeled_entry( + ctk, secret_row, theme, "Secret", + cfg.get("secret", default_config["secret"]), + tip=_TIP_SECRET, width=160, pack_fill=True, ) - port_lbl.pack(anchor="w", pady=(0, 2)) - port_var = ctk.StringVar(value=str(cfg.get("port", default_config["port"]))) - port_entry = ctk.CTkEntry( - port_col, - textvariable=port_var, - width=100, - height=36, - font=(theme.ui_font_family, 13), - corner_radius=10, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, - ) - port_entry.pack(anchor="w") - attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT) + secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) + + regen_col = ctk.CTkFrame(secret_row, fg_color="transparent") + regen_col.pack(side="left", anchor="s") + ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2)) + ctk.CTkButton( + regen_col, text="↺", width=36, height=36, + font=(theme.ui_font_family, 18), corner_radius=10, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, + command=lambda: secret_var.set(os.urandom(16).hex()), + ).pack() dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") - dc_lbl = ctk.CTkLabel( - dc_inner, - text="По одному правилу на строку, формат: номер:IP", - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - ) + dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11) dc_lbl.pack(anchor="w", pady=(0, 4)) dc_textbox = ctk.CTkTextbox( - dc_inner, - width=inner_w, - height=88, - font=(theme.mono_font_family, 12), - corner_radius=10, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, + dc_inner, width=_INNER_W, height=88, + font=(theme.mono_font_family, 12), corner_radius=10, + fg_color=theme.bg, border_color=theme.field_border, + border_width=1, text_color=theme.text_primary, ) dc_textbox.pack(fill="x") dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) @@ -242,18 +236,7 @@ def install_tray_config_form( log_inner = _config_section(ctk, frame, theme, "Логи и производительность") verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - verbose_cb = ctk.CTkCheckBox( - log_inner, - text="Подробное логирование (verbose)", - variable=verbose_var, - font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, - hover_color=theme.tg_blue_hover, - corner_radius=6, - border_width=2, - border_color=theme.field_border, - ) + verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var) verbose_cb.pack(anchor="w", pady=(0, 6)) attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE) @@ -265,33 +248,17 @@ def install_tray_config_form( ("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL), ("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB), ] - for lbl, key, tip in adv_rows: - col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - col_frame.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) - adv_l = ctk.CTkLabel( - col_frame, - text=lbl, - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - ) + for label_text, key, tip in adv_rows: + col = ctk.CTkFrame(adv_frame, fg_color="transparent") + col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) + adv_l = _label(ctk, col, theme, label_text, size=11) adv_l.pack(anchor="w", pady=(0, 2)) - adv_e = ctk.CTkEntry( - col_frame, - width=inner_w, - height=32, - font=(theme.ui_font_family, 13), - corner_radius=8, - fg_color=theme.bg, - border_color=theme.field_border, - border_width=1, - text_color=theme.text_primary, - textvariable=ctk.StringVar( - value=str(cfg.get(key, default_config[key])) - ), + adv_e = _entry( + ctk, col, theme, width=_INNER_W, height=32, radius=8, + textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))), ) adv_e.pack(fill="x") - attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip) + attach_tooltip_to_widgets([adv_l, adv_e, col], tip) adv_entries = list(adv_frame.winfo_children()) adv_keys = ("buf_kb", "pool_size", "log_max_mb") @@ -299,22 +266,9 @@ def install_tray_config_form( upd_inner = _config_section(ctk, frame, theme, "Обновления") st = get_status() check_updates_var = ctk.BooleanVar( - value=bool( - cfg.get("check_updates", default_config.get("check_updates", True)) - ) - ) - upd_cb = ctk.CTkCheckBox( - upd_inner, - text="Проверять обновления при запуске", - variable=check_updates_var, - font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, - hover_color=theme.tg_blue_hover, - corner_radius=6, - border_width=2, - border_color=theme.field_border, + value=bool(cfg.get("check_updates", default_config.get("check_updates", True))) ) + upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var) upd_cb.pack(anchor="w", pady=(0, 6)) attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES) @@ -335,72 +289,38 @@ def install_tray_config_form( else: upd_status = "Установлена последняя известная версия с GitHub." - ctk.CTkLabel( - upd_inner, - text=upd_status, - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - justify="left", - wraplength=inner_w, - ).pack(anchor="w", pady=(0, 8)) + _label(ctk, upd_inner, theme, upd_status, size=11, + justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - open_rel_btn = ctk.CTkButton( - upd_inner, - text="Открыть страницу релиза", - height=32, - font=(theme.ui_font_family, 13), - corner_radius=8, - fg_color=theme.field_bg, - hover_color=theme.field_border, - text_color=theme.text_primary, - border_width=1, + ctk.CTkButton( + upd_inner, text="Открыть страницу релиза", height=32, + font=(theme.ui_font_family, 13), corner_radius=8, + fg_color=theme.field_bg, hover_color=theme.field_border, + text_color=theme.text_primary, border_width=1, border_color=theme.field_border, command=lambda u=rel_url: webbrowser.open(u), - ) - open_rel_btn.pack(anchor="w") + ).pack(anchor="w") autostart_var = None if show_autostart: - sys_inner = _config_section( - ctk, frame, theme, "Запуск Windows", bottom_spacer=4 - ) + sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4) autostart_var = ctk.BooleanVar(value=autostart_value) - as_cb = ctk.CTkCheckBox( - sys_inner, - text="Автозапуск при включении компьютера", - variable=autostart_var, - font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, - hover_color=theme.tg_blue_hover, - corner_radius=6, - border_width=2, - border_color=theme.field_border, - ) + as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var) as_cb.pack(anchor="w", pady=(0, 4)) - as_hint = ctk.CTkLabel( - sys_inner, - text="Если переместить программу в другую папку, запись автозапуска может сброситься.", - font=(theme.ui_font_family, 11), - text_color=theme.text_secondary, - anchor="w", - justify="left", - wraplength=inner_w, + as_hint = _label( + ctk, sys_inner, theme, + "Если переместить программу в другую папку, запись автозапуска может сброситься.", + size=11, justify="left", wraplength=_INNER_W, ) as_hint.pack(anchor="w") attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) return TrayConfigFormWidgets( - host_var=host_var, - port_var=port_var, - dc_textbox=dc_textbox, - verbose_var=verbose_var, - adv_entries=adv_entries, - adv_keys=adv_keys, - autostart_var=autostart_var, - check_updates_var=check_updates_var, + host_var=host_var, port_var=port_var, secret_var=secret_var, + dc_textbox=dc_textbox, verbose_var=verbose_var, + adv_entries=adv_entries, adv_keys=adv_keys, + autostart_var=autostart_var, check_updates_var=check_updates_var, ) @@ -409,7 +329,6 @@ def merge_adv_from_form( base: Dict[str, Any], default_config: dict, ) -> None: - """Дополняет base значениями buf_kb / pool_size / log_max_mb (in-place).""" for i, key in enumerate(widgets.adv_keys): col_frame = widgets.adv_entries[i] entry = col_frame.winfo_children()[1] @@ -428,9 +347,6 @@ def validate_config_form( *, include_autostart: bool, ) -> Union[dict, str]: - """ - Возвращает словарь полей конфига или строку ошибки для показа пользователю. - """ import socket as _sock host_val = widgets.host_var.get().strip() @@ -456,9 +372,18 @@ def validate_config_form( except ValueError as e: return str(e) + secret_val = widgets.secret_var.get().strip() + if len(secret_val) != 32: + return "Secret должен содержать ровно 32 hex-символа (16 байт)." + try: + bytes.fromhex(secret_val) + except ValueError: + return "Secret должен состоять только из hex-символов (0-9, a-f)." + new_cfg: Dict[str, Any] = { "host": host_val, "port": port_val, + "secret": secret_val, "dc_ip": lines, "verbose": widgets.verbose_var.get(), } @@ -517,12 +442,11 @@ def populate_first_run_window( *, host: str, port: int, + secret: str, on_done: Callable[[bool], None], ) -> None: - """ - Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна. - """ - tg_url = f"tg://socks?server={host}&port={port}" + link_host = tg_ws_proxy.get_link_host(host) + tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" fpx, fpy = FIRST_RUN_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) @@ -541,18 +465,35 @@ def populate_first_run_window( ("Как подключить Telegram Desktop:", True), (" Автоматически:", True), (" ПКМ по иконке в трее → «Открыть в Telegram»", False), - (f" Или ссылка: {tg_url}", False), + (f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False), ("\n Вручную:", True), (" Настройки → Продвинутые → Тип подключения → Прокси", False), - (f" SOCKS5 → {host} : {port} (без логина/пароля)", False), + (f" MTProto → {link_host} : {port}", False), + (f" Secret: dd{secret}", False), ] + textbox = ctk.CTkTextbox( + frame, + font=(theme.ui_font_family, 13), + fg_color=theme.bg, + border_width=0, + text_color=theme.text_primary, + activate_scrollbars=False, + wrap="word", + height=275, + ) + textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold")) + textbox._textbox.configure(spacing1=1, spacing3=1) for text, bold in sections: - weight = "bold" if bold else "normal" - ctk.CTkLabel(frame, text=text, - font=(theme.ui_font_family, 13, weight), - text_color=theme.text_primary, - anchor="w", justify="left").pack(anchor="w", pady=1) + if text.startswith("\n"): + textbox.insert("end", "\n") + text = text[1:] + if bold: + textbox.insert("end", text + "\n", "bold") + else: + textbox.insert("end", text + "\n") + textbox.configure(state="disabled") + textbox.pack(anchor="w", fill="x") ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() @@ -560,12 +501,8 @@ def populate_first_run_window( corner_radius=0).pack(fill="x", pady=(0, 12)) auto_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас", - variable=auto_var, font=(theme.ui_font_family, 13), - text_color=theme.text_primary, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - corner_radius=6, border_width=2, - border_color=theme.field_border).pack(anchor="w", pady=(0, 16)) + _checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас", + auto_var).pack(anchor="w", pady=(0, 16)) def on_ok(): on_done(auto_var.get()) diff --git a/utils/default_config.py b/utils/default_config.py index 30b7bc6..cb893f6 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -5,10 +5,11 @@ from __future__ import annotations import sys +import os from typing import Any, Dict _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { - "port": 1080, + "port": 1443, "host": "127.0.0.1", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "verbose": False, @@ -20,8 +21,10 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { def default_tray_config() -> Dict[str, Any]: - """Новая копия конфига по умолчанию для текущей ОС.""" cfg = dict(_TRAY_DEFAULTS_COMMON) + cfg["secret"] = os.urandom(16).hex() + if sys.platform == "win32": cfg["autostart"] = False + return cfg diff --git a/utils/tray_common.py b/utils/tray_common.py new file mode 100644 index 0000000..05e3a14 --- /dev/null +++ b/utils/tray_common.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import logging.handlers +import os +import socket as _socket +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple + +import psutil + +import proxy.tg_ws_proxy as tg_ws_proxy +from proxy import __version__ +from utils.default_config import default_tray_config + +log = logging.getLogger("tg-ws-tray") + +APP_NAME = "TgWsProxy" + + +def _app_dir() -> Path: + if sys.platform == "win32": + return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / APP_NAME + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME + + +APP_DIR = _app_dir() +CONFIG_FILE = APP_DIR / "config.json" +LOG_FILE = APP_DIR / "proxy.log" +FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto" +IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" + +DEFAULT_CONFIG: Dict[str, Any] = default_tray_config() + +IS_FROZEN = bool(getattr(sys, "frozen", False)) + + +def ensure_dirs() -> None: + APP_DIR.mkdir(parents=True, exist_ok=True) + + +# single-instance lock + +_lock_file_path: Optional[Path] = None + + +def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool: + try: + lock_ct = float(meta.get("create_time", 0.0)) + if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0: + return False + except Exception: + return False + if IS_FROZEN: + return APP_NAME.lower() in proc.name().lower() + try: + for arg in proc.cmdline(): + if script_hint in arg: + return True + except Exception: + pass + return False + + +def acquire_lock(script_hint: str = "") -> bool: + global _lock_file_path + ensure_dirs() + for f in list(APP_DIR.glob("*.lock")): + try: + pid = int(f.stem) + except Exception: + f.unlink(missing_ok=True) + continue + meta: dict = {} + try: + raw = f.read_text(encoding="utf-8").strip() + if raw: + meta = json.loads(raw) + except Exception: + pass + try: + if _same_process(meta, psutil.Process(pid), script_hint): + return False + except Exception: + pass + f.unlink(missing_ok=True) + + lock_file = APP_DIR / f"{os.getpid()}.lock" + try: + proc = psutil.Process(os.getpid()) + lock_file.write_text( + json.dumps({"create_time": proc.create_time()}, ensure_ascii=False), + encoding="utf-8", + ) + except Exception: + lock_file.touch() + _lock_file_path = lock_file + return True + + +def release_lock() -> None: + global _lock_file_path + if _lock_file_path: + try: + _lock_file_path.unlink(missing_ok=True) + except Exception: + pass + _lock_file_path = None + + +# config + +def load_config() -> dict: + ensure_dirs() + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in DEFAULT_CONFIG.items(): + data.setdefault(k, v) + return data + except Exception as exc: + log.warning("Failed to load config: %s", exc) + return dict(DEFAULT_CONFIG) + + +def save_config(cfg: dict) -> None: + ensure_dirs() + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +# logging + +_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s" +_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s" + + +def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None: + ensure_dirs() + level = logging.DEBUG if verbose else logging.INFO + root = logging.getLogger() + root.setLevel(level) + + fh = logging.handlers.RotatingFileHandler( + str(LOG_FILE), + maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)), + backupCount=0, + encoding="utf-8", + ) + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) + root.addHandler(fh) + + if not IS_FROZEN: + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(level) + ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S")) + root.addHandler(ch) + + +# icon + +def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)): + from PIL import Image, ImageDraw, ImageFont + + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + margin = 2 + draw.ellipse([margin, margin, size - margin, size - margin], fill=color) + + for path in _font_paths(): + try: + font = ImageFont.truetype(path, size=int(size * 0.55)) + break + except Exception: + continue + else: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), "T", font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text( + ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]), + "T", + fill=(255, 255, 255, 255), + font=font, + ) + return img + + +def _font_paths(): + if sys.platform == "win32": + return ["arial.ttf"] + if sys.platform == "darwin": + return ["/System/Library/Fonts/Helvetica.ttc"] + return [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + ] + + +def load_icon(): + from PIL import Image + + icon_path = Path(__file__).parents[1] / "icon.ico" + if icon_path.exists(): + try: + return Image.open(str(icon_path)) + except Exception: + pass + return make_icon_image(64) + + +# proxy lifecycle + +_proxy_thread: Optional[threading.Thread] = None +_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None + + +def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: + global _async_stop + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + stop_ev = asyncio.Event() + _async_stop = (loop, stop_ev) + + try: + loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev)) + except Exception as exc: + log.error("Proxy thread crashed: %s", exc) + if "Address already in use" in str(exc) or "10048" in str(exc): + on_port_busy( + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите." + ) + finally: + loop.close() + _async_stop = None + + +def apply_proxy_config(cfg: dict) -> bool: + dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) + try: + dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) + except ValueError as e: + log.error("Bad config dc_ip: %s", e) + return False + + pc = tg_ws_proxy.proxy_config + pc.port = cfg.get("port", DEFAULT_CONFIG["port"]) + pc.host = cfg.get("host", DEFAULT_CONFIG["host"]) + pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) + pc.dc_redirects = dc_redirects + pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024 + pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])) + return True + + +def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: + global _proxy_thread + if _proxy_thread and _proxy_thread.is_alive(): + log.info("Proxy already running") + return + + if not apply_proxy_config(cfg): + on_error("Ошибка конфигурации DC → IP.") + return + + pc = tg_ws_proxy.proxy_config + log.info("Starting proxy on %s:%d ...", pc.host, pc.port) + _proxy_thread = threading.Thread( + target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy" + ) + _proxy_thread.start() + + +def stop_proxy() -> None: + global _proxy_thread, _async_stop + if _async_stop: + loop, stop_ev = _async_stop + loop.call_soon_threadsafe(stop_ev.set) + if _proxy_thread: + _proxy_thread.join(timeout=5) + _proxy_thread = None + log.info("Proxy stopped") + + +def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: + log.info("Restarting proxy...") + stop_proxy() + time.sleep(0.3) + start_proxy(cfg, on_error) + + +def tg_proxy_url(cfg: dict) -> str: + host = cfg.get("host", DEFAULT_CONFIG["host"]) + port = cfg.get("port", DEFAULT_CONFIG["port"]) + secret = cfg.get("secret", DEFAULT_CONFIG["secret"]) + link_host = tg_ws_proxy.get_link_host(host) + return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" + + +_IPV6_WARNING = ( + "На вашем компьютере включена поддержка подключения по IPv6.\n\n" + "Telegram может пытаться подключаться через IPv6, " + "что не поддерживается и может привести к ошибкам.\n\n" + "Если прокси не работает или в логах присутствуют ошибки, " + "связанные с попытками подключения по IPv6 - " + "попробуйте отключить в настройках прокси Telegram попытку соединения " + "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " + "в системе.\n\n" + "Это предупреждение будет показано только один раз." +) + + +def _has_ipv6() -> bool: + try: + for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6): + ip = addr[4][0] + if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): + return True + except Exception: + pass + try: + s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM) + s.bind(("::1", 0)) + s.close() + return True + except Exception: + return False + + +def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None: + ensure_dirs() + if IPV6_WARN_MARKER.exists() or not _has_ipv6(): + return + IPV6_WARN_MARKER.touch() + threading.Thread( + target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"), + daemon=True, + ).start() + + +# update check + +def maybe_notify_update( + cfg: dict, + is_exiting: Callable[[], bool], + ask_open: Callable[[str, str], bool], +) -> None: + if not cfg.get("check_updates", True): + return + + def _work(): + time.sleep(1.5) + if is_exiting(): + return + try: + from utils.update_check import RELEASES_PAGE_URL, get_status, run_check + import webbrowser + + run_check(__version__) + st = get_status() + if not st.get("has_update"): + return + url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL + ver = st.get("latest") or "?" + if ask_open( + f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", + "TG WS Proxy — обновление", + ): + webbrowser.open(url) + except Exception as exc: + log.debug("Update check failed: %s", exc) + + threading.Thread(target=_work, daemon=True, name="update-check").start() + + +# ctk thread (windows / linux) + +_ctk_root: Any = None +_ctk_root_ready = threading.Event() + + +def ensure_ctk_thread(ctk: Any) -> bool: + global _ctk_root + if ctk is None: + return False + if _ctk_root_ready.is_set(): + return True + + def _run(): + global _ctk_root + from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard + + install_tkinter_variable_del_guard() + apply_ctk_appearance(ctk) + _ctk_root = ctk.CTk() + _ctk_root.withdraw() + _ctk_root_ready.set() + _ctk_root.mainloop() + + threading.Thread(target=_run, daemon=True, name="ctk-root").start() + _ctk_root_ready.wait(timeout=5.0) + return _ctk_root is not None + + +def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None: + if _ctk_root is None: + return + done = threading.Event() + + def _invoke(): + try: + build_fn(done) + except Exception: + log.exception("CTk dialog failed") + done.set() + + _ctk_root.after(0, _invoke) + done.wait() + import gc + gc.collect() + + +def quit_ctk() -> None: + if _ctk_root is not None: + try: + _ctk_root.after(0, _ctk_root.quit) + except Exception: + pass + + +# common bootstrap + +def bootstrap(cfg: dict) -> None: + save_config(cfg) + if LOG_FILE.exists(): + try: + LOG_FILE.unlink() + except Exception: + pass + setup_logging( + cfg.get("verbose", False), + log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]), + ) + log.info("TG WS Proxy версия %s starting", __version__) + log.info("Config: %s", cfg) + log.info("Log file: %s", LOG_FILE) diff --git a/windows.py b/windows.py index 7e8666d..878027a 100644 --- a/windows.py +++ b/windows.py @@ -1,18 +1,12 @@ from __future__ import annotations import ctypes -import ipaddress -import json -import logging -import logging.handlers import os -import winreg -import psutil import sys import threading import time import webbrowser -import ipaddress +import winreg from pathlib import Path from typing import Optional @@ -32,169 +26,62 @@ except ImportError: ctk = None try: - from PIL import Image, ImageDraw, ImageFont + from PIL import Image except ImportError: - Image = ImageDraw = ImageFont = None + Image = None import proxy.tg_ws_proxy as tg_ws_proxy -from proxy.app_runtime import ProxyAppRuntime -from proxy import __version__ -from utils.default_config import default_tray_config + +from utils.tray_common import ( + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, + acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, + ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, + maybe_notify_update, quit_ctk, release_lock, restart_proxy, + save_config, start_proxy, stop_proxy, tg_proxy_url, +) from ui.ctk_tray_ui import ( - install_tray_config_buttons, - install_tray_config_form, - populate_first_run_window, - tray_settings_scroll_and_footer, + install_tray_config_buttons, install_tray_config_form, + populate_first_run_window, tray_settings_scroll_and_footer, validate_config_form, ) from ui.ctk_theme import ( - CONFIG_DIALOG_FRAME_PAD, - CONFIG_DIALOG_SIZE, - FIRST_RUN_SIZE, - create_ctk_root, - ctk_theme_for_platform, - main_content_frame, + CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, + create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) - -IS_FROZEN = bool(getattr(sys, "frozen", False)) - -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" -FIRST_RUN_MARKER = APP_DIR / ".first_run_done" -IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - - -DEFAULT_CONFIG = default_tray_config() - - _tray_icon: Optional[object] = None _config: dict = {} -_exiting: bool = False -_lock_file_path: Optional[Path] = None +_exiting = False -log = logging.getLogger("tg-ws-tray") -_runtime = ProxyAppRuntime( - APP_DIR, - default_config=DEFAULT_CONFIG, - logger_name="tg-ws-tray", - on_error=lambda text: _show_error(text), -) -CONFIG_FILE = _runtime.config_file -LOG_FILE = _runtime.log_file +ICON_PATH = str(Path(__file__).parent / "icon.ico") -_user32 = ctypes.windll.user32 -_user32.MessageBoxW.argtypes = [ - ctypes.c_void_p, - ctypes.c_wchar_p, - ctypes.c_wchar_p, - ctypes.c_uint, -] -_user32.MessageBoxW.restype = ctypes.c_int +# win32 dialogs + +_u32 = ctypes.windll.user32 +_u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint] +_u32.MessageBoxW.restype = ctypes.c_int + +_MB_OK_ERR = 0x10 +_MB_OK_INFO = 0x40 +_MB_YESNO_Q = 0x24 +_IDYES = 6 -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - for arg in proc.cmdline(): - if "windows.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return ( - os.path.basename(sys.executable).lower() == proc.name().lower() - ) - - return False +def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: + _u32.MessageBoxW(None, text, title, _MB_OK_ERR) -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None +def _show_info(text: str, title: str = "TG WS Proxy") -> None: + _u32.MessageBoxW(None, text, title, _MB_OK_INFO) -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True +def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: + return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES -def _ensure_dirs(): - _runtime.ensure_dirs() +# autostart (registry) - -def load_config() -> dict: - return _runtime.load_config() - - -def save_config(cfg: dict): - _runtime.save_config(cfg) - - -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _runtime.setup_logging(verbose, log_max_mb=log_max_mb) - - -def _autostart_reg_name() -> str: - return APP_NAME +_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" def _supports_autostart() -> bool: @@ -207,408 +94,213 @@ def _autostart_command() -> str: def is_autostart_enabled() -> bool: try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - 0, - winreg.KEY_READ, - ) as k: - val, _ = winreg.QueryValueEx(k, _autostart_reg_name()) - stored = str(val).strip() - expected = _autostart_command().strip() - return stored == expected - except FileNotFoundError: - return False - except OSError: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_READ) as k: + val, _ = winreg.QueryValueEx(k, APP_NAME) + return str(val).strip() == _autostart_command().strip() + except (FileNotFoundError, OSError): return False def set_autostart_enabled(enabled: bool) -> None: try: - with winreg.CreateKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - ) as k: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as k: if enabled: - winreg.SetValueEx( - k, - _autostart_reg_name(), - 0, - winreg.REG_SZ, - _autostart_command(), - ) + winreg.SetValueEx(k, APP_NAME, 0, winreg.REG_SZ, _autostart_command()) else: try: - winreg.DeleteValue(k, _autostart_reg_name()) + winreg.DeleteValue(k, APP_NAME) except FileNotFoundError: pass except OSError as exc: log.error("Failed to update autostart: %s", exc) _show_error( "Не удалось изменить автозапуск.\n\n" - "Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n" - f"Ошибка: {exc}" + "Попробуйте запустить приложение от имени пользователя " + f"с правами на реестр.\n\nОшибка: {exc}" ) -def _make_icon_image(size: int = 64): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 136, 204, 255)) - - try: - font = ImageFont.truetype("arial.ttf", size=int(size * 0.55)) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) +# tray callbacks - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - pass - return _make_icon_image() - - - -def start_proxy(): - _runtime.start_proxy(_config) - - -def stop_proxy(): - _runtime.stop_proxy() - - -def restart_proxy(): - _runtime.restart_proxy() - - -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): - _user32.MessageBoxW(None, text, title, 0x10) - - -def _show_info(text: str, title: str = "TG WS Proxy"): - _user32.MessageBoxW(None, text, title, 0x40) - - -def _ask_open_release_page(latest_version: str, url: str) -> bool: - """Win32 Yes/No: открыть страницу релиза.""" - MB_YESNO = 0x4 - MB_ICONQUESTION = 0x20 - IDYES = 6 - text = ( - f"Доступна новая версия: {latest_version}\n\n" - f"Открыть страницу релиза в браузере?" - ) - r = _user32.MessageBoxW( - None, - text, - "TG WS Proxy — обновление", - MB_YESNO | MB_ICONQUESTION, - ) - return r == IDYES - - -def _maybe_notify_update_async(): - """ - Фоновая проверка GitHub Releases и уведомление (не блокирует трей). - """ - def _work(): - time.sleep(1.5) - if _exiting: - return - if not _config.get("check_updates", True): - return - try: - from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - run_check(__version__) - st = get_status() - if not st.get("has_update"): - return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - if _ask_open_release_page(str(ver), url): - webbrowser.open(url) - except Exception as exc: - log.debug("Update check failed: %s", exc) - - threading.Thread(target=_work, daemon=True, name="update-check").start() - - -def _on_open_in_telegram(icon=None, item=None): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server={host}&port={port}" +def _on_open_in_telegram(icon=None, item=None) -> None: + url = tg_proxy_url(_config) log.info("Opening %s", url) try: - result = webbrowser.open(url) - if not result: - raise RuntimeError("webbrowser.open returned False") + if not webbrowser.open(url): + raise RuntimeError except Exception: log.info("Browser open failed, copying to clipboard") if pyperclip is None: _show_error( "Не удалось открыть Telegram автоматически.\n\n" - f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}") + f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}" + ) return try: pyperclip.copy(url) _show_info( - f"Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", - "TG WS Proxy") + "Не удалось открыть Telegram автоматически.\n\n" + f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" + ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_restart(icon=None, item=None): - threading.Thread(target=restart_proxy, daemon=True).start() +def _on_copy_link(icon=None, item=None) -> None: + url = tg_proxy_url(_config) + log.info("Copying link: %s", url) + if pyperclip is None: + _show_error( + "Установите пакет pyperclip для копирования в буфер обмена." + ) + return + try: + pyperclip.copy(url) + except Exception as exc: + log.error("Clipboard copy failed: %s", exc) + _show_error(f"Не удалось скопировать ссылку:\n{exc}") -def _on_edit_config(icon=None, item=None): +def _on_restart(icon=None, item=None) -> None: + threading.Thread( + target=lambda: restart_proxy(_config, _show_error), daemon=True + ).start() + + +def _on_edit_config(icon=None, item=None) -> None: threading.Thread(target=_edit_config_dialog, daemon=True).start() -def _edit_config_dialog(): - if ctk is None: - _show_error("customtkinter не установлен.") - return - - cfg = dict(_config) - cfg["autostart"] = is_autostart_enabled() - - # Make sure that the autostart key is removed if autostart - # is disabled, even if the executable file is moved. - if _supports_autostart() and not cfg["autostart"]: - set_autostart_enabled(False) - - theme = ctk_theme_for_platform() - w, h = CONFIG_DIALOG_SIZE - if _supports_autostart(): - h += 100 - - icon_path = str(Path(__file__).parent / "icon.ico") - - root = create_ctk_root( - ctk, - title="TG WS Proxy — Настройки", - width=w, - height=h, - theme=theme, - after_create=lambda r: r.iconbitmap(icon_path), - ) - - fpx, fpy = CONFIG_DIALOG_FRAME_PAD - frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) - - scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - - widgets = install_tray_config_form( - ctk, - scroll, - theme, - cfg, - DEFAULT_CONFIG, - show_autostart=_supports_autostart(), - autostart_value=cfg.get("autostart", False), - ) - - def on_save(): - merged = validate_config_form( - widgets, - DEFAULT_CONFIG, - include_autostart=_supports_autostart(), - ) - if isinstance(merged, str): - _show_error(merged) - return - - new_cfg = merged - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - - if _supports_autostart(): - set_autostart_enabled(bool(new_cfg.get("autostart", False))) - - _tray_icon.menu = _build_menu() - - # Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk - # и даёт зависание; tkinter.messagebox согласован с циклом окна. - from tkinter import messagebox - if messagebox.askyesno("Перезапустить?", - "Настройки сохранены.\n\n" - "Перезапустить прокси сейчас?", - parent=root): - root.destroy() - restart_proxy() - else: - root.destroy() - - def on_cancel(): - root.destroy() - - install_tray_config_buttons( - ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) - - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass - - -def _on_open_logs(icon=None, item=None): +def _on_open_logs(icon=None, item=None) -> None: log.info("Opening log file: %s", LOG_FILE) if LOG_FILE.exists(): os.startfile(str(LOG_FILE)) else: - _show_info("Файл логов ещё не создан.", "TG WS Proxy") + _show_info("Файл логов ещё не создан.") -def _on_exit(icon=None, item=None): +def _on_exit(icon=None, item=None) -> None: global _exiting if _exiting: os._exit(0) return _exiting = True log.info("User requested exit") - - def _force_exit(): - time.sleep(3) - os._exit(0) - threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() - + quit_ctk() + threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start() if icon: icon.stop() +# settings dialog -def _show_first_run(): - _ensure_dirs() +def _edit_config_dialog() -> None: + if not ensure_ctk_thread(ctk): + _show_error("customtkinter не установлен.") + return + + cfg = dict(_config) + cfg["autostart"] = is_autostart_enabled() + if _supports_autostart() and not cfg["autostart"]: + set_autostart_enabled(False) + + def _build(done: threading.Event) -> None: + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE + if _supports_autostart(): + h += 100 + + root = create_ctk_toplevel( + ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, + after_create=lambda r: r.iconbitmap(ICON_PATH), + ) + fpx, fpy = CONFIG_DIALOG_FRAME_PAD + frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) + scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) + widgets = install_tray_config_form( + ctk, scroll, theme, cfg, DEFAULT_CONFIG, + show_autostart=_supports_autostart(), + autostart_value=cfg.get("autostart", False), + ) + + def _finish() -> None: + root.destroy() + done.set() + + def on_save() -> None: + from tkinter import messagebox + merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) + if isinstance(merged, str): + messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) + return + save_config(merged) + _config.update(merged) + log.info("Config saved: %s", merged) + if _supports_autostart(): + set_autostart_enabled(bool(merged.get("autostart", False))) + _tray_icon.menu = _build_menu() + + do_restart = messagebox.askyesno( + "Перезапустить?", + "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + parent=root, + ) + _finish() + if do_restart: + threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() + + root.protocol("WM_DELETE_WINDOW", _finish) + install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish) + + ctk_run_dialog(_build) + + +# first run + +def _show_first_run() -> None: + ensure_dirs() if FIRST_RUN_MARKER.exists(): return + if not ensure_ctk_thread(ctk): + FIRST_RUN_MARKER.touch() + return host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + secret = _config.get("secret", DEFAULT_CONFIG["secret"]) - if ctk is None: - FIRST_RUN_MARKER.touch() - return + def _build(done: threading.Event) -> None: + theme = ctk_theme_for_platform() + w, h = FIRST_RUN_SIZE + root = create_ctk_toplevel( + ctk, title="TG WS Proxy", width=w, height=h, theme=theme, + after_create=lambda r: r.iconbitmap(ICON_PATH), + ) - theme = ctk_theme_for_platform() - icon_path = str(Path(__file__).parent / "icon.ico") - w, h = FIRST_RUN_SIZE - root = create_ctk_root( - ctk, - title="TG WS Proxy", - width=w, - height=h, - theme=theme, - after_create=lambda r: r.iconbitmap(icon_path), - ) + def on_done(open_tg: bool) -> None: + FIRST_RUN_MARKER.touch() + root.destroy() + done.set() + if open_tg: + _on_open_in_telegram() - def on_done(open_tg: bool): - FIRST_RUN_MARKER.touch() - root.destroy() - if open_tg: - _on_open_in_telegram() + populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done) - populate_first_run_window( - ctk, root, theme, host=host, port=port, on_done=on_done) - - try: - root.mainloop() - finally: - import tkinter as tk - try: - if root.winfo_exists(): - root.destroy() - except tk.TclError: - pass + ctk_run_dialog(_build) -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if not ip or ip.startswith("::1"): - continue - try: - if ipaddress.IPv6Address(ip).is_link_local: - continue - except ValueError: - if ip.startswith("fe80:"): - continue - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - threading.Thread(target=_show_ipv6_dialog, daemon=True).start() - - -def _show_ipv6_dialog(): - _show_info( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз.", - "TG WS Proxy") - +# tray menu def _build_menu(): if pystray is None: return None host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) + link_host = tg_ws_proxy.get_link_host(host) return pystray.Menu( - pystray.MenuItem( - f"Открыть в Telegram ({host}:{port})", - _on_open_in_telegram, - default=True), + pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), + pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.Menu.SEPARATOR, pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Настройки...", _on_edit_config), @@ -618,23 +310,17 @@ def _build_menu(): ) -def run_tray(): +# entry point + +def run_tray() -> None: global _tray_icon, _config - _config = _runtime.prepare() - _runtime.reset_log_file() - - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy версия %s, tray app starting", __version__) - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) + _config = load_config() + bootstrap(_config) if pystray is None or Image is None or ctk is None: - log.error( - "pystray, Pillow or customtkinter not installed; " - "running in console mode") - start_proxy() + log.error("pystray, Pillow or customtkinter not installed; running in console mode") + start_proxy(_config, _show_error) try: while True: time.sleep(1) @@ -642,20 +328,12 @@ def run_tray(): stop_proxy() return - start_proxy() - - _maybe_notify_update_async() - + start_proxy(_config, _show_error) + maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _show_first_run() - _check_ipv6_warning() - - icon_image = _load_icon() - _tray_icon = pystray.Icon( - APP_NAME, - icon_image, - "TG WS Proxy", - menu=_build_menu()) + check_ipv6_warning(_show_info) + _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) log.info("Tray icon running") _tray_icon.run() @@ -663,15 +341,14 @@ def run_tray(): log.info("Tray app exited") -def main(): - if not _acquire_lock(): +def main() -> None: + if not acquire_lock("windows.py"): _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) return - try: run_tray() finally: - _release_lock() + release_lock() if __name__ == "__main__":