Compare commits

...

16 Commits

Author SHA1 Message Date
delewer 1728fabfc7 docs(README): update preview image source with better visual (#817) 2026-05-06 17:31:49 +03:00
deexsed bf08bad11e Fix --cfproxy-priority CLI parsing (#797) 2026-04-30 13:29:43 +03:00
Kleshzz a787b4fd6b Update README.md (#796) 2026-04-30 06:12:54 +03:00
Sanfix 2c577c1166 Update README.md (change service name in cli version) (#783) 2026-04-28 17:38:37 +03:00
delewer 05b22fe3ba docs(README): fix little typo bug (#778) 2026-04-26 18:54:02 +03:00
Flowseal cc00c6d040 Version bump 2026-04-26 16:58:48 +03:00
Flowseal b3ed5c09db Windows auto update 2026-04-26 16:58:17 +03:00
Flowseal b8556dc702 fix #775 2026-04-26 16:26:50 +03:00
Flowseal 28be00ea9e docs update 2026-04-19 17:32:54 +03:00
Flowseal 5795de00b1 Version bump 2026-04-18 18:59:46 +03:00
Flowseal c5fa5b7f3e fix: cfproxy user domain not set via CLI #741 2026-04-18 18:59:16 +03:00
Flowseal a70e50b9f3 refactor 2026-04-18 16:58:49 +03:00
Flowseal 059ca8760f moved some dubug logs to warning level 2026-04-18 15:49:42 +03:00
Flowseal 0c8d0f160a better exception logging 2026-04-18 15:45:15 +03:00
Flowseal 791708cc3d ws_blacklsit annotation fix 2026-04-18 15:25:11 +03:00
Flowseal 1abcbf86fe gitignore clear 2026-04-18 15:23:56 +03:00
16 changed files with 509 additions and 206 deletions
+1
View File
@@ -5,3 +5,4 @@ zaewayzmplad.com
twdmbzcm.com twdmbzcm.com
awzwsldi.com awzwsldi.com
clngqrflngqin.com clngqrflngqin.com
tjacxbqtj.com
-5
View File
@@ -24,9 +24,4 @@ Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
# Project-specific (not for the repo)
scan_ips.py
scan.txt
AyuGramDesktop-dev/
tweb-master/
/icon.icns /icon.icns
+20 -17
View File
@@ -21,9 +21,12 @@
# TG WS Proxy # TG WS Proxy
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. **Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" /> <picture>
<source srcset="https://github.com/user-attachments/assets/17f1d15e-e1c2-41ea-a452-220d13359262" media="(prefers-color-scheme: dark)">
<img src="https://github.com/user-attachments/assets/8d595468-83a1-4e4f-bac4-9ce4a07027bd">
</picture>
## Как это работает ## Как это работает
@@ -34,13 +37,13 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443` 1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
2. Перехватывает подключения к IP-адресам Telegram 2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета 3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram 4. Устанавливает WebSocket-соединение (TLS) к соответствующему DC через домены Telegram
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение 5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
> [!IMPORTANT] > [!IMPORTANT]
> ### Не грузит фото/видео? > ### Не грузит фото/видео?
> **Удалите в настройках прокси в DC->IP всё, кроме `4:149.154.167.220`** > **Удалите в настройках прокси в DC->IP всё, кроме `4:149.154.167.220`**
> **Если не помогло, то удалите вообще всё из этого поля** > **Если не помогло, то полностью очистите это поле**
> #### > ####
> Подобная проблема встречается на аккаунтах без Premium > Подобная проблема встречается на аккаунтах без Premium
> Если вам не помогло, то настраивайте свой домен по гайду отсюда: https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md > Если вам не помогло, то настраивайте свой домен по гайду отсюда: https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md
@@ -49,20 +52,20 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
### Windows ### Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
**Меню трея:** **Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку - **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
- **Скопировать ссылку** — скопировать ссылку для подключения - **Скопировать ссылку** — скопировать ссылку для подключения
- **Перезапустить прокси** — перезапуск без выхода из приложения - **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub) - **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение - **Выход** — остановить прокси и закрыть приложение
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках). При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (эту проверку можно отключить в настройках).
### Настройка Telegram Desktop ### Настройка Telegram Desktop
@@ -71,7 +74,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
ПКМ по иконке в трее → **«Открыть в Telegram»** ПКМ по иконке в трее → **«Открыть в Telegram»**
Если не сработало (не открылся Telegram с подключением), то: Если не сработало (не открылся Telegram с подключением), то:
1. ПКМ по иконке в трее → **«Скопировать ссылку»** 1. ПКМ по иконке в трее → **«Скопировать ссылку»**
2. Отправьте ссылку себе в избранное в Telegram клиенте и нажмите по ней ЛКМ 2. Отправьте ссылку себе в избранное в Telegram-клиенте и нажмите по ней ЛКМ
3. Подключитесь 3. Подключитесь
### Вручную: ### Вручную:
@@ -96,7 +99,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**. Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**.
Для Arch и Arch-Based дистрибутивов подготовлены пакеты в AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli) Для Arch и Arch-based дистрибутивов подготовлены пакеты в AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell ```shell
# Установка без AUR-helper # Установка без AUR-helper
@@ -107,9 +110,9 @@ makepkg -si
# При помощи AUR-helper # При помощи AUR-helper
paru -S tg-ws-proxy-bin paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта, # Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 - это номер порта,
# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16 # разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
``` ```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64). Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
@@ -165,16 +168,16 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|---|---|---| |---|---|---|
| `--port` | `1443` | Порт прокси | | `--port` | `1443` | Порт прокси |
| `--host` | `127.0.0.1` | Хост прокси | | `--host` | `127.0.0.1` | Хост прокси |
| `--secret` | `random` | 32 hex chars secret для авторизации клиентов | | `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare]((https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md)) | | `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md) |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudfalre. [Подробнее тут](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md) | | `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare. [Подробнее тут](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md) |
| `--cfproxy-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением | | `--cfproxy-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
| `--fake-tls-domain` | | Включить Fake TLS (ee-secret) маскировку с указанным SNI-доменом | | `--fake-tls-domain` | | Включить Fake TLS (ee-secret) маскировку с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) | | `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--buf-kb` | `256` | Размер буфера в КБ | | `--buf-kb` | `256` | Размер буфера в КБ |
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC | | `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
| `--log-file` | выкл. | Путь до файла, в который сохранять логи | | `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) | | `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи | | `--log-backups` | `0` | Количество сохранений логов после перезаписи |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | | `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
@@ -241,7 +244,7 @@ python3 proxy/tg_ws_proxy.py \
--secret <32-hex-chars> --secret <32-hex-chars>
``` ```
Ссылка для подключения будет в формате `ee`-секрета:</p> Ссылка для подключения будет в формате `ee`-секрета:
``` ```
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex> tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
+1 -1
View File
@@ -309,7 +309,7 @@ def _maybe_notify_update_async() -> None:
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.debug("Update check failed: %s", exc) log.warning("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx # http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 6, 2, 0), filevers=(1, 6, 5, 0),
prodvers=(1, 6, 2, 0), prodvers=(1, 6, 5, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[ [
StringStruct(u'CompanyName', u'Flowseal'), StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'), StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.6.2.0'), StringStruct(u'FileVersion', u'1.6.5.0'),
StringStruct(u'InternalName', u'TgWsProxy'), StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'), StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'), StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'), StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.6.2.0'), StringStruct(u'ProductVersion', u'1.6.5.0'),
] ]
) )
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config from .config import parse_dc_ip_list, proxy_config
from .utils import get_link_host from .utils import get_link_host
__version__ = "1.6.3" __version__ = "1.6.5"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"]
+2 -1
View File
@@ -29,7 +29,8 @@ class _Balancer:
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]: def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
current_domain = self._dc_to_domain.get(dc_id) current_domain = self._dc_to_domain.get(dc_id)
yield current_domain if current_domain is not None:
yield current_domain
shuffled_domains = self.domains[:] shuffled_domains = self.domains[:]
random.shuffle(shuffled_domains) random.shuffle(shuffled_domains)
+16 -18
View File
@@ -127,7 +127,7 @@ class MsgSplitter:
async def do_fallback(reader, writer, relay_init, label, async def do_fallback(reader, writer, relay_init, label,
dc, is_media, media_tag, dc: int, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None): ctx: CryptoCtx, splitter=None):
fallback_dst = DC_DEFAULT_IPS.get(dc) fallback_dst = DC_DEFAULT_IPS.get(dc)
use_cf = proxy_config.fallback_cfproxy use_cf = proxy_config.fallback_cfproxy
@@ -141,9 +141,9 @@ async def do_fallback(reader, writer, relay_init, label,
for method in methods: for method in methods:
if method == 'cf': if method == 'cf':
ok = await _cfproxy_fallback( ok = await _cfproxy_fallback(
reader, writer, relay_init, label, reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
if ok: if ok:
return True return True
elif method == 'tcp' and fallback_dst: elif method == 'tcp' and fallback_dst:
@@ -151,15 +151,16 @@ async def do_fallback(reader, writer, relay_init, label,
label, dc, media_tag, fallback_dst) label, dc, media_tag, fallback_dst)
ok = await _tcp_fallback( ok = await _tcp_fallback(
reader, writer, fallback_dst, 443, reader, writer, fallback_dst, 443,
relay_init, label, dc=dc, is_media=is_media, ctx=ctx) relay_init, label, ctx)
if ok: if ok:
return True return True
return False return False
async def _cfproxy_fallback(reader, writer, relay_init, label, async def _cfproxy_fallback(reader, writer, relay_init, label,
dc=None, is_media=False, ctx: CryptoCtx,
ctx: CryptoCtx = None, splitter=None): dc: int, is_media: bool,
splitter=None):
media_tag = ' media' if is_media else '' media_tag = ' media' if is_media else ''
ws = None ws = None
chosen_domain = None chosen_domain = None
@@ -175,7 +176,7 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
break break
except Exception as exc: except Exception as exc:
log.warning("[%s] DC%d%s CF proxy failed: %s", log.warning("[%s] DC%d%s CF proxy failed: %s",
label, dc, media_tag, exc) label, dc, media_tag, repr(exc))
if ws is None: if ws is None:
return False return False
@@ -185,34 +186,32 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
stats.connections_cfproxy += 1 stats.connections_cfproxy += 1
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
return True return True
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
dc=None, is_media=False, ctx: CryptoCtx = None):
try: try:
rr, rw = await asyncio.wait_for( rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10) asyncio.open_connection(dst, port), timeout=10)
except Exception as exc: except Exception as exc:
log.warning("[%s] TCP fallback to %s:%d failed: %s", log.warning("[%s] TCP fallback to %s:%d failed: %s",
label, dst, port, exc) label, dst, port, repr(exc))
return False return False
stats.connections_tcp_fallback += 1 stats.connections_tcp_fallback += 1
rw.write(relay_init) rw.write(relay_init)
await rw.drain() await rw.drain()
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
dc=dc, is_media=is_media, ctx=ctx)
return True return True
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
ctx: CryptoCtx,
dc=None, is_media=False, dc=None, is_media=False,
ctx: CryptoCtx = None, splitter: Optional[MsgSplitter] = None):
splitter: MsgSplitter = None):
""" """
Bidirectional TCP(client) <-> WS(telegram) with re-encryption. Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
client ciphertext decrypt(clt_key) encrypt(tg_key) WS client ciphertext decrypt(clt_key) encrypt(tg_key) WS
@@ -309,8 +308,7 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer, async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
label, dc=None, is_media=False, label, ctx: CryptoCtx):
ctx: CryptoCtx = None):
"""Bidirectional TCP <-> TCP with re-encryption.""" """Bidirectional TCP <-> TCP with re-encryption."""
async def forward(src, dst_w, is_up): async def forward(src, dst_w, is_up):
+1 -1
View File
@@ -66,7 +66,7 @@ def _fetch_cfproxy_domain_list() -> List[str]:
] ]
return [_dd(d) for d in encoded] return [_dd(d) for d in encoded]
except Exception as exc: except Exception as exc:
log.warning("Failed to fetch CF proxy domain list: %s", exc) log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
return [] return []
+2 -2
View File
@@ -213,8 +213,8 @@ async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
up_reader, up_writer = await asyncio.wait_for( up_reader, up_writer = await asyncio.wait_for(
asyncio.open_connection(domain, 443), timeout=10) asyncio.open_connection(domain, 443), timeout=10)
except Exception as exc: except Exception as exc:
log.debug("[%s] masking: cannot connect to %s:443: %s", log.warning("[%s] masking: cannot connect to %s:443: %s",
label, domain, exc) label, domain, repr(exc))
return return
log.debug("[%s] masking -> %s:443", label, domain) log.debug("[%s] masking -> %s:443", label, domain)
+1 -4
View File
@@ -25,7 +25,7 @@ _ssl_ctx.verify_mode = ssl.CERT_NONE
class WsHandshakeError(Exception): class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str, def __init__(self, status_code: int, status_line: str,
headers: dict = None, location: str = None): headers: Optional[dict] = None, location: Optional[str] = None):
self.status_code = status_code self.status_code = status_code
self.status_line = status_line self.status_line = status_line
self.headers = headers or {} self.headers = headers or {}
@@ -96,9 +96,6 @@ class RawWebSocket:
f'Sec-WebSocket-Key: {ws_key}\r\n' f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n' f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\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' f'\r\n'
) )
writer.write(req.encode()) writer.write(req.encode())
+166 -146
View File
@@ -35,8 +35,8 @@ log = logging.getLogger('tg-mtproto-proxy')
DC_FAIL_COOLDOWN = 30.0 DC_FAIL_COOLDOWN = 30.0
WS_FAIL_TIMEOUT = 2.0 WS_FAIL_TIMEOUT = 2.0
ws_blacklist: Set[Tuple[int, bool]] = set() ws_blacklist: Set[str] = set()
dc_fail_until: Dict[Tuple[int, bool], float] = {} dc_fail_until: Dict[str, float] = {}
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]: def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
@@ -191,7 +191,7 @@ class _WsPool:
except Exception: except Exception:
pass pass
async def warmup(self, dc_redirects: Dict[int, Optional[str]]): async def warmup(self, dc_redirects: Dict[int, str]):
for dc, target_ip in dc_redirects.items(): for dc, target_ip in dc_redirects.items():
if target_ip is None: if target_ip is None:
continue continue
@@ -207,6 +207,146 @@ class _WsPool:
_ws_pool = _WsPool() _ws_pool = _WsPool()
async def _read_client_init(reader, writer, secret, label, masking):
if proxy_config.proxy_protocol:
try:
pp_line = await asyncio.wait_for(
reader.readline(), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] disconnected during PROXY header", label)
return None
pp_text = pp_line.decode('ascii', errors='replace').strip()
if pp_text.startswith('PROXY '):
parts = pp_text.split()
if len(parts) >= 6:
label = f"{parts[2]}:{parts[4]}"
log.debug("[%s] PROXY protocol: %s", label, pp_text)
else:
log.debug("[%s] expected PROXY header, got: %r", label,
pp_text[:60])
try:
first_byte = await asyncio.wait_for(
reader.readexactly(1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return None
if first_byte[0] == TLS_RECORD_HANDSHAKE and masking:
try:
hdr_rest = await asyncio.wait_for(
reader.readexactly(4), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record header", label)
return None
tls_header = first_byte + hdr_rest
record_len = struct.unpack('>H', tls_header[3:5])[0]
try:
record_body = await asyncio.wait_for(
reader.readexactly(record_len), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record body", label)
return None
client_hello = tls_header + record_body
tls_result = verify_client_hello(client_hello, secret)
if tls_result is None:
log.debug("[%s] Fake TLS verify failed (size=%d rec=%d) "
"-> masking",
label, len(client_hello), record_len)
await proxy_to_masking_domain(
reader, writer, client_hello, masking, label)
return None
client_random, session_id, ts = tls_result
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
server_hello = build_server_hello(secret, client_random, session_id)
writer.write(server_hello)
await writer.drain()
tls_stream = FakeTlsStream(reader, writer)
try:
handshake = await asyncio.wait_for(
tls_stream.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete obfs2 init inside TLS", label)
return None
return handshake, tls_stream, tls_stream, label
elif masking:
log.debug("[%s] non-TLS byte 0x%02X -> HTTP redirect", label,
first_byte[0])
redirect = (
f"HTTP/1.1 301 Moved Permanently\r\n"
f"Location: https://{masking}/\r\n"
f"Content-Length: 0\r\n"
f"Connection: close\r\n\r\n"
).encode()
writer.write(redirect)
await writer.drain()
return None
else:
try:
rest = await asyncio.wait_for(
reader.readexactly(HANDSHAKE_LEN - 1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return None
return first_byte + rest, reader, writer, label
def _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init):
# 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()
tg_encryptor.update(ZERO_64)
return CryptoCtx(clt_decryptor, clt_encryptor, tg_encryptor, tg_decryptor)
async def _handle_client(reader, writer, secret: bytes): async def _handle_client(reader, writer, secret: bytes):
stats.connections_total += 1 stats.connections_total += 1
stats.connections_active += 1 stats.connections_active += 1
@@ -215,115 +355,25 @@ async def _handle_client(reader, writer, secret: bytes):
set_sock_opts(writer.transport, proxy_config.buffer_size) set_sock_opts(writer.transport, proxy_config.buffer_size)
tls_stream = None
masking = proxy_config.fake_tls_domain
try: try:
if proxy_config.proxy_protocol: init = await _read_client_init(
try: reader, writer, secret, label, proxy_config.fake_tls_domain)
pp_line = await asyncio.wait_for( if init is None:
reader.readline(), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] disconnected during PROXY header", label)
return
pp_text = pp_line.decode('ascii', errors='replace').strip()
if pp_text.startswith('PROXY '):
parts = pp_text.split()
if len(parts) >= 6:
label = f"{parts[2]}:{parts[4]}"
log.debug("[%s] PROXY protocol: %s", label, pp_text)
else:
log.debug("[%s] expected PROXY header, got: %r", label,
pp_text[:60])
try:
first_byte = await asyncio.wait_for(
reader.readexactly(1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return return
if first_byte[0] == TLS_RECORD_HANDSHAKE and masking: handshake, clt_reader, clt_writer, label = init
try:
hdr_rest = await asyncio.wait_for(
reader.readexactly(4), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record header", label)
return
tls_header = first_byte + hdr_rest
record_len = struct.unpack('>H', tls_header[3:5])[0]
try:
record_body = await asyncio.wait_for(
reader.readexactly(record_len), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record body", label)
return
client_hello = tls_header + record_body
tls_result = verify_client_hello(client_hello, secret)
if tls_result is None:
log.debug("[%s] Fake TLS verify failed (size=%d rec=%d) "
"-> masking",
label, len(client_hello), record_len)
await proxy_to_masking_domain(
reader, writer, client_hello, masking, label)
return
client_random, session_id, ts = tls_result
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
server_hello = build_server_hello(secret, client_random, session_id)
writer.write(server_hello)
await writer.drain()
tls_stream = FakeTlsStream(reader, writer)
try:
handshake = await asyncio.wait_for(
tls_stream.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete obfs2 init inside TLS", label)
return
elif masking:
log.debug("[%s] non-TLS byte 0x%02X -> HTTP redirect", label,
first_byte[0])
redirect = (
f"HTTP/1.1 301 Moved Permanently\r\n"
f"Location: https://{masking}/\r\n"
f"Content-Length: 0\r\n"
f"Connection: close\r\n\r\n"
).encode()
writer.write(redirect)
await writer.drain()
return
else:
try:
rest = await asyncio.wait_for(
reader.readexactly(HANDSHAKE_LEN - 1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return
handshake = first_byte + rest
result = _try_handshake(handshake, secret) result = _try_handshake(handshake, secret)
if result is None: if result is None:
stats.connections_bad += 1 stats.connections_bad += 1
log.debug("[%s] bad handshake (wrong secret or proto)", label) log.warning("[%s] bad handshake (wrong secret or proto)", label)
try: try:
drain_src = tls_stream or reader while await clt_reader.read(4096):
while await drain_src.read(4096):
pass pass
except Exception: except Exception:
pass pass
return return
clt_reader = tls_stream or reader
clt_writer = tls_stream or writer
dc, is_media, proto_tag, client_dec_prekey_iv = result dc, is_media, proto_tag, client_dec_prekey_iv = result
if proto_tag == PROTO_TAG_ABRIDGED: if proto_tag == PROTO_TAG_ABRIDGED:
@@ -339,48 +389,7 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, ' media' if is_media else '', proto_int) label, dc, ' media' if is_media else '', proto_int)
relay_init = _generate_relay_init(proto_tag, dc_idx) relay_init = _generate_relay_init(proto_tag, dc_idx)
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
# 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()
tg_encryptor.update(ZERO_64)
ctx = CryptoCtx(clt_decryptor, clt_encryptor, tg_encryptor, tg_decryptor)
dc_key = f'{dc}{"m" if is_media else ""}' dc_key = f'{dc}{"m" if is_media else ""}'
media_tag = " media" if is_media else "" media_tag = " media" if is_media else ""
@@ -448,7 +457,7 @@ async def _handle_client(reader, writer, secret: bytes):
stats.ws_errors += 1 stats.ws_errors += 1
all_redirects = False all_redirects = False
log.warning("[%s] DC%d%s WS connect failed: %s", log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, exc) label, dc, media_tag, repr(exc))
# WS failed -> fallback # WS failed -> fallback
if ws is None: if ws is None:
@@ -490,9 +499,9 @@ async def _handle_client(reader, writer, secret: bytes):
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(clt_reader, clt_writer, ws, label, await bridge_ws_reencrypt(clt_reader, clt_writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warning("[%s] timeout during handshake", label) log.warning("[%s] timeout during handshake", label)
@@ -506,7 +515,7 @@ async def _handle_client(reader, writer, secret: bytes):
if getattr(exc, 'winerror', None) == 1236: if getattr(exc, 'winerror', None) == 1236:
log.debug("[%s] connection aborted by local system", label) log.debug("[%s] connection aborted by local system", label)
else: else:
log.error("[%s] unexpected OS error: %s", label, exc) log.error("[%s] unexpected OS error: %s", label, repr(exc))
except Exception as exc: except Exception as exc:
log.error("[%s] unexpected: %s", label, exc, exc_info=True) log.error("[%s] unexpected: %s", label, exc, exc_info=True)
finally: finally:
@@ -642,6 +651,16 @@ def run_proxy(stop_event: Optional[asyncio.Event] = None):
def main(): def main():
def _parse_bool(value: str) -> bool:
lowered = value.strip().lower()
if lowered == 'true':
return True
if lowered == 'false':
return False
raise argparse.ArgumentTypeError(
"Expected boolean value: true or false",
)
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
description='Telegram MTProto WebSocket Bridge Proxy') description='Telegram MTProto WebSocket Bridge Proxy')
ap.add_argument('--port', type=int, default=1443, ap.add_argument('--port', type=int, default=1443,
@@ -670,7 +689,7 @@ def main():
help='User defined Cloudflare-proxied domain for WS fallback') help='User defined Cloudflare-proxied domain for WS fallback')
ap.add_argument('--no-cfproxy', action='store_true', ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback') help='Disable Cloudflare proxy fallback')
ap.add_argument('--cfproxy-priority', type=bool, default=True, ap.add_argument('--cfproxy-priority', type=_parse_bool, default=True,
help='Try cfproxy before tcp fallback (default: true)') help='Try cfproxy before tcp fallback (default: true)')
ap.add_argument('--fake-tls-domain', type=str, default='', ap.add_argument('--fake-tls-domain', type=str, default='',
metavar='DOMAIN', metavar='DOMAIN',
@@ -712,6 +731,7 @@ def main():
proxy_config.pool_size = max(0, args.pool_size) proxy_config.pool_size = max(0, args.pool_size)
proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.fallback_cfproxy_priority = args.cfproxy_priority proxy_config.fallback_cfproxy_priority = args.cfproxy_priority
proxy_config.cfproxy_user_domain = args.cfproxy_domain.strip()
proxy_config.fake_tls_domain = args.fake_tls_domain.strip() proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol proxy_config.proxy_protocol = args.proxy_protocol
+1 -1
View File
@@ -31,7 +31,7 @@ def human_bytes(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'): for unit in ('B', 'KB', 'MB', 'GB'):
if abs(n) < 1024: if abs(n) < 1024:
return f"{n:.1f}{unit}" return f"{n:.1f}{unit}"
n /= 1024 n /= 1024 # type: ignore
return f"{n:.1f}TB" return f"{n:.1f}TB"
+3 -3
View File
@@ -132,7 +132,7 @@ def load_config() -> dict:
data.setdefault(k, v) data.setdefault(k, v)
return data return data
except Exception as exc: except Exception as exc:
log.warning("Failed to load config: %s", exc) log.warning("Failed to load config: %s", repr(exc))
return dict(DEFAULT_CONFIG) return dict(DEFAULT_CONFIG)
@@ -242,7 +242,7 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
try: try:
loop.run_until_complete(_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", repr(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):
on_port_busy( on_port_busy(
"Не удалось запустить прокси:\n" "Не удалось запустить прокси:\n"
@@ -391,7 +391,7 @@ def maybe_notify_update(
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.debug("Update check failed: %s", exc) log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()
+52
View File
@@ -30,6 +30,7 @@ _state: Dict[str, Any] = {
"latest": None, "latest": None,
"html_url": None, "html_url": None,
"error": None, "error": None,
"assets": [],
} }
@@ -162,6 +163,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip() tag = (cache.get("tag_name") or "").strip()
if tag: if tag:
_apply_release_tag(tag, cache.get("html_url") or "", current_version) _apply_release_tag(tag, cache.get("html_url") or "", current_version)
_state["assets"] = cache.get("assets") or []
return return
err = cache.get("last_error") err = cache.get("last_error")
_state["error"] = ( _state["error"] = (
@@ -181,6 +183,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip() tag = (cache.get("tag_name") or "").strip()
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
_apply_release_tag(tag, url, current_version) _apply_release_tag(tag, url, current_version)
_state["assets"] = cache.get("assets") or []
if new_etag: if new_etag:
cache["etag"] = new_etag cache["etag"] = new_etag
_save_cache(cache_path, cache) _save_cache(cache_path, cache)
@@ -200,6 +203,13 @@ def run_check(current_version: str) -> None:
cache["etag"] = new_etag cache["etag"] = new_etag
cache["tag_name"] = tag cache["tag_name"] = tag
cache["html_url"] = html_url cache["html_url"] = html_url
assets = [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
]
_state["assets"] = assets
cache["assets"] = assets
cache.pop("last_error", None) cache.pop("last_error", None)
_save_cache(cache_path, cache) _save_cache(cache_path, cache)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e: except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
@@ -221,3 +231,45 @@ def run_check(current_version: str) -> None:
def get_status() -> Dict[str, Any]: def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках).""" """Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state) return dict(_state)
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
assets = _state.get("assets") or []
if not assets:
return None
# Try SHA256 match against release asset digests
try:
import hashlib
h = hashlib.sha256()
with open(exe_path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
exe_sha = h.hexdigest().lower()
for a in assets:
d = (a.get("digest") or "").lower()
if d.startswith("sha256:") and d[7:] == exe_sha:
return a["url"], a["name"]
except Exception:
pass
# Fallback
import struct
is_64 = struct.calcsize("P") * 8 == 64
try:
is_modern = sys.getwindowsversion().major >= 10
except Exception:
is_modern = True
if is_modern:
name = "TgWsProxy_windows.exe"
elif is_64:
name = "TgWsProxy_windows_7_64bit.exe"
else:
name = "TgWsProxy_windows_7_32bit.exe"
for a in assets:
if a.get("name") == name:
return a["url"], a["name"]
return None
+238 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import ctypes import ctypes
import os import os
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -40,7 +41,7 @@ from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, save_config, start_proxy, stop_proxy, tg_proxy_url,
) )
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
@@ -101,7 +102,9 @@ _u32.MessageBoxW.restype = ctypes.c_int
_MB_OK_ERR = 0x10 _MB_OK_ERR = 0x10
_MB_OK_INFO = 0x40 _MB_OK_INFO = 0x40
_MB_YESNO_Q = 0x24 _MB_YESNO_Q = 0x24
_MB_YESNOCANCEL_Q = 0x23
_IDYES = 6 _IDYES = 6
_IDNO = 7
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
@@ -116,6 +119,227 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
def update_ctk_form(
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None,
release_url: Optional[str] = None,
) -> str:
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES:
return "update"
if result == _IDNO:
return "open"
return "close"
result = {"value": "close"}
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
root = create_ctk_toplevel(
ctk,
title=title,
width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100,
theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
ctk.CTkLabel(
frame,
text=text,
justify="left",
anchor="w",
wraplength=270,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
).pack(fill="x", pady=(0, 10))
row = ctk.CTkFrame(frame, fg_color="transparent")
row.pack(fill="x")
status_label = ctk.CTkLabel(
frame, text="", justify="left", anchor="w", wraplength=270,
font=(theme.ui_font_family, 11), text_color=theme.text_secondary,
)
status_label.pack(fill="x", pady=(6, 0))
btns: list = []
def _set_status(msg: str) -> None:
root.after(0, lambda: status_label.configure(text=msg))
def _close_with(value: str) -> None:
result["value"] = value
root.destroy()
done.set()
def _on_update() -> None:
if not download_url:
if release_url:
webbrowser.open(release_url)
_close_with("open")
return
for b in btns:
b.configure(state="disabled")
root.protocol("WM_DELETE_WINDOW", lambda: None)
def _run():
_perform_update(download_url, set_status=_set_status)
root.after(0, lambda: [b.configure(state="normal") for b in btns])
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
threading.Thread(target=_run, daemon=True).start()
if IS_FROZEN:
btn_upd = ctk.CTkButton(
row, text="Обновить", width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update,
)
btn_upd.pack(side="left", padx=(0, 6))
btns.append(btn_upd)
btn_pg = ctk.CTkButton(
row, text="Страница", width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
)
btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg)
btn_cl = ctk.CTkButton(
row, text="Закрыть", width=88, height=34,
font=(theme.ui_font_family, 13),
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: _close_with("close"),
)
btn_cl.pack(side="left")
btns.append(btn_cl)
root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))
ctk_run_dialog(_build)
return result["value"]
def _perform_update(download_url: str, set_status=None) -> None:
import tempfile
import urllib.request
def _step(msg: str) -> None:
log.info("Update: %s", msg)
if set_status:
set_status(msg)
time.sleep(0.8)
def _err(msg: str) -> None:
log.error("Update error: %s", msg)
if set_status:
set_status(f"Ошибка: {msg}")
else:
_show_error(msg)
_step("Скачивание...")
cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
tmp_path = None
try:
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
os.close(fd)
tmp_path = Path(tmp_name)
log.info("Downloading update from %s", download_url)
urllib.request.urlretrieve(download_url, str(tmp_path))
except Exception as exc:
_err(f"Не удалось скачать:\n{exc}")
if tmp_path:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step("Замена файла...")
try:
if old_exe.exists():
old_exe.unlink()
cur_exe.rename(old_exe)
except Exception as exc:
_err(f"Не удалось переименовать файл:\n{exc}")
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
try:
tmp_path.rename(cur_exe)
except Exception as exc:
_err(f"Не удалось переместить файл:\n{exc}")
try:
old_exe.rename(cur_exe)
except OSError:
pass
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step("Перезапуск...")
_release_win_mutex()
stop_proxy()
# Don't reuse existing _MEI* dir
env = os.environ.copy()
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
del env[_k]
if hasattr(sys, "_MEIPASS"):
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
env["PATH"] = os.pathsep.join(
p for p in env.get("PATH", "").split(os.pathsep)
if os.path.normcase(p.rstrip("\\/")) != _mei
)
try:
subprocess.Popen(
[str(cur_exe)],
env=env,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
except Exception as exc:
log.error("Failed to launch updated exe: %s", exc)
time.sleep(0.5)
os._exit(0)
def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True):
return
def _work():
time.sleep(1.5)
if is_exiting():
return
try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update") or is_exiting():
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
choice = update_ctk_form(
f"Доступна новая версия: {ver}",
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start()
# autostart (registry) # autostart (registry)
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
@@ -370,7 +594,7 @@ def run_tray() -> None:
return return
start_proxy(_config, _show_error) start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _maybe_do_update(_config, lambda: _exiting)
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
@@ -387,6 +611,18 @@ def main() -> None:
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
if IS_FROZEN:
def _cleanup_old_exes():
exe_dir = Path(sys.executable).parent
time.sleep(3)
for _f in exe_dir.glob("*_oldtgws.exe"):
try:
_f.unlink()
log.info("Deleted leftover: %s", _f)
except OSError:
pass
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
try: try:
run_tray() run_tray()
finally: finally: