mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-22 23:41:44 +03:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c2907fa8 | ||
|
|
26b95ffa0f | ||
|
|
3dfcc27932 | ||
|
|
6e0e567790 | ||
|
|
bc79a5e4c1 | ||
|
|
ce83b78bac | ||
|
|
a6235f3594 | ||
|
|
c0d9b5f8e1 | ||
|
|
4041fd9f05 | ||
|
|
dd09f24449 | ||
|
|
dd666489e3 | ||
|
|
3af0cd75a2 | ||
|
|
535d4126ed | ||
|
|
44e754ded0 | ||
|
|
71be4461d3 | ||
|
|
9279399f00 | ||
|
|
557c92b9a3 | ||
|
|
c883674ad0 | ||
|
|
df98baf961 | ||
|
|
34dde32033 | ||
|
|
b8bd062663 | ||
|
|
8e1e3fcc45 | ||
|
|
097bb9d0b7 | ||
|
|
19fbf7494a | ||
|
|
4b0bc2f4d2 | ||
|
|
7850e1f5b4 | ||
|
|
63d5bafd3e | ||
|
|
7eaba0b29c |
2
.github/cfproxy-domains.txt
vendored
Normal file
2
.github/cfproxy-domains.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
virkgj.com
|
||||||
|
vmmzovy.com
|
||||||
56
.github/workflows/build.yml
vendored
56
.github/workflows/build.yml
vendored
@@ -29,15 +29,43 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Setup MSVC 14.40 toolset
|
||||||
|
uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
with:
|
||||||
|
toolset: 14.40
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install .
|
run: pip install .
|
||||||
|
|
||||||
- name: Install pyinstaller
|
- name: Build PyInstaller bootloader from source
|
||||||
run: pip install "pyinstaller==6.13.0"
|
run: |
|
||||||
|
pip install "pyinstaller==6.16.0" --no-binary pyinstaller
|
||||||
|
env:
|
||||||
|
PYINSTALLER_COMPILE_BOOTLOADER: 1
|
||||||
|
|
||||||
- name: Build EXE with PyInstaller
|
- name: Build EXE with PyInstaller
|
||||||
run: pyinstaller packaging/windows.spec --noconfirm
|
run: pyinstaller packaging/windows.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Strip Rich PE header
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
import struct, pathlib
|
||||||
|
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||||
|
data = bytearray(exe.read_bytes())
|
||||||
|
rich = data.find(b'Rich')
|
||||||
|
if rich == -1:
|
||||||
|
raise SystemExit('Rich header not found')
|
||||||
|
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||||
|
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||||
|
ds = data.find(dans)
|
||||||
|
if ds == -1:
|
||||||
|
raise SystemExit('DanS marker not found')
|
||||||
|
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||||
|
exe.write_bytes(data)
|
||||||
|
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||||
|
"
|
||||||
|
|
||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
||||||
|
|
||||||
@@ -71,6 +99,26 @@ jobs:
|
|||||||
- name: Build EXE with PyInstaller
|
- name: Build EXE with PyInstaller
|
||||||
run: pyinstaller packaging/windows.spec --noconfirm
|
run: pyinstaller packaging/windows.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Strip Rich PE header
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
import struct, pathlib
|
||||||
|
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||||
|
data = bytearray(exe.read_bytes())
|
||||||
|
rich = data.find(b'Rich')
|
||||||
|
if rich == -1:
|
||||||
|
raise SystemExit('Rich header not found')
|
||||||
|
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||||
|
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||||
|
ds = data.find(dans)
|
||||||
|
if ds == -1:
|
||||||
|
raise SystemExit('DanS marker not found')
|
||||||
|
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||||
|
exe.write_bytes(data)
|
||||||
|
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||||
|
"
|
||||||
|
|
||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||||
|
|
||||||
@@ -145,7 +193,7 @@ jobs:
|
|||||||
|
|
||||||
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
|
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
|
||||||
python3.12 -m pip install .
|
python3.12 -m pip install .
|
||||||
python3.12 -m pip install pyinstaller==6.13.0
|
python3.12 -m pip install pyinstaller==6.16.0
|
||||||
|
|
||||||
- name: Create macOS icon from ICO
|
- name: Create macOS icon from ICO
|
||||||
run: |
|
run: |
|
||||||
@@ -247,7 +295,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
.venv/bin/pip install --upgrade pip
|
.venv/bin/pip install --upgrade pip
|
||||||
.venv/bin/pip install .
|
.venv/bin/pip install .
|
||||||
.venv/bin/pip install "pyinstaller==6.13.0"
|
.venv/bin/pip install "pyinstaller==6.16.0"
|
||||||
|
|
||||||
- name: Build binary with PyInstaller
|
- name: Build binary with PyInstaller
|
||||||
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
|
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Cloudflare Proxy
|
# Cloudflare Proxy
|
||||||
|
|
||||||
Для недоступных датацентров можно использовать альтернативный бесплатный метод подключения - проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и лучше) заменить на свой.
|
Для недоступных датацентров можно использовать альтернативный бесплатный метод подключения - проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и лучше) заменить на свой.
|
||||||
|
|
||||||
|
Прокси возвращает доступ к тому, что до этого не грузило (реакциям, некоторым стикерам). Если у вас до этого не грузило видео/фото на аккаунте без премиума, то уберите всё кроме `4:149.154.167.220` из `DC->IP` блока в настройках. Если CF-прокси у вас работает - медиа снова начнёт грузиться.
|
||||||
|
|
||||||
## Зачем мне настраивать свой домен?
|
## Зачем мне настраивать свой домен?
|
||||||
Cloudflare имеет лимиты на одновременное количество подключений WS. Домен по умолчанию может перестать работать в любой момент.
|
Cloudflare имеет лимиты на одновременное количество подключений WS. Домен по умолчанию может перестать работать в любой момент.
|
||||||
|
|
||||||
## Настройка своего домена
|
## Настройка своего домена
|
||||||
1. Добавьте свой домен в Cloudflare (либо купив у них напрямую, либо поменяв NS сервера: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)
|
1. Добавьте свой домен в Cloudflare (либо купив у них напрямую, либо поменяв NS сервера: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят +- 150 рублей на год, подойдёт любой.
|
||||||
|
|
||||||
2. В `SSL/TLS` -> `Overview` выставьте режим **Flexible**
|
2. В `SSL/TLS` -> `Overview` выставьте режим **Flexible**
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ Cloudflare имеет лимиты на одновременное количе
|
|||||||
- Name=`kws3` IPv4=`149.154.175.100`
|
- Name=`kws3` IPv4=`149.154.175.100`
|
||||||
- Name=`kws4` IPv4=`149.154.167.91`
|
- Name=`kws4` IPv4=`149.154.167.91`
|
||||||
- Name=`kws5` IPv4=`149.154.171.5`
|
- Name=`kws5` IPv4=`149.154.171.5`
|
||||||
- Name=`kws203` IPv4=`149.154.175.50`
|
- Name=`kws203` IPv4=`91.105.192.100`
|
||||||
|
|
||||||
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или другой софт для обхода блокировок, так как подсеть Cloudflare забанена (по крайней мере, если вы из России)**
|
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или другой софт для обхода блокировок, так как подсеть Cloudflare забанена (по крайней мере, если вы из России)**
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> ### 🎉 Поддержать меня
|
||||||
|
>
|
||||||
|
> USDT (TRC20): `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
|
> BTC: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
|
> ETH: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
>
|
>
|
||||||
> ### Реакция антивирусов
|
> ### Реакция антивирусов
|
||||||
@@ -10,14 +18,6 @@
|
|||||||
>
|
>
|
||||||
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
|
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
>
|
|
||||||
> ### 🎉 Поддержать меня
|
|
||||||
>
|
|
||||||
> USDT (TRC20): `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
|
||||||
> BTC: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
|
||||||
> ETH: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
|
||||||
|
|
||||||
# TG WS Proxy
|
# TG WS Proxy
|
||||||
|
|
||||||
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
|
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
|
||||||
@@ -36,6 +36,12 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
|
|||||||
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
|
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
|
||||||
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
|
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> ### Не грузит фото/видео?
|
||||||
|
> ### Удалите в настройках прокси в DC->IP всё, кроме `4:149.154.167.220`
|
||||||
|
> Подобная проблема встречается на аккаунтах без Premium
|
||||||
|
> Если вам не помогло, то настраивайте свой домен по гайду отсюда: https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
@@ -160,20 +166,6 @@ tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
|||||||
tg-ws-proxy -v
|
tg-ws-proxy -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI-скрипты (pyproject.toml)
|
|
||||||
|
|
||||||
CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`.
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[project.scripts]
|
|
||||||
tg-ws-proxy = "proxy.tg_ws_proxy:main"
|
|
||||||
tg-ws-proxy-tray-win = "windows:main"
|
|
||||||
tg-ws-proxy-tray-macos = "macos:main"
|
|
||||||
tg-ws-proxy-tray-linux = "linux:main"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Настройка Telegram Desktop
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
### Автоматически
|
### Автоматически
|
||||||
|
|||||||
8
linux.py
8
linux.py
@@ -12,7 +12,7 @@ import pyperclip
|
|||||||
import pystray
|
import pystray
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import get_link_host
|
||||||
|
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
||||||
@@ -138,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _edit_config_dialog() -> None:
|
def _edit_config_dialog() -> None:
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
_show_error("customtkinter не установлен.")
|
_show_error("customtkinter не установлен.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ def _show_first_run() -> None:
|
|||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
if FIRST_RUN_MARKER.exists():
|
if FIRST_RUN_MARKER.exists():
|
||||||
return
|
return
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
FIRST_RUN_MARKER.touch()
|
FIRST_RUN_MARKER.touch()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ def _show_first_run() -> None:
|
|||||||
def _build_menu():
|
def _build_menu():
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
return pystray.Menu(
|
||||||
pystray.MenuItem(f"Открыть в Telegram ({link_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.MenuItem("Скопировать ссылку", _on_copy_link),
|
||||||
|
|||||||
25
macos.py
25
macos.py
@@ -24,8 +24,8 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pyperclip = None
|
pyperclip = None
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
||||||
from proxy import __version__
|
from proxy.tg_ws_proxy import _run
|
||||||
|
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
|
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
|
||||||
@@ -153,7 +153,7 @@ def _run_proxy_thread() -> None:
|
|||||||
stop_ev = _asyncio.Event()
|
stop_ev = _asyncio.Event()
|
||||||
_async_stop = (loop, stop_ev)
|
_async_stop = (loop, stop_ev)
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Proxy thread crashed: %s", exc)
|
log.error("Proxy thread crashed: %s", exc)
|
||||||
if "Address already in use" in str(exc):
|
if "Address already in use" in str(exc):
|
||||||
@@ -176,7 +176,7 @@ def _start_proxy() -> None:
|
|||||||
if not apply_proxy_config(_config):
|
if not apply_proxy_config(_config):
|
||||||
_show_error("Ошибка конфигурации DC → IP.")
|
_show_error("Ошибка конфигурации DC → IP.")
|
||||||
return
|
return
|
||||||
pc = tg_ws_proxy.proxy_config
|
pc = proxy_config
|
||||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
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 = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
|
||||||
_proxy_thread.start()
|
_proxy_thread.start()
|
||||||
@@ -362,7 +362,7 @@ def _edit_config_dialog() -> None:
|
|||||||
return
|
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:
|
try:
|
||||||
tg_ws_proxy.parse_dc_ip_list(dc_lines)
|
parse_dc_ip_list(dc_lines)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
_show_error(str(e))
|
_show_error(str(e))
|
||||||
return
|
return
|
||||||
@@ -404,10 +404,9 @@ def _edit_config_dialog() -> None:
|
|||||||
cfproxy_priority = cfproxy_priority_result
|
cfproxy_priority = cfproxy_priority_result
|
||||||
|
|
||||||
cfproxy_domain = _osascript_input(
|
cfproxy_domain = _osascript_input(
|
||||||
"Домен CF-прокси:\n"
|
"Свой CF-домен (оставьте пустым для автоматического выбора):\n"
|
||||||
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.\n"
|
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
||||||
"pclead.co.uk готовый настроенный домен. Подробнее про настройку читайте в репозитории - docs/CfProxy.md",
|
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")),
|
||||||
cfg.get("cfproxy_domain", DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk")),
|
|
||||||
)
|
)
|
||||||
if cfproxy_domain is None:
|
if cfproxy_domain is None:
|
||||||
return
|
return
|
||||||
@@ -425,7 +424,7 @@ def _edit_config_dialog() -> None:
|
|||||||
"check_updates": cfg.get("check_updates", True),
|
"check_updates": cfg.get("check_updates", True),
|
||||||
"cfproxy": cfproxy,
|
"cfproxy": cfproxy,
|
||||||
"cfproxy_priority": cfproxy_priority,
|
"cfproxy_priority": cfproxy_priority,
|
||||||
"cfproxy_domain": cfproxy_domain or DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk"),
|
"cfproxy_user_domain": cfproxy_domain,
|
||||||
}
|
}
|
||||||
save_config(new_cfg)
|
save_config(new_cfg)
|
||||||
log.info("Config saved: %s", new_cfg)
|
log.info("Config saved: %s", new_cfg)
|
||||||
@@ -451,7 +450,7 @@ def _show_first_run() -> None:
|
|||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
tg_url = tg_proxy_url(_config)
|
tg_url = tg_proxy_url(_config)
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
f"Прокси запущен и работает в строке меню.\n\n"
|
f"Прокси запущен и работает в строке меню.\n\n"
|
||||||
@@ -520,7 +519,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
|
|||||||
|
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
|
|
||||||
self._open_tg_item = rumps.MenuItem(
|
self._open_tg_item = rumps.MenuItem(
|
||||||
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
|
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
|
||||||
@@ -560,7 +559,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
|
|||||||
def update_menu_title(self) -> None:
|
def update_menu_title(self) -> None:
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
|
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,11 +46,25 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||||
|
|||||||
@@ -25,11 +25,25 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
|
||||||
if not os.path.exists(icon_path):
|
if not os.path.exists(icon_path):
|
||||||
icon_path = None
|
icon_path = None
|
||||||
|
|||||||
36
packaging/version_info.txt
Normal file
36
packaging/version_info.txt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# UTF-8
|
||||||
|
#
|
||||||
|
# For more details about fixed file info 'ffi' see:
|
||||||
|
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=(1, 0, 0, 0),
|
||||||
|
prodvers=(1, 0, 0, 0),
|
||||||
|
mask=0x3f,
|
||||||
|
flags=0x0,
|
||||||
|
OS=0x40004,
|
||||||
|
fileType=0x1,
|
||||||
|
subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo(
|
||||||
|
[
|
||||||
|
StringTable(
|
||||||
|
u'040904B0',
|
||||||
|
[
|
||||||
|
StringStruct(u'CompanyName', u'Flowseal'),
|
||||||
|
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||||
|
StringStruct(u'FileVersion', u'1.0.0.0'),
|
||||||
|
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||||
|
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||||
|
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||||
|
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||||
|
StringStruct(u'ProductVersion', u'1.0.0.0'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -26,14 +26,29 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||||
|
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||||
|
|
||||||
@@ -50,7 +65,7 @@ exe = EXE(
|
|||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=False,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False,
|
||||||
@@ -60,4 +75,5 @@ exe = EXE(
|
|||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon=icon_path if os.path.exists(icon_path) else None,
|
icon=icon_path if os.path.exists(icon_path) else None,
|
||||||
|
version=version_path if os.path.exists(version_path) else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
__version__ = "1.5.0"
|
from .config import parse_dc_ip_list, proxy_config
|
||||||
|
from .utils import get_link_host
|
||||||
|
|
||||||
|
__version__ = "1.6.0"
|
||||||
|
|
||||||
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"]
|
||||||
361
proxy/bridge.py
Normal file
361
proxy/bridge.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
from .stats import stats
|
||||||
|
from .config import proxy_config
|
||||||
|
from .raw_websocket import RawWebSocket
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
_st_I_le = struct.Struct('<I')
|
||||||
|
|
||||||
|
ZERO_64 = b'\x00' * 64
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoCtx:
|
||||||
|
__slots__ = ('clt_dec', 'clt_enc', 'tg_enc', 'tg_dec')
|
||||||
|
|
||||||
|
def __init__(self, clt_dec, clt_enc, tg_enc, tg_dec):
|
||||||
|
self.clt_dec = clt_dec # decrypt from client
|
||||||
|
self.clt_enc = clt_enc # encrypt to client
|
||||||
|
self.tg_enc = tg_enc # encrypt to telegram
|
||||||
|
self.tg_dec = tg_dec # decrypt from telegram
|
||||||
|
|
||||||
|
|
||||||
|
class MsgSplitter:
|
||||||
|
"""
|
||||||
|
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, 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]:
|
||||||
|
if not chunk:
|
||||||
|
return []
|
||||||
|
if self._disabled:
|
||||||
|
return [chunk]
|
||||||
|
|
||||||
|
self._cipher_buf.extend(chunk)
|
||||||
|
self._plain_buf.extend(self._dec.update(chunk))
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
while self._cipher_buf:
|
||||||
|
packet_len = self._next_packet_len()
|
||||||
|
if packet_len is None:
|
||||||
|
break
|
||||||
|
if packet_len <= 0:
|
||||||
|
parts.append(bytes(self._cipher_buf))
|
||||||
|
self._cipher_buf.clear()
|
||||||
|
self._plain_buf.clear()
|
||||||
|
self._disabled = True
|
||||||
|
break
|
||||||
|
parts.append(bytes(self._cipher_buf[:packet_len]))
|
||||||
|
del self._cipher_buf[:packet_len]
|
||||||
|
del self._plain_buf[:packet_len]
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def flush(self) -> List[bytes]:
|
||||||
|
if not self._cipher_buf:
|
||||||
|
return []
|
||||||
|
tail = bytes(self._cipher_buf)
|
||||||
|
self._cipher_buf.clear()
|
||||||
|
self._plain_buf.clear()
|
||||||
|
return [tail]
|
||||||
|
|
||||||
|
def _next_packet_len(self) -> Optional[int]:
|
||||||
|
if not self._plain_buf:
|
||||||
|
return None
|
||||||
|
if self._proto == PROTO_ABRIDGED_INT:
|
||||||
|
return self._next_abridged_len()
|
||||||
|
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||||
|
PROTO_PADDED_INTERMEDIATE_INT):
|
||||||
|
return self._next_intermediate_len()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _next_abridged_len(self) -> Optional[int]:
|
||||||
|
first = self._plain_buf[0]
|
||||||
|
if first in (0x7F, 0xFF):
|
||||||
|
if len(self._plain_buf) < 4:
|
||||||
|
return None
|
||||||
|
payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4
|
||||||
|
header_len = 4
|
||||||
|
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
|
||||||
|
return packet_len
|
||||||
|
|
||||||
|
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
|
||||||
|
return packet_len
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
|
dc, is_media, media_tag,
|
||||||
|
ctx: CryptoCtx, splitter=None):
|
||||||
|
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
||||||
|
use_cf = proxy_config.fallback_cfproxy
|
||||||
|
cf_first = proxy_config.fallback_cfproxy_priority
|
||||||
|
|
||||||
|
methods: List[str] = ['tcp']
|
||||||
|
|
||||||
|
if use_cf:
|
||||||
|
methods.insert(0 if cf_first else 1, 'cf')
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
if method == 'cf':
|
||||||
|
ok = await _cfproxy_fallback(
|
||||||
|
reader, writer, relay_init, label,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
ctx=ctx, splitter=splitter)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
elif method == 'tcp' and fallback_dst:
|
||||||
|
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, ctx=ctx)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
|
dc=None, is_media=False,
|
||||||
|
ctx: CryptoCtx = None, splitter=None):
|
||||||
|
media_tag = ' media' if is_media else ''
|
||||||
|
|
||||||
|
active = proxy_config.active_cfproxy_domain
|
||||||
|
others = [d for d in proxy_config.cfproxy_domains if d != active]
|
||||||
|
|
||||||
|
ws = None
|
||||||
|
chosen_domain = None
|
||||||
|
|
||||||
|
log.info("[%s] DC%d%s -> trying CF proxy",
|
||||||
|
label, dc, media_tag)
|
||||||
|
|
||||||
|
for base_domain in ([active] + others):
|
||||||
|
domain = f'kws{dc}.{base_domain}'
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
|
||||||
|
chosen_domain = base_domain
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] DC%d%s CF proxy failed: %s",
|
||||||
|
label, dc, media_tag, exc)
|
||||||
|
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if chosen_domain and chosen_domain != proxy_config.active_cfproxy_domain:
|
||||||
|
log.info("[%s] Switching active CF domain", label)
|
||||||
|
proxy_config.active_cfproxy_domain = chosen_domain
|
||||||
|
|
||||||
|
stats.connections_cfproxy += 1
|
||||||
|
await ws.send(relay_init)
|
||||||
|
await bridge_ws_reencrypt(reader, writer, ws, label,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
ctx=ctx, splitter=splitter)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _tcp_fallback(reader, writer, dst, port, relay_init, label,
|
||||||
|
dc=None, is_media=False, ctx: CryptoCtx = None):
|
||||||
|
try:
|
||||||
|
rr, rw = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(dst, port), timeout=10)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] TCP fallback to %s:%d failed: %s",
|
||||||
|
label, dst, port, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
stats.connections_tcp_fallback += 1
|
||||||
|
rw.write(relay_init)
|
||||||
|
await rw.drain()
|
||||||
|
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label,
|
||||||
|
dc=dc, is_media=is_media, ctx=ctx)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||||
|
dc=None, is_media=False,
|
||||||
|
ctx: CryptoCtx = 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?"
|
||||||
|
|
||||||
|
up_bytes = 0
|
||||||
|
down_bytes = 0
|
||||||
|
up_packets = 0
|
||||||
|
down_packets = 0
|
||||||
|
start_time = asyncio.get_running_loop().time()
|
||||||
|
|
||||||
|
async def tcp_to_ws():
|
||||||
|
nonlocal up_bytes, up_packets
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
if splitter:
|
||||||
|
tail = splitter.flush()
|
||||||
|
if tail:
|
||||||
|
await ws.send(tail[0])
|
||||||
|
break
|
||||||
|
n = len(chunk)
|
||||||
|
stats.bytes_up += n
|
||||||
|
up_bytes += n
|
||||||
|
up_packets += 1
|
||||||
|
plain = ctx.clt_dec.update(chunk)
|
||||||
|
chunk = ctx.tg_enc.update(plain)
|
||||||
|
if splitter:
|
||||||
|
parts = splitter.split(chunk)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if len(parts) > 1:
|
||||||
|
await ws.send_batch(parts)
|
||||||
|
else:
|
||||||
|
await ws.send(parts[0])
|
||||||
|
else:
|
||||||
|
await ws.send(chunk)
|
||||||
|
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||||
|
|
||||||
|
async def ws_to_tcp():
|
||||||
|
nonlocal down_bytes, down_packets
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.recv()
|
||||||
|
if data is None:
|
||||||
|
break
|
||||||
|
n = len(data)
|
||||||
|
stats.bytes_down += n
|
||||||
|
down_bytes += n
|
||||||
|
down_packets += 1
|
||||||
|
plain = ctx.tg_dec.update(data)
|
||||||
|
data = ctx.clt_enc.update(plain)
|
||||||
|
writer.write(data)
|
||||||
|
await writer.drain()
|
||||||
|
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||||
|
asyncio.create_task(ws_to_tcp())]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
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,
|
||||||
|
human_bytes(up_bytes), up_packets,
|
||||||
|
human_bytes(down_bytes), down_packets,
|
||||||
|
elapsed)
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
|
||||||
|
label, dc=None, is_media=False,
|
||||||
|
ctx: CryptoCtx = None):
|
||||||
|
"""Bidirectional TCP <-> TCP with re-encryption."""
|
||||||
|
|
||||||
|
async def forward(src, dst_w, is_up):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await src.read(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
n = len(data)
|
||||||
|
if is_up:
|
||||||
|
stats.bytes_up += n
|
||||||
|
plain = ctx.clt_dec.update(data)
|
||||||
|
data = ctx.tg_enc.update(plain)
|
||||||
|
else:
|
||||||
|
stats.bytes_down += n
|
||||||
|
plain = ctx.tg_dec.update(data)
|
||||||
|
data = ctx.clt_enc.update(plain)
|
||||||
|
dst_w.write(data)
|
||||||
|
await dst_w.drain()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] forward ended: %s", label, e)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(forward(reader, remote_writer, True)),
|
||||||
|
asyncio.create_task(forward(remote_reader, writer, False)),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
for w in (writer, remote_writer):
|
||||||
|
try:
|
||||||
|
w.close()
|
||||||
|
await w.wait_closed()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
109
proxy/config.py
Normal file
109
proxy/config.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
import socket as _socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
CFPROXY_DOMAINS_URL = (
|
||||||
|
"https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main"
|
||||||
|
"/.github/cfproxy-domains.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CFPROXY_ENC: List[str] = ['virkgj.com']
|
||||||
|
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||||
|
|
||||||
|
|
||||||
|
def _dd(s: str) -> str:
|
||||||
|
"""Only for decoding CF proxy domains"""
|
||||||
|
if not s[-4:] == '.com':
|
||||||
|
return s
|
||||||
|
p, n = s[:-4], sum(c.isalpha() for c in s[:-4])
|
||||||
|
return ''.join(
|
||||||
|
chr((ord(c) - (97 if c > '`' else 65) - n) % 26 + (97 if c > '`' else 65))
|
||||||
|
if c.isalpha() else c for c in p
|
||||||
|
) + _S
|
||||||
|
|
||||||
|
|
||||||
|
CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
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'})
|
||||||
|
buffer_size: int = 256 * 1024
|
||||||
|
pool_size: int = 4
|
||||||
|
fallback_cfproxy: bool = True
|
||||||
|
fallback_cfproxy_priority: bool = True
|
||||||
|
cfproxy_user_domain: str = ''
|
||||||
|
cfproxy_domains: List[str] = field(default_factory=lambda: list(CFPROXY_DEFAULT_DOMAINS))
|
||||||
|
active_cfproxy_domain: str = field(default_factory=lambda: random.choice(CFPROXY_DEFAULT_DOMAINS))
|
||||||
|
|
||||||
|
|
||||||
|
proxy_config = ProxyConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_cfproxy_domain_list() -> List[str]:
|
||||||
|
try:
|
||||||
|
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
||||||
|
headers={'User-Agent': 'tg-ws-proxy'})
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
text = resp.read().decode('utf-8', errors='replace')
|
||||||
|
encoded = [
|
||||||
|
line.strip() for line in text.splitlines()
|
||||||
|
if line.strip() and not line.startswith('#')
|
||||||
|
]
|
||||||
|
return [_dd(d) for d in encoded]
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to fetch CF proxy domain list: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_cfproxy_domains() -> None:
|
||||||
|
if proxy_config.cfproxy_user_domain:
|
||||||
|
return
|
||||||
|
|
||||||
|
fetched = _fetch_cfproxy_domain_list()
|
||||||
|
|
||||||
|
if fetched:
|
||||||
|
seen = set()
|
||||||
|
pool = [d for d in fetched if not (d in seen or seen.add(d))]
|
||||||
|
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool))
|
||||||
|
else:
|
||||||
|
pool = list(proxy_config.cfproxy_domains) or list(CFPROXY_DEFAULT_DOMAINS)
|
||||||
|
|
||||||
|
proxy_config.cfproxy_domains = pool
|
||||||
|
proxy_config.active_cfproxy_domain = random.choice(pool)
|
||||||
|
|
||||||
|
|
||||||
|
def start_cfproxy_domain_refresh() -> None:
|
||||||
|
threading.Thread(
|
||||||
|
target=refresh_cfproxy_domains,
|
||||||
|
daemon=True,
|
||||||
|
name='cfproxy-domains-refresh',
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
|
||||||
|
dc_redirects: Dict[int, str] = {}
|
||||||
|
for entry in dc_ip_list:
|
||||||
|
if ':' not in entry:
|
||||||
|
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_redirects[dc_n] = ip_s
|
||||||
|
return dc_redirects
|
||||||
239
proxy/raw_websocket.py
Normal file
239
proxy/raw_websocket.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import asyncio
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from .config import proxy_config
|
||||||
|
|
||||||
|
|
||||||
|
_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')
|
||||||
|
|
||||||
|
_ssl_ctx = ssl.create_default_context()
|
||||||
|
_ssl_ctx.check_hostname = False
|
||||||
|
_ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
class WsHandshakeError(Exception):
|
||||||
|
def __init__(self, status_code: int, status_line: str,
|
||||||
|
headers: dict = None, location: str = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.status_line = status_line
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.location = location
|
||||||
|
super().__init__(f"HTTP {status_code}: {status_line}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_redirect(self) -> bool:
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
def set_sock_opts(transport, buffer_size):
|
||||||
|
sock = transport.get_extra_info('socket')
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, buffer_size)
|
||||||
|
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, buffer_size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RawWebSocket:
|
||||||
|
__slots__ = ('reader', 'writer', '_closed')
|
||||||
|
|
||||||
|
OP_BINARY = 0x2
|
||||||
|
OP_CLOSE = 0x8
|
||||||
|
OP_PING = 0x9
|
||||||
|
OP_PONG = 0xA
|
||||||
|
|
||||||
|
def __init__(self, reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter):
|
||||||
|
self.reader = reader
|
||||||
|
self.writer = writer
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def connect(host: str, domain: str, timeout: float = 10.0) -> 'RawWebSocket':
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||||
|
server_hostname=domain),
|
||||||
|
timeout=min(timeout, 10))
|
||||||
|
|
||||||
|
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||||
|
|
||||||
|
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
|
||||||
|
req = (
|
||||||
|
f'GET /apiws HTTP/1.1\r\n'
|
||||||
|
f'Host: {domain}\r\n'
|
||||||
|
f'Upgrade: websocket\r\n'
|
||||||
|
f'Connection: Upgrade\r\n'
|
||||||
|
f'Sec-WebSocket-Key: {ws_key}\r\n'
|
||||||
|
f'Sec-WebSocket-Version: 13\r\n'
|
||||||
|
f'Sec-WebSocket-Protocol: binary\r\n'
|
||||||
|
f'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||||
|
f'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||||
|
f'Chrome/131.0.0.0 Safari/537.36\r\n'
|
||||||
|
f'\r\n'
|
||||||
|
)
|
||||||
|
writer.write(req.encode())
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
response_lines: list[str] = []
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await asyncio.wait_for(reader.readline(),
|
||||||
|
timeout=timeout)
|
||||||
|
if line in (b'\r\n', b'\n', b''):
|
||||||
|
break
|
||||||
|
response_lines.append(
|
||||||
|
line.decode('utf-8', errors='replace').strip())
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not response_lines:
|
||||||
|
writer.close()
|
||||||
|
raise WsHandshakeError(0, 'empty response')
|
||||||
|
|
||||||
|
first_line = response_lines[0]
|
||||||
|
parts = first_line.split(' ', 2)
|
||||||
|
try:
|
||||||
|
status_code = int(parts[1]) if len(parts) >= 2 else 0
|
||||||
|
except ValueError:
|
||||||
|
status_code = 0
|
||||||
|
|
||||||
|
if status_code == 101:
|
||||||
|
return RawWebSocket(reader, writer)
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for hl in response_lines[1:]:
|
||||||
|
if ':' in hl:
|
||||||
|
k, v = hl.split(':', 1)
|
||||||
|
headers[k.strip().lower()] = v.strip()
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
raise WsHandshakeError(status_code, first_line, headers,
|
||||||
|
location=headers.get('location'))
|
||||||
|
|
||||||
|
async def send(self, data: bytes):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
frame = self._build_frame(self.OP_BINARY, data, mask=True)
|
||||||
|
self.writer.write(frame)
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def send_batch(self, parts: List[bytes]):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
for part in parts:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def recv(self) -> Optional[bytes]:
|
||||||
|
while not self._closed:
|
||||||
|
opcode, payload = await self._read_frame()
|
||||||
|
|
||||||
|
if opcode == self.OP_CLOSE:
|
||||||
|
self._closed = True
|
||||||
|
try:
|
||||||
|
self.writer.write(self._build_frame(
|
||||||
|
self.OP_CLOSE,
|
||||||
|
payload[:2] if payload else b'', mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
if opcode == self.OP_PING:
|
||||||
|
try:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_PONG, payload, mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if opcode == self.OP_PONG:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if opcode in (0x1, 0x2):
|
||||||
|
return payload
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
try:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_CLOSE, b'', mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_frame(opcode: int, data: bytes,
|
||||||
|
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:
|
||||||
|
return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
|
||||||
|
if length < 65536:
|
||||||
|
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
|
||||||
|
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
|
||||||
|
|
||||||
|
async def _read_frame(self) -> Tuple[int, bytes]:
|
||||||
|
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]
|
||||||
|
elif length == 127:
|
||||||
|
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
|
||||||
33
proxy/stats.py
Normal file
33
proxy/stats.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from .utils import human_bytes
|
||||||
|
|
||||||
|
class _Stats:
|
||||||
|
def __init__(self):
|
||||||
|
self.connections_total = 0
|
||||||
|
self.connections_active = 0
|
||||||
|
self.connections_ws = 0
|
||||||
|
self.connections_tcp_fallback = 0
|
||||||
|
self.connections_cfproxy = 0
|
||||||
|
self.connections_bad = 0
|
||||||
|
self.ws_errors = 0
|
||||||
|
self.bytes_up = 0
|
||||||
|
self.bytes_down = 0
|
||||||
|
self.pool_hits = 0
|
||||||
|
self.pool_misses = 0
|
||||||
|
|
||||||
|
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} "
|
||||||
|
f"active={self.connections_active} "
|
||||||
|
f"ws={self.connections_ws} "
|
||||||
|
f"tcp_fb={self.connections_tcp_fallback} "
|
||||||
|
f"cf={self.connections_cfproxy} "
|
||||||
|
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()
|
||||||
File diff suppressed because it is too large
Load Diff
48
proxy/utils.py
Normal file
48
proxy/utils.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
ZERO_64 = b'\x00' * 64
|
||||||
|
HANDSHAKE_LEN = 64
|
||||||
|
SKIP_LEN = 8
|
||||||
|
PREKEY_LEN = 32
|
||||||
|
KEY_LEN = 32
|
||||||
|
IV_LEN = 16
|
||||||
|
PROTO_TAG_POS = 56
|
||||||
|
DC_IDX_POS = 60
|
||||||
|
|
||||||
|
PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef'
|
||||||
|
PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee'
|
||||||
|
PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd'
|
||||||
|
|
||||||
|
PROTO_ABRIDGED_INT = 0xEFEFEFEF
|
||||||
|
PROTO_INTERMEDIATE_INT = 0xEEEEEEEE
|
||||||
|
PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
def human_bytes(n: int) -> str:
|
||||||
|
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||||
|
if abs(n) < 1024:
|
||||||
|
return f"{n:.1f}{unit}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f}TB"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -71,3 +71,6 @@ packages = ["proxy", "ui", "utils"]
|
|||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
path = "proxy/__init__.py"
|
path = "proxy/__init__.py"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = ["F403", "F405"]
|
||||||
@@ -51,8 +51,11 @@ def ctk_theme_for_platform() -> CtkTheme:
|
|||||||
return CtkTheme()
|
return CtkTheme()
|
||||||
|
|
||||||
|
|
||||||
def apply_ctk_appearance(ctk: Any) -> None:
|
_APPEARANCE_MODE_MAP = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
ctk.set_appearance_mode("auto")
|
|
||||||
|
|
||||||
|
def apply_ctk_appearance(ctk: Any, mode: str = "auto") -> None:
|
||||||
|
ctk.set_appearance_mode(_APPEARANCE_MODE_MAP.get(mode, "system"))
|
||||||
ctk.set_default_color_theme("blue")
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import webbrowser
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import __version__, get_link_host, parse_dc_ip_list
|
||||||
from proxy import __version__
|
from proxy.config import CFPROXY_DEFAULT_DOMAINS
|
||||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||||
|
|
||||||
|
|
||||||
from ui.ctk_theme import (
|
from ui.ctk_theme import (
|
||||||
FIRST_RUN_FRAME_PAD,
|
FIRST_RUN_FRAME_PAD,
|
||||||
CtkTheme,
|
CtkTheme,
|
||||||
@@ -58,7 +59,11 @@ _TIP_CFPROXY_PRIORITY = (
|
|||||||
"Пробовать CF-прокси раньше прямого TCP-подключения"
|
"Пробовать CF-прокси раньше прямого TCP-подключения"
|
||||||
)
|
)
|
||||||
_TIP_CFPROXY_DOMAIN = (
|
_TIP_CFPROXY_DOMAIN = (
|
||||||
"Домен, проксируемый через Cloudflare, для WS-подключения"
|
"Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n"
|
||||||
|
"Если не указан — выбирается автоматически из поддерживаемых доменов"
|
||||||
|
)
|
||||||
|
_TIP_CFPROXY_USER_DOMAIN_CB = (
|
||||||
|
"Указать свой домен вместо автоматического выбора"
|
||||||
)
|
)
|
||||||
_TIP_SAVE = "Сохранить настройки"
|
_TIP_SAVE = "Сохранить настройки"
|
||||||
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
||||||
@@ -115,6 +120,16 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
||||||
|
last: dict = {}
|
||||||
|
for domain in domains:
|
||||||
|
res = _run_cfproxy_connectivity_test(domain)
|
||||||
|
last = res
|
||||||
|
if any(v is True for v in res.values()):
|
||||||
|
return domain, res
|
||||||
|
return None, last
|
||||||
|
|
||||||
|
|
||||||
def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
||||||
import tkinter as _tk
|
import tkinter as _tk
|
||||||
from tkinter import messagebox as _mb
|
from tkinter import messagebox as _mb
|
||||||
@@ -145,8 +160,35 @@ def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
|||||||
_mb.showinfo(title, msg, parent=root)
|
_mb.showinfo(title, msg, parent=root)
|
||||||
root.destroy()
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def _cfproxy_show_auto_test_results(ok_domain, results: dict) -> None:
|
||||||
|
import tkinter as _tk
|
||||||
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
|
if ok_domain is not None:
|
||||||
|
title = "CF-прокси: доступен"
|
||||||
|
ok = [dc for dc, v in results.items() if v is True]
|
||||||
|
msg = f"\u2713 CF-прокси работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
|
||||||
|
else:
|
||||||
|
title = "CF-прокси: недоступен"
|
||||||
|
msg = "\u2717 Ни один из автоматических CF-доменов не отвечает.\n"
|
||||||
|
msg += "Возможно, блокировка или проблемы с сетью."
|
||||||
|
root = _tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_mb.showinfo(title, msg, parent=root)
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
_INNER_W = 396
|
_INNER_W = 396
|
||||||
|
|
||||||
|
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"]
|
||||||
|
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
|
||||||
|
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
|
||||||
|
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
|
|
||||||
|
|
||||||
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
||||||
opts = dict(
|
opts = dict(
|
||||||
@@ -249,7 +291,8 @@ class TrayConfigFormWidgets:
|
|||||||
check_updates_var: Optional[Any]
|
check_updates_var: Optional[Any]
|
||||||
cfproxy_var: Optional[Any] = None
|
cfproxy_var: Optional[Any] = None
|
||||||
cfproxy_priority_var: Optional[Any] = None
|
cfproxy_priority_var: Optional[Any] = None
|
||||||
cfproxy_domain_var: Optional[Any] = None
|
cfproxy_user_domain_var: Optional[Any] = None
|
||||||
|
appearance_var: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
def install_tray_config_form(
|
def install_tray_config_form(
|
||||||
@@ -273,6 +316,33 @@ def install_tray_config_form(
|
|||||||
header, text=f"v{__version__}",
|
header, text=f"v{__version__}",
|
||||||
font=(theme.ui_font_family, 12),
|
font=(theme.ui_font_family, 12),
|
||||||
text_color=theme.text_secondary, anchor="e",
|
text_color=theme.text_secondary, anchor="e",
|
||||||
|
).pack(side="right", padx=(4, 0))
|
||||||
|
appearance_var = ctk.StringVar(
|
||||||
|
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_appearance_change(choice: str) -> None:
|
||||||
|
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto")
|
||||||
|
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||||
|
|
||||||
|
ctk.CTkComboBox(
|
||||||
|
header,
|
||||||
|
values=_APPEARANCE_OPTIONS,
|
||||||
|
variable=appearance_var,
|
||||||
|
width=102,
|
||||||
|
height=28,
|
||||||
|
font=(theme.ui_font_family, 12),
|
||||||
|
text_color=theme.text_secondary,
|
||||||
|
fg_color=theme.field_bg,
|
||||||
|
border_color=theme.field_border,
|
||||||
|
button_color=theme.field_border,
|
||||||
|
button_hover_color=theme.text_secondary,
|
||||||
|
dropdown_fg_color=theme.field_bg,
|
||||||
|
dropdown_text_color=theme.text_primary,
|
||||||
|
dropdown_hover_color=theme.field_border,
|
||||||
|
corner_radius=8,
|
||||||
|
state="readonly",
|
||||||
|
command=_on_appearance_change,
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
|
|
||||||
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
|
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
|
||||||
@@ -331,7 +401,7 @@ def install_tray_config_form(
|
|||||||
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
|
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
|
||||||
|
|
||||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_row.pack(fill="x", pady=(0, 6))
|
cf_row.pack(fill="x", pady=(0, 4))
|
||||||
|
|
||||||
cfproxy_var = ctk.BooleanVar(
|
cfproxy_var = ctk.BooleanVar(
|
||||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||||
@@ -343,60 +413,76 @@ def install_tray_config_form(
|
|||||||
cfproxy_priority_var = ctk.BooleanVar(
|
cfproxy_priority_var = ctk.BooleanVar(
|
||||||
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
|
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
|
||||||
)
|
)
|
||||||
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет CF-прокси", cfproxy_priority_var)
|
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет", cfproxy_priority_var)
|
||||||
cf_prio_cb.pack(side="left")
|
cf_prio_cb.pack(side="left")
|
||||||
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
|
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
|
||||||
|
|
||||||
cf_domain_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
|
||||||
cf_domain_row.pack(fill="x")
|
|
||||||
|
|
||||||
cf_domain_col, cfproxy_domain_var = _labeled_entry(
|
|
||||||
ctk, cf_domain_row, theme, "Домен",
|
|
||||||
cfg.get("cfproxy_domain", default_config.get("cfproxy_domain", "pclead.co.uk")),
|
|
||||||
tip=_TIP_CFPROXY_DOMAIN, width=160, pack_fill=True,
|
|
||||||
)
|
|
||||||
cf_domain_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
|
||||||
|
|
||||||
_cf_test_btn = [None]
|
_cf_test_btn = [None]
|
||||||
|
|
||||||
def _on_cf_test():
|
def _on_cf_test():
|
||||||
domain = cfproxy_domain_var.get().strip()
|
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else ""
|
||||||
if not domain:
|
|
||||||
return
|
|
||||||
btn = _cf_test_btn[0]
|
btn = _cf_test_btn[0]
|
||||||
if btn:
|
if btn:
|
||||||
btn.configure(text="...", state="disabled")
|
btn.configure(text="...", state="disabled")
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
def _worker():
|
if user_domain:
|
||||||
res = _run_cfproxy_connectivity_test(domain)
|
def _worker():
|
||||||
if btn:
|
res = _run_cfproxy_connectivity_test(user_domain)
|
||||||
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
if btn:
|
||||||
btn.after(0, lambda: _cfproxy_show_test_results(domain, res))
|
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
||||||
_threading.Thread(target=_worker, daemon=True).start()
|
btn.after(0, lambda: _cfproxy_show_test_results(user_domain, res))
|
||||||
|
_threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
else:
|
||||||
|
def _worker_auto():
|
||||||
|
ok_domain, res = _run_cfproxy_auto_test(CFPROXY_DEFAULT_DOMAINS)
|
||||||
|
if btn:
|
||||||
|
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
||||||
|
btn.after(0, lambda: _cfproxy_show_auto_test_results(ok_domain, res))
|
||||||
|
_threading.Thread(target=_worker_auto, daemon=True).start()
|
||||||
|
|
||||||
cf_test_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
|
|
||||||
cf_test_col.pack(side="left", anchor="s", padx=(0, 6))
|
|
||||||
ctk.CTkLabel(cf_test_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
|
||||||
_cf_test_widget = ctk.CTkButton(
|
_cf_test_widget = ctk.CTkButton(
|
||||||
cf_test_col, text="Тест", width=56, height=36,
|
cf_row, text="Тест", width=56, height=28,
|
||||||
font=(theme.ui_font_family, 13), corner_radius=10,
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=_on_cf_test,
|
command=_on_cf_test,
|
||||||
)
|
)
|
||||||
_cf_test_widget.pack()
|
_cf_test_widget.pack(side="right")
|
||||||
_cf_test_btn[0] = _cf_test_widget
|
_cf_test_btn[0] = _cf_test_widget
|
||||||
|
|
||||||
cf_help_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
|
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_help_col.pack(side="left", anchor="s")
|
cf_custom_row.pack(fill="x")
|
||||||
ctk.CTkLabel(cf_help_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
|
||||||
|
saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||||
|
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain))
|
||||||
|
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
|
||||||
|
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||||
|
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
|
||||||
|
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
cf_help_col, text="?", width=36, height=36,
|
cf_custom_row, text="?", width=28, height=32,
|
||||||
font=(theme.ui_font_family, 18), corner_radius=10,
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
||||||
).pack()
|
).pack(side="right")
|
||||||
|
|
||||||
|
cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain)
|
||||||
|
cf_domain_entry = _entry(
|
||||||
|
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||||
|
height=32, radius=8,
|
||||||
|
)
|
||||||
|
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
|
attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN)
|
||||||
|
|
||||||
|
def _sync_domain_entry(*_):
|
||||||
|
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
||||||
|
cf_domain_entry.configure(state=state)
|
||||||
|
if not cf_custom_cb_var.get():
|
||||||
|
cfproxy_user_domain_var.set("")
|
||||||
|
|
||||||
|
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||||
|
_sync_domain_entry()
|
||||||
|
|
||||||
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
||||||
|
|
||||||
@@ -488,7 +574,8 @@ def install_tray_config_form(
|
|||||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||||
cfproxy_var=cfproxy_var,
|
cfproxy_var=cfproxy_var,
|
||||||
cfproxy_priority_var=cfproxy_priority_var,
|
cfproxy_priority_var=cfproxy_priority_var,
|
||||||
cfproxy_domain_var=cfproxy_domain_var,
|
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||||
|
appearance_var=appearance_var,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -536,7 +623,7 @@ def validate_config_form(
|
|||||||
if line.strip()
|
if line.strip()
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
tg_ws_proxy.parse_dc_ip_list(lines)
|
parse_dc_ip_list(lines)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return str(e)
|
return str(e)
|
||||||
|
|
||||||
@@ -569,10 +656,10 @@ def validate_config_form(
|
|||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||||
if widgets.cfproxy_priority_var is not None:
|
if widgets.cfproxy_priority_var is not None:
|
||||||
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
|
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
|
||||||
if widgets.cfproxy_domain_var is not None:
|
if widgets.cfproxy_user_domain_var is not None:
|
||||||
domain = widgets.cfproxy_domain_var.get().strip()
|
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip()
|
||||||
if domain:
|
if widgets.appearance_var is not None:
|
||||||
new_cfg["cfproxy_domain"] = domain
|
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
|
||||||
return new_cfg
|
return new_cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -621,7 +708,7 @@ def populate_first_run_window(
|
|||||||
secret: str,
|
secret: str,
|
||||||
on_done: Callable[[bool], None],
|
on_done: Callable[[bool], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
fpx, fpy = FIRST_RUN_FRAME_PAD
|
fpx, fpy = FIRST_RUN_FRAME_PAD
|
||||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
|||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"cfproxy": True,
|
"cfproxy": True,
|
||||||
"cfproxy_priority": True,
|
"cfproxy_priority": True,
|
||||||
"cfproxy_domain": "pclead.co.uk",
|
"cfproxy_user_domain": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from typing import Any, Callable, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
||||||
from proxy import __version__
|
from proxy.tg_ws_proxy import _run
|
||||||
from utils.default_config import default_tray_config
|
from utils.default_config import default_tray_config
|
||||||
|
|
||||||
log = logging.getLogger("tg-ws-tray")
|
log = logging.getLogger("tg-ws-tray")
|
||||||
@@ -239,7 +239,7 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
|||||||
_async_stop = (loop, stop_ev)
|
_async_stop = (loop, stop_ev)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Proxy thread crashed: %s", exc)
|
log.error("Proxy thread crashed: %s", exc)
|
||||||
if "Address already in use" in str(exc) or "10048" in str(exc):
|
if "Address already in use" in str(exc) or "10048" in str(exc):
|
||||||
@@ -257,12 +257,12 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
|||||||
def apply_proxy_config(cfg: dict) -> bool:
|
def apply_proxy_config(cfg: dict) -> bool:
|
||||||
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
||||||
try:
|
try:
|
||||||
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
|
dc_redirects = parse_dc_ip_list(dc_ip_list)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
log.error("Bad config dc_ip: %s", e)
|
log.error("Bad config dc_ip: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pc = tg_ws_proxy.proxy_config
|
pc = proxy_config
|
||||||
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||||
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||||
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
@@ -271,7 +271,7 @@ def apply_proxy_config(cfg: dict) -> bool:
|
|||||||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||||
pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
|
pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
|
||||||
pc.fallback_cfproxy_domain = cfg.get("cfproxy_domain", DEFAULT_CONFIG["cfproxy_domain"])
|
pc.cfproxy_user_domain = cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
|||||||
on_error("Ошибка конфигурации DC → IP.")
|
on_error("Ошибка конфигурации DC → IP.")
|
||||||
return
|
return
|
||||||
|
|
||||||
pc = tg_ws_proxy.proxy_config
|
pc = proxy_config
|
||||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||||
_proxy_thread = threading.Thread(
|
_proxy_thread = threading.Thread(
|
||||||
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
||||||
@@ -315,7 +315,7 @@ def tg_proxy_url(cfg: dict) -> str:
|
|||||||
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||||
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ _ctk_root: Any = None
|
|||||||
_ctk_root_ready = threading.Event()
|
_ctk_root_ready = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
def ensure_ctk_thread(ctk: Any) -> bool:
|
def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
|
||||||
global _ctk_root
|
global _ctk_root
|
||||||
if ctk is None:
|
if ctk is None:
|
||||||
return False
|
return False
|
||||||
@@ -413,7 +413,7 @@ def ensure_ctk_thread(ctk: Any) -> bool:
|
|||||||
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
||||||
|
|
||||||
install_tkinter_variable_del_guard()
|
install_tkinter_variable_del_guard()
|
||||||
apply_ctk_appearance(ctk)
|
apply_ctk_appearance(ctk, mode)
|
||||||
_ctk_root = ctk.CTk()
|
_ctk_root = ctk.CTk()
|
||||||
_ctk_root.withdraw()
|
_ctk_root.withdraw()
|
||||||
_ctk_root_ready.set()
|
_ctk_root_ready.set()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
Image = None
|
Image = None
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import get_link_host
|
||||||
|
|
||||||
from utils.win32_theme import (
|
from utils.win32_theme import (
|
||||||
is_windows_dark_theme,
|
is_windows_dark_theme,
|
||||||
@@ -200,7 +200,7 @@ def _on_exit(icon=None, item=None) -> None:
|
|||||||
# settings dialog
|
# settings dialog
|
||||||
|
|
||||||
def _edit_config_dialog() -> None:
|
def _edit_config_dialog() -> None:
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
_show_error("customtkinter не установлен.")
|
_show_error("customtkinter не установлен.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ def _show_first_run() -> None:
|
|||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
if FIRST_RUN_MARKER.exists():
|
if FIRST_RUN_MARKER.exists():
|
||||||
return
|
return
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
FIRST_RUN_MARKER.touch()
|
FIRST_RUN_MARKER.touch()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ def _build_menu():
|
|||||||
return None
|
return None
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
return pystray.Menu(
|
||||||
pystray.MenuItem(f"Открыть в Telegram ({link_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.MenuItem("Скопировать ссылку", _on_copy_link),
|
||||||
|
|||||||
Reference in New Issue
Block a user