39 Commits

Author SHA1 Message Date
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
Flowseal
d84b9eadc4 version fix 2026-04-16 18:20:47 +03:00
Flowseal
c1b4cb0204 docs update 2026-04-16 18:01:48 +03:00
Flowseal
5d08e16e5d removed repeated annotation 2026-04-16 17:56:48 +03:00
Flowseal
a844a88f38 docs update 2026-04-16 17:52:58 +03:00
Flowseal
e5f1d02737 docs links update 2026-04-16 17:51:41 +03:00
Flowseal
3a6e82c2a8 docs update 2026-04-16 17:50:32 +03:00
Flowseal
e56ada1a34 CF domains balancer 2026-04-16 17:08:03 +03:00
Flowseal
b44d79a933 docs update 2026-04-16 17:08:03 +03:00
Aksarin Mikhail
77723d875f Update README.md (#711)
Fix relative links
2026-04-16 00:29:58 +03:00
Flowseal
548ec05fc5 docs update 2026-04-14 21:56:14 +03:00
Flowseal
03c7719c39 mutex check simplify 2026-04-14 16:58:54 +03:00
Flowseal
db4cebe0b2 build test 2026-04-14 16:51:26 +03:00
Flowseal
ca81d037f7 docs update 2026-04-14 03:11:13 +03:00
Flowseal
07615af49c bootloader build fix 2026-04-14 02:44:15 +03:00
Flowseal
f8ee37370d Version bump 2026-04-14 00:27:27 +03:00
Flowseal
4cbb9e555c windows mutex-lock 2026-04-14 00:27:27 +03:00
Flowseal
25ae4b0a24 build version changes 2026-04-14 00:27:27 +03:00
Kleshzz
8af1bc8c89 Add .gitattributes & Update .gitignore (#690) 2026-04-13 19:30:57 +03:00
Flowseal
b48ac67b9f donate web link 2026-04-11 21:27:21 +03:00
Flowseal
937acdb461 Version bump 2026-04-11 21:09:46 +03:00
Flowseal
6f3da84e48 Refresh domains schedule 2026-04-11 21:09:08 +03:00
Flowseal
3c3e9eb34b fix domains testing 2026-04-11 21:03:53 +03:00
Flowseal
ba89cad8b8 fake-tls cli 2026-04-11 20:52:24 +03:00
Flowseal
bf905ec54f docs update 2026-04-11 19:11:47 +03:00
Flowseal
ace0a5e968 docs update 2026-04-11 18:54:32 +03:00
Flowseal
e47eef4709 docs update 2026-04-11 15:28:37 +03:00
Flowseal
abe1d1f01e docs update 2026-04-11 15:28:37 +03:00
Flowseal
cc31c02c9d donate button ctk 2026-04-11 15:28:37 +03:00
Flowseal
f39bb15ff6 docs update 2026-04-11 15:28:31 +03:00
kreker06
5a62cd82b2 Update Dockerfile (#586) 2026-04-11 14:54:32 +03:00
Flowseal
fe4e0e8234 docs update 2026-04-10 19:28:36 +03:00
Flowseal
172dc67093 docs update 2026-04-10 02:57:25 +03:00
24 changed files with 747 additions and 169 deletions

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
* text=auto eol=lf
*.py text diff=python
*.spec text linguist-language=Python
*.toml text
*.txt text
*.ico binary

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ['https://nowpayments.io/donation/flowseal']

View File

@@ -1,2 +1,7 @@
virkgj.com virkgj.com
vmmzovy.com vmmzovy.com
mkuosckvso.com
zaewayzmplad.com
twdmbzcm.com
awzwsldi.com
clngqrflngqin.com

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.11"
cache: "pip" cache: "pip"
- name: Setup MSVC 14.40 toolset - name: Setup MSVC 14.40 toolset
@@ -38,10 +38,11 @@ jobs:
run: pip install . run: pip install .
- name: Build PyInstaller bootloader from source - name: Build PyInstaller bootloader from source
run: |
pip install "pyinstaller==6.16.0" --no-binary pyinstaller
env: env:
PYINSTALLER_COMPILE_BOOTLOADER: 1 PYINSTALLER_COMPILE_BOOTLOADER: "1"
run: |
pip download --no-binary pyinstaller --no-deps --no-cache-dir -d pyinstaller_src "pyinstaller==6.10.0"
pip install (Get-ChildItem pyinstaller_src\*.tar.gz).FullName
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
@@ -193,7 +194,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.16.0 python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO - name: Create macOS icon from ICO
run: | run: |
@@ -295,7 +296,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.16.0" .venv/bin/pip install "pyinstaller==6.13.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
@@ -383,7 +384,8 @@ jobs:
tag_name: ${{ github.event.inputs.version }} tag_name: ${{ github.event.inputs.version }}
name: "TG WS Proxy ${{ github.event.inputs.version }}" name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: | body: |
## TG WS Proxy ${{ github.event.inputs.version }} ##
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
files: | files: |
dist/TgWsProxy_windows.exe dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_7_64bit.exe dist/TgWsProxy_windows_7_64bit.exe

7
.gitignore vendored
View File

@@ -6,6 +6,8 @@ __pycache__/
dist/ dist/
build/ build/
*.spec.bak *.spec.bak
venv/
.venv/
# PyInstaller # PyInstaller
*.manifest *.manifest
@@ -22,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

View File

@@ -41,5 +41,5 @@ USER app
EXPOSE 1443/tcp EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD [] CMD []

View File

@@ -20,7 +20,7 @@ Cloudflare имеет лимиты на одновременное количе
- Name=`kws5` IPv4=`149.154.171.5` - Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100` - 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 забанена (по крайней мере, если вы из России)**
5. В настройках TgWsProxy поменяйте домен на свой 5. В настройках TgWsProxy поменяйте домен на свой

14
docs/Funding.md Normal file
View File

@@ -0,0 +1,14 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
##
### Проект полностью бесплатен для использования всеми.
### Однако его развитие и стабильная работа при росте числа пользователей требуют от меня определённых вложений.
### Буду благодарен за любую форму поддержки! Спасибо ❤️

View File

@@ -1,22 +1,23 @@
> [!TIP] > [!TIP]
> >
> ### 🎉 Поддержать меня > ### [🎉 Поддержать меня](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
> >
> USDT (TRC20): `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> BTC: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> ETH: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
> ### Реакция антивирусов > ### Реакция антивирусов
> >
> Windows Defender часто ошибочно помечает приложение как **Wacatac**. > Антивирусы часто ошибочно помечают приложение как вирус из-за упаковщика.
> Если вы не можете скачать из-за блокировки, то: > Если вы не можете скачать из-за блокировки антивирусом, то:
> >
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала) > 1) **Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)**
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно > 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
> >
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal** > Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
# TG WS Proxy # TG WS Proxy
@@ -38,7 +39,9 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
> [!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
@@ -53,6 +56,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
**Меню трея:** **Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку - **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку
- **Скопировать ссылку** — скопировать ссылку для подключения
- **Перезапустить прокси** — перезапуск без выхода из приложения - **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub) - **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
@@ -60,6 +64,26 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках). При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
### Настройка Telegram Desktop
### Автоматически:
ПКМ по иконке в трее → **«Открыть в Telegram»**
Если не сработало (не открылся Telegram с подключением), то:
1. ПКМ по иконке в трее → **«Скопировать ссылку»**
2. Отправьте ссылку себе в избранное в Telegram клиенте и нажмите по ней ЛКМ
3. Подключитесь
### Вручную:
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
##
### macOS ### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel. Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
@@ -146,6 +170,8 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--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` | | Указать свой домен для проксирования через Cloudfalre. [Подробнее тут](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-доменом |
| `--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` | выкл. | Путь до файла, в который сохранять логи |
@@ -164,24 +190,64 @@ 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
# Fake TLS маскировка (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
``` ```
## Настройка Telegram Desktop ## Fake TLS + nginx upstream
### Домен (`--fake-tls-domain`) должен указывать на тот же IP, на котором стоит прокси
### Автоматически **Пример `nginx.conf` (stream):**
ПКМ по иконке в трее → **«Открыть в Telegram»** ```nginx
upstream mtproto {
server 127.0.0.1:8446;
}
### Вручную map $ssl_preread_server_name $sni_name {
hostnames;
example.com mtproto;
# if you have xray with selfsni running:
# sub.example.com www;
# default xray;
}
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси** # upstream xray {
2. Добавить прокси: # server 127.0.0.1:8443;
- **Тип:** MTProto # }
- **Сервер:** `127.0.0.1` (или переопределенный вами) #
- **Порт:** `1443` (или переопределенный вами) # upstream www {
- **Secret:** из настроек или логов # server 127.0.0.1:7443;
# }
## Конфигурация server {
proxy_protocol on;
set_real_ip_from unix:;
listen 443;
proxy_pass $sni_name;
ssl_preread on;
}
```
**Запуск прокси за nginx:**
```bash
python3 proxy/tg_ws_proxy.py \
--port 8446 \
--host 127.0.0.1 \
--fake-tls-domain example.com \
--proxy-protocol \
--secret <32-hex-chars>
```
Ссылка для подключения будет в формате `ee`-секрета:</p>
```
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
```
## Файлы конфигурации Tray-приложения
Tray-приложение хранит данные в: Tray-приложение хранит данные в:
@@ -202,7 +268,11 @@ Tray-приложение хранит данные в:
"buf_kb": 256, "buf_kb": 256,
"pool_size": 4, "pool_size": 4,
"log_max_mb": 5.0, "log_max_mb": 5.0,
"check_updates": true "check_updates": true,
"cfproxy": true,
"cfproxy_priority": true,
"cfproxy_user_domain": "",
"appearance": "auto"
} }
``` ```
@@ -210,7 +280,7 @@ Tray-приложение хранит данные в:
## Автоматическая сборка ## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](../packaging/windows.spec), [`packaging/macos.spec`](../packaging/macos.spec), [`packaging/linux.spec`](../packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](../.github/workflows/build.yml)) для автоматической сборки.
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
@@ -223,4 +293,4 @@ Tray-приложение хранит данные в:
## Лицензия ## Лицензия
[MIT License](LICENSE) [MIT License](https://github.com/Flowseal/tg-ws-proxy/blob/main/LICENSE)

View File

@@ -273,7 +273,7 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if not acquire_lock("linux.py"): if not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
try: try:

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()
@@ -610,7 +610,7 @@ def run_menubar() -> None:
def main() -> None: def main() -> None:
if not acquire_lock("macos.py"): if not acquire_lock():
_show_info("Приложение уже запущено.") _show_info("Приложение уже запущено.")
return return
try: try:

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, 0, 0, 0), filevers=(1, 6, 2, 0),
prodvers=(1, 0, 0, 0), prodvers=(1, 6, 2, 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.0.0.0'), StringStruct(u'FileVersion', u'1.6.2.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.0.0.0'), StringStruct(u'ProductVersion', u'1.6.2.0'),
] ]
) )
] ]

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.0" __version__ = "1.6.4"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"]

43
proxy/balancer.py Normal file
View File

@@ -0,0 +1,43 @@
import random
from collections import Counter
from typing import Dict, List, Iterator
class _Balancer:
def __init__(self):
self.domains: List[str] = []
self._dc_to_domain: Dict[int, str] = {}
def update_domains_list(self, domains_list: List[str]) -> None:
if Counter(self.domains) == Counter(domains_list):
return
self.domains = domains_list[:]
self._dc_to_domain = {
dc_id: random.choice(self.domains)
for dc_id in (1, 2, 3, 4, 5, 203)
}
def update_domain_for_dc(self, dc_id: int, domain: str) -> bool:
if self._dc_to_domain.get(dc_id) == domain:
return False
self._dc_to_domain[dc_id] = domain
return True
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
current_domain = self._dc_to_domain.get(dc_id)
if current_domain is not None:
yield current_domain
shuffled_domains = self.domains[:]
random.shuffle(shuffled_domains)
for domain in shuffled_domains:
if domain != current_domain:
yield domain
balancer = _Balancer()

View File

@@ -7,6 +7,7 @@ from typing import Dict, List, Optional
from .utils import * from .utils import *
from .stats import stats from .stats import stats
from .balancer import balancer
from .config import proxy_config from .config import proxy_config
from .raw_websocket import RawWebSocket from .raw_websocket import RawWebSocket
@@ -126,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
@@ -140,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:
@@ -150,27 +151,24 @@ 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 ''
active = proxy_config.active_cfproxy_domain
others = [d for d in proxy_config.cfproxy_domains if d != active]
ws = None ws = None
chosen_domain = None chosen_domain = None
log.info("[%s] DC%d%s -> trying CF proxy", log.info("[%s] DC%d%s -> trying CF proxy",
label, dc, media_tag) label, dc, media_tag)
for base_domain in ([active] + others): for base_domain in balancer.get_domains_for_dc(dc):
domain = f'kws{dc}.{base_domain}' domain = f'kws{dc}.{base_domain}'
try: try:
ws = await RawWebSocket.connect(domain, domain, timeout=10.0) ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
@@ -178,45 +176,42 @@ 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
if chosen_domain and chosen_domain != proxy_config.active_cfproxy_domain: if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
log.info("[%s] Switching active CF domain", label) log.info("[%s] Switched active CF domain", label)
proxy_config.active_cfproxy_domain = chosen_domain
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
@@ -313,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):

View File

@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
from typing import Dict, List from typing import Dict, List
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from .balancer import balancer
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
CFPROXY_DOMAINS_URL = ( CFPROXY_DOMAINS_URL = (
@@ -16,7 +18,7 @@ CFPROXY_DOMAINS_URL = (
"/.github/cfproxy-domains.txt" "/.github/cfproxy-domains.txt"
) )
_CFPROXY_ENC: List[str] = ['virkgj.com'] _CFPROXY_ENC: List[str] = ['virkgj.com', 'vmmzovy.com', 'mkuosckvso.com', 'zaewayzmplad.com', 'twdmbzcm.com']
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107)) _S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
@@ -45,8 +47,8 @@ class ProxyConfig:
fallback_cfproxy: bool = True fallback_cfproxy: bool = True
fallback_cfproxy_priority: bool = True fallback_cfproxy_priority: bool = True
cfproxy_user_domain: str = '' cfproxy_user_domain: str = ''
cfproxy_domains: List[str] = field(default_factory=lambda: list(CFPROXY_DEFAULT_DOMAINS)) fake_tls_domain: str = ''
active_cfproxy_domain: str = field(default_factory=lambda: random.choice(CFPROXY_DEFAULT_DOMAINS)) proxy_protocol: bool = False
proxy_config = ProxyConfig() proxy_config = ProxyConfig()
@@ -64,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 []
@@ -77,20 +79,27 @@ def refresh_cfproxy_domains() -> None:
if fetched: if fetched:
seen = set() seen = set()
pool = [d for d in fetched if not (d in seen or seen.add(d))] pool = [d for d in fetched if not (d in seen or seen.add(d))]
balancer.update_domains_list(pool)
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool)) 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) _refresh_stop: threading.Event = threading.Event()
def start_cfproxy_domain_refresh() -> None: def start_cfproxy_domain_refresh() -> None:
threading.Thread( global _refresh_stop
target=refresh_cfproxy_domains, _refresh_stop.set()
daemon=True, _refresh_stop = threading.Event()
name='cfproxy-domains-refresh', stop = _refresh_stop
).start()
balancer.update_domains_list(CFPROXY_DEFAULT_DOMAINS)
def _loop():
refresh_cfproxy_domains()
while not stop.wait(timeout=3600):
refresh_cfproxy_domains()
threading.Thread(target=_loop, daemon=True, name='cfproxy-domains-refresh').start()
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:

256
proxy/fake_tls.py Normal file
View File

@@ -0,0 +1,256 @@
from __future__ import annotations
import asyncio
import hmac
import hashlib
import os
import random
import struct
import time
import logging
from typing import Optional, Tuple
from .stats import stats
log = logging.getLogger('tg-mtproto-proxy')
TLS_RECORD_HANDSHAKE = 0x16
TLS_RECORD_CCS = 0x14
TLS_RECORD_APPDATA = 0x17
TLS_VERSION_10 = b'\x03\x01'
TLS_VERSION_12 = b'\x03\x03'
TLS_VERSION_13 = b'\x03\x04'
CLIENT_RANDOM_OFFSET = 11
CLIENT_RANDOM_LEN = 32
SESSION_ID_OFFSET = 44
SESSION_ID_LEN = 32
TIMESTAMP_TOLERANCE = 120
TLS_APPDATA_MAX = 16384
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
_SERVER_HELLO_TEMPLATE = bytearray(
b'\x16\x03\x03\x00\x7a'
b'\x02\x00\x00\x76'
b'\x03\x03'
+ b'\x00' * 32
+ b'\x20'
+ b'\x00' * 32
+ b'\x13\x01\x00'
+ b'\x00\x2e'
+ b'\x00\x33\x00\x24\x00\x1d\x00\x20'
+ b'\x00' * 32
+ b'\x00\x2b\x00\x02\x03\x04'
)
_SH_RANDOM_OFF = 11
_SH_SESSID_OFF = 44
_SH_PUBKEY_OFF = 89
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
n = len(data)
# 5 (record hdr) + 6 (hs type+len+version) + 32 (random) = 43
if n < 43:
return None
if data[0] != TLS_RECORD_HANDSHAKE:
return None
if data[5] != 0x01:
return None
client_random = bytes(data[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN])
zeroed = bytearray(data)
zeroed[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = b'\x00' * CLIENT_RANDOM_LEN
expected = hmac.new(secret, bytes(zeroed), hashlib.sha256).digest()
if not hmac.compare_digest(expected[:28], client_random[:28]):
return None
ts_xor = bytes(client_random[28 + i] ^ expected[28 + i] for i in range(4))
timestamp = struct.unpack('<I', ts_xor)[0]
now = int(time.time())
if abs(now - timestamp) > TIMESTAMP_TOLERANCE:
return None
session_id = b'\x00' * SESSION_ID_LEN
if n >= SESSION_ID_OFFSET + SESSION_ID_LEN and data[43] == 0x20:
session_id = bytes(data[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN])
return client_random, session_id, timestamp
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -> bytes:
sh = bytearray(_SERVER_HELLO_TEMPLATE)
sh[_SH_SESSID_OFF:_SH_SESSID_OFF + 32] = session_id
sh[_SH_PUBKEY_OFF:_SH_PUBKEY_OFF + 32] = os.urandom(32)
ccs = _CCS_FRAME
encrypted_size = random.randint(1900, 2100)
encrypted_data = os.urandom(encrypted_size)
app_record = b'\x17\x03\x03' + struct.pack('>H', encrypted_size) + encrypted_data
response = bytes(sh) + ccs + app_record
hmac_input = client_random + response
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
final = bytearray(response)
final[_SH_RANDOM_OFF:_SH_RANDOM_OFF + 32] = server_random
return bytes(final)
def wrap_tls_record(data: bytes) -> bytes:
parts = []
offset = 0
while offset < len(data):
chunk = data[offset:offset + TLS_APPDATA_MAX]
parts.append(
b'\x17\x03\x03'
+ struct.pack('>H', len(chunk))
+ chunk
)
offset += len(chunk)
return b''.join(parts)
class FakeTlsStream:
__slots__ = ('_reader', '_writer', '_read_buf', '_read_left')
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self._reader = reader
self._writer = writer
self._read_buf = bytearray()
self._read_left = 0
async def readexactly(self, n: int) -> bytes:
while len(self._read_buf) < n:
payload = await self._read_tls_payload()
if not payload:
raise asyncio.IncompleteReadError(bytes(self._read_buf), n)
self._read_buf.extend(payload)
result = bytes(self._read_buf[:n])
del self._read_buf[:n]
return result
async def read(self, n: int) -> bytes:
if self._read_buf:
chunk = bytes(self._read_buf[:n])
del self._read_buf[:n]
return chunk
payload = await self._read_tls_payload()
if not payload:
return b''
if len(payload) > n:
self._read_buf.extend(payload[n:])
return payload[:n]
return payload
async def _read_tls_payload(self) -> bytes:
if self._read_left > 0:
data = await self._reader.read(self._read_left)
if not data:
return b''
self._read_left -= len(data)
return data
while True:
hdr = await self._reader.readexactly(5)
rtype = hdr[0]
rec_len = struct.unpack('>H', hdr[3:5])[0]
if rtype == TLS_RECORD_CCS:
if rec_len > 0:
await self._reader.readexactly(rec_len)
continue
if rtype != TLS_RECORD_APPDATA:
return b''
data = await self._reader.read(min(rec_len, 65536))
if not data:
return b''
remaining = rec_len - len(data)
if remaining > 0:
self._read_left = remaining
return data
def write(self, data: bytes) -> None:
self._writer.write(wrap_tls_record(data))
async def drain(self) -> None:
await self._writer.drain()
def close(self) -> None:
self._writer.close()
async def wait_closed(self) -> None:
await self._writer.wait_closed()
def get_extra_info(self, name, default=None):
return self._writer.get_extra_info(name, default)
@property
def transport(self):
return self._writer.transport
def is_closing(self):
return self._writer.is_closing()
async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
domain: str, label: str) -> None:
try:
up_reader, up_writer = await asyncio.wait_for(
asyncio.open_connection(domain, 443), timeout=10)
except Exception as exc:
log.warning("[%s] masking: cannot connect to %s:443: %s",
label, domain, repr(exc))
return
log.debug("[%s] masking -> %s:443", label, domain)
stats.connections_masked += 1
try:
if initial_data:
up_writer.write(initial_data)
await up_writer.drain()
async def _relay(src, dst):
try:
while True:
chunk = await src.read(16384)
if not chunk:
break
dst.write(chunk)
await dst.drain()
except (ConnectionResetError, BrokenPipeError, OSError,
asyncio.CancelledError):
pass
finally:
try:
dst.close()
await dst.wait_closed()
except Exception:
pass
await asyncio.gather(
_relay(reader, up_writer),
_relay(up_reader, writer),
)
except Exception:
pass
finally:
try:
up_writer.close()
except Exception:
pass

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 {}

View File

@@ -8,6 +8,7 @@ class _Stats:
self.connections_tcp_fallback = 0 self.connections_tcp_fallback = 0
self.connections_cfproxy = 0 self.connections_cfproxy = 0
self.connections_bad = 0 self.connections_bad = 0
self.connections_masked = 0
self.ws_errors = 0 self.ws_errors = 0
self.bytes_up = 0 self.bytes_up = 0
self.bytes_down = 0 self.bytes_down = 0
@@ -24,6 +25,7 @@ class _Stats:
f"tcp_fb={self.connections_tcp_fallback} " f"tcp_fb={self.connections_tcp_fallback} "
f"cf={self.connections_cfproxy} " f"cf={self.connections_cfproxy} "
f"bad={self.connections_bad} " f"bad={self.connections_bad} "
f"masked={self.connections_masked} "
f"err={self.ws_errors} " f"err={self.ws_errors} "
f"pool={pool_s} " f"pool={pool_s} "
f"up={human_bytes(self.bytes_up)} " f"up={human_bytes(self.bytes_up)} "

View File

@@ -4,7 +4,6 @@ import os
import sys import sys
import time import time
import struct import struct
import random
import asyncio import asyncio
import hashlib import hashlib
import argparse import argparse
@@ -25,17 +24,19 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''):
from .utils import * from .utils import *
from .stats import stats from .stats import stats
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, CFPROXY_DEFAULT_DOMAINS from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
from .balancer import balancer
log = logging.getLogger('tg-mtproto-proxy') 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]]:
@@ -190,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
@@ -206,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,19 +356,19 @@ 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)
try: try:
try: init = await _read_client_init(
handshake = await asyncio.wait_for( reader, writer, secret, label, proxy_config.fake_tls_domain)
reader.readexactly(HANDSHAKE_LEN), timeout=10) if init is None:
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return return
handshake, clt_reader, clt_writer, label = init
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:
while await reader.read(4096): while await clt_reader.read(4096):
pass pass
except Exception: except Exception:
pass pass
@@ -248,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 ""
@@ -308,7 +408,7 @@ async def _handle_client(reader, writer, secret: bytes):
except Exception: except Exception:
pass pass
ok = await do_fallback( ok = await do_fallback(
reader, writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_media, media_tag,
ctx, splitter=splitter) ctx, splitter=splitter)
if not ok: if not ok:
@@ -357,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:
@@ -378,7 +478,7 @@ async def _handle_client(reader, writer, secret: bytes):
except Exception: except Exception:
pass pass
ok = await do_fallback( ok = await do_fallback(
reader, writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_media, media_tag,
ctx, splitter=splitter_fb) ctx, splitter=splitter_fb)
if ok: if ok:
@@ -399,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(reader, 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)
@@ -415,13 +515,14 @@ 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:
stats.connections_active -= 1 stats.connections_active -= 1
try: try:
writer.close() writer.close()
await writer.wait_closed()
except BaseException: except BaseException:
pass pass
@@ -443,11 +544,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
if proxy_config.fallback_cfproxy: if proxy_config.fallback_cfproxy:
user = proxy_config.cfproxy_user_domain user = proxy_config.cfproxy_user_domain
if user: if user:
proxy_config.cfproxy_domains = [user] balancer.update_domains_list([user])
proxy_config.active_cfproxy_domain = user
else: else:
proxy_config.cfproxy_domains = list(CFPROXY_DEFAULT_DOMAINS)
proxy_config.active_cfproxy_domain = random.choice(CFPROXY_DEFAULT_DOMAINS)
start_cfproxy_domain_refresh() start_cfproxy_domain_refresh()
secret_bytes = bytes.fromhex(proxy_config.secret) secret_bytes = bytes.fromhex(proxy_config.secret)
@@ -467,12 +565,23 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
pass pass
link_host = get_link_host(proxy_config.host) link_host = get_link_host(proxy_config.host)
tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}" ftls = proxy_config.fake_tls_domain
dd_link = (f"tg://proxy?server={link_host}"
f"&port={proxy_config.port}"
f"&secret=dd{proxy_config.secret}")
ee_link = ""
if ftls:
domain_hex = ftls.encode('ascii').hex()
ee_link = (f"tg://proxy?server={link_host}"
f"&port={proxy_config.port}"
f"&secret=ee{proxy_config.secret}{domain_hex}")
log.info("=" * 60) log.info("=" * 60)
log.info(" Telegram MTProto WS Bridge Proxy") log.info(" Telegram MTProto WS Bridge Proxy")
log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port) log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port)
log.info(" Secret: %s", proxy_config.secret) log.info(" Secret: %s", proxy_config.secret)
if ftls:
log.info(" Fake TLS: %s", ftls)
log.info(" Target DC IPs:") log.info(" Target DC IPs:")
for dc in sorted(proxy_config.dc_redirects.keys()): for dc in sorted(proxy_config.dc_redirects.keys()):
ip = proxy_config.dc_redirects.get(dc) ip = proxy_config.dc_redirects.get(dc)
@@ -482,8 +591,11 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
user_domain = "user" if proxy_config.cfproxy_user_domain else "auto" user_domain = "user" if proxy_config.cfproxy_user_domain else "auto"
log.info(" CF proxy: enabled (%s | %s)", prio, user_domain) log.info(" CF proxy: enabled (%s | %s)", prio, user_domain)
log.info("=" * 60) log.info("=" * 60)
log.info(" Connect link:") log.info(" Connect:")
log.info(" %s", tg_link) if ftls:
log.info(" %s", ee_link)
else:
log.info(" %s", dd_link)
log.info("=" * 60) log.info("=" * 60)
async def log_stats(): async def log_stats():
@@ -569,6 +681,13 @@ def main():
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=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='',
metavar='DOMAIN',
help='Enable Fake TLS (ee-secret) masking with the given '
'SNI domain, e.g. example.com')
ap.add_argument('--proxy-protocol', action='store_true',
help='Accept PROXY protocol v1 header '
'(for use behind nginx/haproxy with proxy_protocol on)')
args = ap.parse_args() args = ap.parse_args()
if not args.dc_ip: if not args.dc_ip:
@@ -602,7 +721,9 @@ 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 proxy_config.cfproxy_user_domain = args.cfproxy_domain.strip()
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol
log_level = logging.DEBUG if args.verbose else logging.INFO log_level = logging.DEBUG if args.verbose else logging.INFO
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
@@ -624,6 +745,8 @@ def main():
fh.setFormatter(log_fmt) fh.setFormatter(log_fmt)
root.addHandler(fh) root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING)
try: try:
asyncio.run(_run()) asyncio.run(_run())
except KeyboardInterrupt: except KeyboardInterrupt:

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"

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from proxy import __version__, get_link_host, parse_dc_ip_list from proxy import __version__, get_link_host, parse_dc_ip_list
from proxy.config import CFPROXY_DEFAULT_DOMAINS from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status from utils.update_check import RELEASES_PAGE_URL, get_status
@@ -121,13 +121,19 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
def _run_cfproxy_auto_test(domains: list) -> tuple: def _run_cfproxy_auto_test(domains: list) -> tuple:
last: dict = {} merged: dict = {}
for domain in domains: best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain) res = _run_cfproxy_connectivity_test(domain)
last = res if all(v is True for v in res.values()):
if any(v is True for v in res.values()):
return domain, res return domain, res
return None, last for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def _cfproxy_show_test_results(domain: str, results: dict) -> None: def _cfproxy_show_test_results(domain: str, results: dict) -> None:
@@ -308,7 +314,7 @@ def install_tray_config_form(
header = ctk.CTkFrame(frame, fg_color="transparent") header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2)) header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel( ctk.CTkLabel(
header, text="Настройки прокси", header, text="Настройки",
font=(theme.ui_font_family, 17, "bold"), font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w", text_color=theme.text_primary, anchor="w",
).pack(side="left") ).pack(side="left")
@@ -345,6 +351,17 @@ def install_tray_config_form(
command=_on_appearance_change, command=_on_appearance_change,
).pack(side="right") ).pack(side="right")
ctk.CTkButton(
header, text="Donate ♥", width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color="#22c55e", hover_color="#16a34a",
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md"),
),
).pack(side="right", padx=(0, 6))
conn = _config_section(ctk, frame, theme, "Подключение MTProto") conn = _config_section(ctk, frame, theme, "Подключение MTProto")
host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row = ctk.CTkFrame(conn, fg_color="transparent")
@@ -434,7 +451,7 @@ def install_tray_config_form(
_threading.Thread(target=_worker, daemon=True).start() _threading.Thread(target=_worker, daemon=True).start()
else: else:
def _worker_auto(): def _worker_auto():
ok_domain, res = _run_cfproxy_auto_test(CFPROXY_DEFAULT_DOMAINS) ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
if btn: if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal")) btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
btn.after(0, lambda: _cfproxy_show_auto_test_results(ok_domain, res)) btn.after(0, lambda: _cfproxy_show_auto_test_results(ok_domain, res))

View File

@@ -51,7 +51,7 @@ def ensure_dirs() -> None:
_lock_file_path: Optional[Path] = None _lock_file_path: Optional[Path] = None
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool: def _same_process(meta: dict, proc: psutil.Process) -> bool:
try: try:
lock_ct = float(meta.get("create_time", 0.0)) lock_ct = float(meta.get("create_time", 0.0))
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0: if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
@@ -63,7 +63,7 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
return False return False
def acquire_lock(script_hint: str = "") -> bool: def acquire_lock() -> bool:
global _lock_file_path global _lock_file_path
ensure_dirs() ensure_dirs()
for f in list(APP_DIR.glob("*.lock")): for f in list(APP_DIR.glob("*.lock")):
@@ -84,7 +84,7 @@ def acquire_lock(script_hint: str = "") -> bool:
pass pass
is_running = False is_running = False
try: try:
is_running = _same_process(meta, psutil.Process(pid), script_hint) is_running = _same_process(meta, psutil.Process(pid))
except Exception: except Exception:
pass pass
if is_running: if is_running:
@@ -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)
@@ -153,6 +153,7 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
level = logging.DEBUG if verbose else logging.INFO level = logging.DEBUG if verbose else logging.INFO
root = logging.getLogger() root = logging.getLogger()
root.setLevel(level) root.setLevel(level)
logging.getLogger('asyncio').setLevel(logging.WARNING)
fh = logging.handlers.RotatingFileHandler( fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE), str(LOG_FILE),
@@ -241,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"
@@ -390,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()

View File

@@ -56,6 +56,39 @@ from ui.ctk_theme import (
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
_win_mutex_handle = None
_ERROR_ALREADY_EXISTS = 183
def _acquire_win_mutex() -> bool | None:
global _win_mutex_handle
try:
kernel32 = ctypes.windll.kernel32
kernel32.CreateMutexW.restype = ctypes.c_void_p
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
handle = kernel32.CreateMutexW(None, True, "Local\\TgWsProxy_SingleInstance")
if kernel32.GetLastError() == _ERROR_ALREADY_EXISTS:
kernel32.CloseHandle(ctypes.c_void_p(handle))
return False
if not handle:
return None
_win_mutex_handle = handle
return True
except Exception:
return None
def _release_win_mutex() -> None:
global _win_mutex_handle
if _win_mutex_handle:
try:
kernel32 = ctypes.windll.kernel32
kernel32.ReleaseMutex(ctypes.c_void_p(_win_mutex_handle))
kernel32.CloseHandle(ctypes.c_void_p(_win_mutex_handle))
except Exception:
pass
_win_mutex_handle = None
ICON_PATH = str(Path(__file__).parent / "icon.ico") ICON_PATH = str(Path(__file__).parent / "icon.ico")
@@ -350,13 +383,15 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if not acquire_lock("windows.py"): if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
finally: finally:
release_lock() release_lock()
_release_win_mutex()
if __name__ == "__main__": if __name__ == "__main__":