mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-22 23:41:44 +03:00
Compare commits
91 Commits
v1.5.1
...
b7cca232ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7cca232ea | ||
|
|
0eebdff69e | ||
|
|
ab3bec967c | ||
|
|
a16f7dfc0b | ||
|
|
6f02fc1c46 | ||
|
|
884fffcc2f | ||
|
|
09ce00b2e0 | ||
|
|
362c5a4893 | ||
|
|
bff67b3ecf | ||
|
|
d5abfbf9c2 | ||
|
|
8269ebe3bb | ||
|
|
3770569789 | ||
|
|
e72a44d74b | ||
|
|
33d3147c0b | ||
|
|
e46cf138ac | ||
|
|
145b0c431a | ||
|
|
b991948a77 | ||
|
|
42df9cfdc4 | ||
|
|
eabc0c9a15 | ||
|
|
ea88adc2dc | ||
|
|
f554f730c2 | ||
|
|
f85c4fe854 | ||
|
|
b070647996 | ||
|
|
e3d2229f7f | ||
|
|
1cbc7dee7d | ||
|
|
1728fabfc7 | ||
|
|
bf08bad11e | ||
|
|
a787b4fd6b | ||
|
|
2c577c1166 | ||
|
|
05b22fe3ba | ||
|
|
cc00c6d040 | ||
|
|
b3ed5c09db | ||
|
|
b8556dc702 | ||
|
|
28be00ea9e | ||
|
|
5795de00b1 | ||
|
|
c5fa5b7f3e | ||
|
|
a70e50b9f3 | ||
|
|
059ca8760f | ||
|
|
0c8d0f160a | ||
|
|
791708cc3d | ||
|
|
1abcbf86fe | ||
|
|
d84b9eadc4 | ||
|
|
c1b4cb0204 | ||
|
|
5d08e16e5d | ||
|
|
a844a88f38 | ||
|
|
e5f1d02737 | ||
|
|
3a6e82c2a8 | ||
|
|
e56ada1a34 | ||
|
|
b44d79a933 | ||
|
|
77723d875f | ||
|
|
548ec05fc5 | ||
|
|
03c7719c39 | ||
|
|
db4cebe0b2 | ||
|
|
ca81d037f7 | ||
|
|
07615af49c | ||
|
|
f8ee37370d | ||
|
|
4cbb9e555c | ||
|
|
25ae4b0a24 | ||
|
|
8af1bc8c89 | ||
|
|
b48ac67b9f | ||
|
|
937acdb461 | ||
|
|
6f3da84e48 | ||
|
|
3c3e9eb34b | ||
|
|
ba89cad8b8 | ||
|
|
bf905ec54f | ||
|
|
ace0a5e968 | ||
|
|
e47eef4709 | ||
|
|
abe1d1f01e | ||
|
|
cc31c02c9d | ||
|
|
f39bb15ff6 | ||
|
|
5a62cd82b2 | ||
|
|
fe4e0e8234 | ||
|
|
172dc67093 | ||
|
|
c5c2907fa8 | ||
|
|
26b95ffa0f | ||
|
|
3dfcc27932 | ||
|
|
6e0e567790 | ||
|
|
bc79a5e4c1 | ||
|
|
ce83b78bac | ||
|
|
a6235f3594 | ||
|
|
c0d9b5f8e1 | ||
|
|
4041fd9f05 | ||
|
|
dd09f24449 | ||
|
|
dd666489e3 | ||
|
|
3af0cd75a2 | ||
|
|
535d4126ed | ||
|
|
44e754ded0 | ||
|
|
71be4461d3 | ||
|
|
9279399f00 | ||
|
|
557c92b9a3 | ||
|
|
c883674ad0 |
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.py text diff=python
|
||||||
|
*.spec text linguist-language=Python
|
||||||
|
|
||||||
|
*.toml text
|
||||||
|
*.txt text
|
||||||
|
|
||||||
|
*.ico binary
|
||||||
11
.github/CODEOWNERS
vendored
Normal file
11
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Default owners
|
||||||
|
* @Flowseal
|
||||||
|
|
||||||
|
# Automation and repository settings
|
||||||
|
.github/** @Flowseal
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/** @Flowseal
|
||||||
|
|
||||||
|
# Core proxy implementation
|
||||||
|
proxy/** @Flowseal
|
||||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
custom: ['https://nowpayments.io/donation/flowseal']
|
||||||
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,20 +1,23 @@
|
|||||||
name: 🐛 Проблема
|
name: 🐛 Проблема
|
||||||
title: '[Проблема] '
|
title: '[Проблема] '
|
||||||
description: Сообщить о проблеме
|
description: Сообщить о проблеме
|
||||||
labels: ['type: проблема', 'status: нуждается в сортировке']
|
labels: ['bug']
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: input
|
||||||
id: description
|
id: app_version
|
||||||
attributes:
|
attributes:
|
||||||
label: Опишите вашу проблему
|
label: Версия TG WS Proxy
|
||||||
description: Чётко опишите проблему с которой вы столкнулись
|
description: Укажите версию приложения (например, v1.2.3)
|
||||||
placeholder: Описание проблемы
|
placeholder: vX.Y.Z
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additions
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Дополнительные детали
|
label: Опишите вашу проблему
|
||||||
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы.
|
description: Чётко опишите проблему, с которой вы столкнулись
|
||||||
|
placeholder: Описание проблемы
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
6
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: 📚 Документация
|
||||||
|
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
|
||||||
|
about: Ознакомьтесь с документацией перед созданием issue
|
||||||
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: 🚀 Предложение
|
||||||
|
title: '[Предложение] '
|
||||||
|
description: Предложить улучшение или новую функциональность
|
||||||
|
labels: ['enhancement']
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Предлагаемое решение
|
||||||
|
description: Опишите, как именно вы предлагаете улучшить проект
|
||||||
|
placeholder: |
|
||||||
|
Предлагаю добавить ...
|
||||||
|
Это позволит ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Для какой платформы актуально?
|
||||||
|
description: Выберите платформу, если предложение связано с конкретной ОС
|
||||||
|
options:
|
||||||
|
- Все платформы
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
- Другое
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Дополнительный контекст
|
||||||
|
description: Добавьте примеры, ссылки, скриншоты или другие детали
|
||||||
|
placeholder: Любые дополнительные материалы по предложению
|
||||||
10
.github/cfproxy-domains.txt
vendored
Normal file
10
.github/cfproxy-domains.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
virkgj.com
|
||||||
|
vmmzovy.com
|
||||||
|
mkuosckvso.com
|
||||||
|
zaewayzmplad.com
|
||||||
|
twdmbzcm.com
|
||||||
|
awzwsldi.com
|
||||||
|
clngqrflngqin.com
|
||||||
|
tjacxbqtj.com
|
||||||
|
bxaxtxmrw.com
|
||||||
|
dmohrsgmohcrwb.com
|
||||||
92
.github/workflows/build.yml
vendored
92
.github/workflows/build.yml
vendored
@@ -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
|
||||||
@@ -357,6 +358,76 @@ jobs:
|
|||||||
dpkg-deb --build --root-owner-group \
|
dpkg-deb --build --root-owner-group \
|
||||||
"$PKG_ROOT" \
|
"$PKG_ROOT" \
|
||||||
"dist/TgWsProxy_linux_amd64.deb"
|
"dist/TgWsProxy_linux_amd64.deb"
|
||||||
|
|
||||||
|
- name: Create .rpm package with fpm
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
sudo gem install fpm -v 1.17.0
|
||||||
|
|
||||||
|
mkdir -p rpm_package/usr/bin
|
||||||
|
mkdir -p rpm_package/usr/share/applications
|
||||||
|
mkdir -p rpm_package/usr/share/icons/hicolor/256x256/apps
|
||||||
|
|
||||||
|
cp dist/TgWsProxy_linux_amd64 rpm_package/usr/bin/tg-ws-proxy
|
||||||
|
chmod 755 rpm_package/usr/bin/tg-ws-proxy
|
||||||
|
|
||||||
|
.venv/bin/python - <<PY
|
||||||
|
from PIL import Image
|
||||||
|
Image.open("icon.ico").save(
|
||||||
|
"rpm_package/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
|
||||||
|
"PNG",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
cat > rpm_package/usr/share/applications/tg-ws-proxy.desktop <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=TG WS Proxy
|
||||||
|
GenericName=Telegram Proxy
|
||||||
|
Comment=Telegram Desktop WebSocket Bridge Proxy
|
||||||
|
Exec=tg-ws-proxy
|
||||||
|
Icon=tg-ws-proxy
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
StartupNotify=true
|
||||||
|
Keywords=telegram;proxy;websocket;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > post_install.sh <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
if [ -x /usr/bin/update-desktop-database ]; then
|
||||||
|
/usr/bin/update-desktop-database &> /dev/null || :
|
||||||
|
fi
|
||||||
|
if [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||||
|
/usr/bin/gtk-update-icon-cache -q /usr/share/icons/hicolor &> /dev/null || :
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x post_install.sh
|
||||||
|
|
||||||
|
fpm -s dir \
|
||||||
|
-t rpm \
|
||||||
|
-n tg-ws-proxy \
|
||||||
|
-v ${VERSION} \
|
||||||
|
--iteration 1 \
|
||||||
|
--architecture x86_64 \
|
||||||
|
--license "MIT" \
|
||||||
|
--vendor "Flowseal" \
|
||||||
|
--maintainer "Flowseal" \
|
||||||
|
--url "https://github.com/Flowseal/tg-ws-proxy" \
|
||||||
|
--description "MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI." \
|
||||||
|
--depends "libgtk-3.so.0()(64bit)" \
|
||||||
|
--depends "libayatana-appindicator3.so.1()(64bit)" \
|
||||||
|
--depends "python3-tkinter" \
|
||||||
|
--after-install post_install.sh \
|
||||||
|
--after-remove post_install.sh \
|
||||||
|
-C rpm_package \
|
||||||
|
.
|
||||||
|
|
||||||
|
mv tg-ws-proxy-${VERSION}-1.x86_64.rpm dist/TgWsProxy_linux_amd64.rpm
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -365,6 +436,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
dist/TgWsProxy_linux_amd64
|
dist/TgWsProxy_linux_amd64
|
||||||
dist/TgWsProxy_linux_amd64.deb
|
dist/TgWsProxy_linux_amd64.deb
|
||||||
|
dist/TgWsProxy_linux_amd64.rpm
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-windows, build-win7, build-macos, build-linux]
|
needs: [build-windows, build-win7, build-macos, build-linux]
|
||||||
@@ -383,7 +455,12 @@ 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)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Не можете скачать?
|
||||||
|
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
|
||||||
files: |
|
files: |
|
||||||
dist/TgWsProxy_windows.exe
|
dist/TgWsProxy_windows.exe
|
||||||
dist/TgWsProxy_windows_7_64bit.exe
|
dist/TgWsProxy_windows_7_64bit.exe
|
||||||
@@ -391,6 +468,7 @@ jobs:
|
|||||||
dist/TgWsProxy_macos_universal.dmg
|
dist/TgWsProxy_macos_universal.dmg
|
||||||
dist/TgWsProxy_linux_amd64
|
dist/TgWsProxy_linux_amd64
|
||||||
dist/TgWsProxy_linux_amd64.deb
|
dist/TgWsProxy_linux_amd64.deb
|
||||||
|
dist/TgWsProxy_linux_amd64.rpm
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
|
|||||||
42
.github/workflows/issue-triage.yml
vendored
Normal file
42
.github/workflows/issue-triage.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Auto comment on new issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
if: contains(github.event.issue.labels.*.name, 'bug')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Comment on new issue
|
||||||
|
uses: peter-evans/create-or-update-comment@v5
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.issue.number }}
|
||||||
|
body: |
|
||||||
|
### Проверьте две вещи:
|
||||||
|
- вы на последней версии: [Releases](https://github.com/Flowseal/tg-ws-proxy/releases)
|
||||||
|
- запускали по инструкции для своей ОС: [Быстрый старт](https://github.com/Flowseal/tg-ws-proxy#навигация)
|
||||||
|
|
||||||
|
## Решение частых проблем:
|
||||||
|
|
||||||
|
**Q**: Не запускается, падает с ошибкой, не работает как раньше после обновления?
|
||||||
|
**A**:
|
||||||
|
1. Удалите всё в папке Temp (или хотя бы всё, что начинается с _MEI)
|
||||||
|
2. Запускайте от имени админа
|
||||||
|
3. Попробуйте Win7 версию (если вы пользователь Windows)
|
||||||
|
4. Попробуйте отключить антивирус (если помогло, то добавьте exe в исключения). Не забудьте включить антивирус обратно.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
**Q**: Не грузит медиа? (фото/видео/стикеры)
|
||||||
|
**A**: Удалите в настройках прокси в поле **DC → IP** всё, кроме `4:149.154.167.220`. Если это не помогло, полностью очистите это поле.
|
||||||
|
|
||||||
|
|
||||||
|
#### Если проблема решена, то закройте Issue
|
||||||
|
|
||||||
|
### Если проблема осталась, пожалуйста, приложите по возможности логи.
|
||||||
|
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
48
CONTRIBUTING.md
Normal file
48
CONTRIBUTING.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# CONTRIBUTING
|
||||||
|
|
||||||
|
Спасибо за желание помочь проекту `tg-ws-proxy`.
|
||||||
|
|
||||||
|
## Перед созданием issue
|
||||||
|
|
||||||
|
1. Проверьте документацию в `docs/README.md`.
|
||||||
|
2. Убедитесь, что похожий issue еще не открыт.
|
||||||
|
3. Для корректной работы triage используйте стандартные лейблы из `.github/labels.md`.
|
||||||
|
|
||||||
|
## Как сообщать о проблемах
|
||||||
|
|
||||||
|
- Используйте шаблон `Проблема`.
|
||||||
|
- По возможности укажите:
|
||||||
|
- версию приложения,
|
||||||
|
- ОС,
|
||||||
|
- шаги воспроизведения,
|
||||||
|
- ожидаемое и фактическое поведение,
|
||||||
|
- лог-файл или текст ошибки.
|
||||||
|
|
||||||
|
Чем точнее описание, тем быстрее можно помочь.
|
||||||
|
|
||||||
|
## Локальный запуск из исходников
|
||||||
|
|
||||||
|
Требуется Python `>=3.8`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
|
- консольный режим: `tg-ws-proxy`
|
||||||
|
- Windows tray: `tg-ws-proxy-tray-win`
|
||||||
|
- macOS tray: `tg-ws-proxy-tray-macos`
|
||||||
|
- Linux tray: `tg-ws-proxy-tray-linux`
|
||||||
|
|
||||||
|
Подробности: `docs/BuildFromSource.md`.
|
||||||
|
|
||||||
|
## Pull Request
|
||||||
|
|
||||||
|
Перед открытием PR:
|
||||||
|
|
||||||
|
1. Убедитесь, что изменение решает конкретную проблему.
|
||||||
|
2. Проверьте, что не сломаны существующие сценарии.
|
||||||
|
3. Обновите документацию, если меняется поведение или настройка.
|
||||||
|
|
||||||
|
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
|
||||||
@@ -24,6 +24,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
PATH=/opt/venv/bin:$PATH \
|
PATH=/opt/venv/bin:$PATH \
|
||||||
TG_WS_PROXY_HOST=0.0.0.0 \
|
TG_WS_PROXY_HOST=0.0.0.0 \
|
||||||
TG_WS_PROXY_PORT=1443 \
|
TG_WS_PROXY_PORT=1443 \
|
||||||
|
TG_WS_PROXY_SECRET="" \
|
||||||
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
|
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -41,5 +42,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; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
||||||
CMD []
|
CMD []
|
||||||
|
|||||||
75
docs/BuildFromSource.md
Normal file
75
docs/BuildFromSource.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Установка из исходников
|
||||||
|
|
||||||
|
## Консольный прокси
|
||||||
|
|
||||||
|
Для запуска только прокси без интерфейса системного трея достаточно базовой установки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tray-приложение по ОС
|
||||||
|
|
||||||
|
### Windows 7/10+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-win
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-macos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## Консольный режим из исходников
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Аргументы:**
|
||||||
|
|
||||||
|
| Аргумент | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port` | `1443` | Порт прокси |
|
||||||
|
| `--host` | `127.0.0.1` | Хост прокси |
|
||||||
|
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
||||||
|
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
||||||
|
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
||||||
|
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare. [Подробнее](./CfProxy.md) |
|
||||||
|
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md) |
|
||||||
|
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
||||||
|
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
||||||
|
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||||
|
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
|
||||||
|
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
|
||||||
|
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
|
||||||
|
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
||||||
|
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Стандартный запуск
|
||||||
|
tg-ws-proxy
|
||||||
|
|
||||||
|
# Другой порт и дополнительные DC
|
||||||
|
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
||||||
|
|
||||||
|
# С подробным логированием
|
||||||
|
tg-ws-proxy -v
|
||||||
|
|
||||||
|
# Fake TLS маскировка (ee-secret)
|
||||||
|
tg-ws-proxy --fake-tls-domain example.com
|
||||||
|
```
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
# Cloudflare Proxy
|
# Cloudflare-прокси
|
||||||
|
|
||||||
Для недоступных датацентров можно использовать альтернативный бесплатный метод подключения - проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и лучше) заменить на свой.
|
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
|
||||||
|
|
||||||
Прокси возвращает доступ к тому, что до этого не грузило (реакциям, некоторым стикерам). Если у вас до этого не грузило видео/фото на аккаунте без премиума, то уберите `2:149.154.167.220` из `DC->IP` блока в настройках. Если CF-прокси у вас работает - медиа сновая начнёт грузиться.
|
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
|
||||||
|
|
||||||
## Зачем мне настраивать свой домен?
|
## Зачем мне настраивать свой домен?
|
||||||
Cloudflare имеет лимиты на одновременное количество подключений WS. Домен по умолчанию может перестать работать в любой момент.
|
|
||||||
|
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
|
||||||
|
|
||||||
## Настройка своего домена
|
## Настройка своего домена
|
||||||
1. Добавьте свой домен в Cloudflare (либо купив у них напрямую, либо поменяв NS сервера: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)
|
|
||||||
|
|
||||||
2. В `SSL/TLS` -> `Overview` выставьте режим **Flexible**
|
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
|
||||||
|
|
||||||
3. В `DNS` -> `Records` добавьте следующие `A` записи через `+ Add Record`:
|
2. В `SSL/TLS` → `Overview` выставьте режим **Flexible**.
|
||||||
|
|
||||||
|
3. В `DNS` → `Records` добавьте следующие `A`-записи через `+ Add Record`:
|
||||||
- Name=`kws1` IPv4=`149.154.175.50`
|
- Name=`kws1` IPv4=`149.154.175.50`
|
||||||
- Name=`kws2` IPv4=`149.154.167.51`
|
- Name=`kws2` IPv4=`149.154.167.51`
|
||||||
- Name=`kws3` IPv4=`149.154.175.100`
|
- Name=`kws3` IPv4=`149.154.175.100`
|
||||||
- Name=`kws4` IPv4=`149.154.167.91`
|
- Name=`kws4` IPv4=`149.154.167.91`
|
||||||
- Name=`kws5` IPv4=`149.154.171.5`
|
- Name=`kws5` IPv4=`149.154.171.5`
|
||||||
- Name=`kws203` IPv4=`149.154.175.50`
|
- Name=`kws203` IPv4=`91.105.192.100`
|
||||||
|
|
||||||
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или другой софт для обхода блокировок, так как подсеть Cloudflare забанена (по крайней мере, если вы из России)**
|
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
|
||||||
|
|
||||||
5. В настройках TgWsProxy поменяйте домен на свой
|
5. В настройках `TgWsProxy` замените домен на свой.
|
||||||
|
|
||||||
## Mentions
|
## Благодарности
|
||||||
Idea - https://github.com/Nekogram/WSProxy
|
|
||||||
Thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for the information
|
- Идея: https://github.com/Nekogram/WSProxy
|
||||||
|
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
|
||||||
124
docs/CfWorker.md
Normal file
124
docs/CfWorker.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Cloudflare Worker
|
||||||
|
|
||||||
|
Альтернативный (полностью бесплатный, не нужно покупать домен в отличии от [CfProxy](./CfProxy.md)) способ проксирования.
|
||||||
|
|
||||||
|
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium с данным способом все еще не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
1. **Добавьте в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО следующие домены:**
|
||||||
|
```
|
||||||
|
cloudflare.com
|
||||||
|
cloudflare.dev
|
||||||
|
workers.dev
|
||||||
|
```
|
||||||
|
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
|
||||||
|
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
|
||||||
|
3. Слева в панели выберите `Compute` → `Workers & Pages`
|
||||||
|
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
|
||||||
|
|
||||||
|
4. Нажмите сверху справа кнопку **`Create application`** → `Start with Hello World!` → `Deploy`
|
||||||
|
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
|
||||||
|
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
|
||||||
|
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
|
||||||
|
|
||||||
|
5. Сверху справа нажмите кнопку **`Edit code`**, замените код слева на тот, [что находится внизу этой страницы](./CfWorker.md#код-workerа)
|
||||||
|
* Если у вас не загружается код, то вы не выполнили первый пункт
|
||||||
|
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
|
||||||
|
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
|
||||||
|
|
||||||
|
|
||||||
|
6. Нажмите сверху справа кнопку **`Deploy`**
|
||||||
|
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
|
||||||
|
|
||||||
|
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
|
||||||
|
* Пример домена: `random-symbols-1234.username.workers.dev`
|
||||||
|
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
|
||||||
|
|
||||||
|
|
||||||
|
### Код Worker'а
|
||||||
|
```javascript
|
||||||
|
import { connect } from "cloudflare:sockets";
|
||||||
|
|
||||||
|
function toBytes(data) {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (data && typeof data.arrayBuffer === "function") {
|
||||||
|
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
|
||||||
|
}
|
||||||
|
return new Uint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request) {
|
||||||
|
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
|
||||||
|
return new Response("Expected websocket", { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname !== "/apiws") {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dst = url.searchParams.get("dst");
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
const client = pair[0];
|
||||||
|
const server = pair[1];
|
||||||
|
server.accept();
|
||||||
|
|
||||||
|
const socket = connect({ hostname: dst, port: 443 });
|
||||||
|
const tcpReader = socket.readable.getReader();
|
||||||
|
const tcpWriter = socket.writable.getWriter();
|
||||||
|
|
||||||
|
server.addEventListener("message", async (event) => {
|
||||||
|
try {
|
||||||
|
await tcpWriter.write(await toBytes(event.data));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
server.close(1011, "tcp write failed");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addEventListener("close", async () => {
|
||||||
|
try {
|
||||||
|
await tcpWriter.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await tcpReader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
server.send(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
tcpReader.releaseLock();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new Response(null, { status: 101, webSocket: client });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
52
docs/FakeTlsNginx.md
Normal file
52
docs/FakeTlsNginx.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Fake TLS + upstream в nginx
|
||||||
|
|
||||||
|
Домен в параметре `--fake-tls-domain` должен указывать на тот же IP, на котором запущен прокси.
|
||||||
|
|
||||||
|
## Пример `nginx.conf` для stream-модуля
|
||||||
|
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# upstream xray {
|
||||||
|
# server 127.0.0.1:8443;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# upstream www {
|
||||||
|
# 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`-секрета:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
|
||||||
|
```
|
||||||
12
docs/Funding.md
Normal file
12
docs/Funding.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> ### 🎉 Поддержать меня
|
||||||
|
>
|
||||||
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
|
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||||
|
|
||||||
|
Проект полностью бесплатен для всех.
|
||||||
|
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||||
|
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
||||||
69
docs/README.docker.md
Normal file
69
docs/README.docker.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# TG WS Proxy для Docker
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Вводите команды последовательно, одну за другой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скачиваем репозиторий
|
||||||
|
git clone https://github.com/Flowseal/tg-ws-proxy.git
|
||||||
|
|
||||||
|
# Переходим в папку с проектом
|
||||||
|
cd tg-ws-proxy
|
||||||
|
|
||||||
|
# Собираем образ
|
||||||
|
docker build -t tg-ws-proxy .
|
||||||
|
|
||||||
|
# Запускаем контейнер
|
||||||
|
docker run -d \
|
||||||
|
--name tg-ws-proxy \
|
||||||
|
--restart=always \
|
||||||
|
-p 1443:1443 \
|
||||||
|
tg-ws-proxy:latest
|
||||||
|
|
||||||
|
# Получаем ссылку для подключения
|
||||||
|
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
|
||||||
|
```
|
||||||
|
|
||||||
|
После выполнения последней команды вы увидите ссылку вида:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка параметров
|
||||||
|
|
||||||
|
Все настройки задаются переменными окружения при запуске контейнера:
|
||||||
|
|
||||||
|
| Переменная | Описание | По умолчанию |
|
||||||
|
|-----------------------|------------------------------------------------|--------------------------------------|
|
||||||
|
| `TG_WS_PROXY_HOST` | Адрес для приёма подключений | `0.0.0.0` |
|
||||||
|
| `TG_WS_PROXY_PORT` | Порт внутри контейнера | `1443` |
|
||||||
|
| `TG_WS_PROXY_SECRET` | Секретный ключ | `random` |
|
||||||
|
| `TG_WS_PROXY_DC_IPS` | Пары «номер DC:IP» через пробел | `2:149.154.167.220 4:149.154.167.220`|
|
||||||
|
|
||||||
|
Пример с ручным указанием секрета:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name tg-ws-proxy \
|
||||||
|
--restart=always \
|
||||||
|
-p 1443:1443 \
|
||||||
|
-e TG_WS_PROXY_SECRET="ваш_секрет" \
|
||||||
|
tg-ws-proxy:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Для генерации секрета можно использовать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 16
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
51
docs/README.linux.md
Normal file
51
docs/README.linux.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# TG WS Proxy для Linux
|
||||||
|
|
||||||
|
## Готовые сборки
|
||||||
|
|
||||||
|
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет `TgWsProxy_linux_amd64.deb`.
|
||||||
|
|
||||||
|
Для Arch и основанных на Arch дистрибутивов подготовлены пакеты в 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
|
||||||
|
# Установка без AUR-helper
|
||||||
|
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
|
||||||
|
cd tg-ws-proxy-bin
|
||||||
|
makepkg -si
|
||||||
|
|
||||||
|
# При помощи AUR-helper
|
||||||
|
paru -S tg-ws-proxy-bin
|
||||||
|
|
||||||
|
# Для пакета -cli запуск через systemd (8888 — номер порта; secret можно сгенерировать командой openssl rand -hex 16)
|
||||||
|
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
|
||||||
|
```
|
||||||
|
|
||||||
|
Для остальных дистрибутивов можно использовать `TgWsProxy_linux_amd64` (бинарный файл для x86_64).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x TgWsProxy_linux_amd64
|
||||||
|
./TgWsProxy_linux_amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-linux
|
||||||
|
```
|
||||||
30
docs/README.macos.md
Normal file
30
docs/README.macos.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# TG WS Proxy для macOS
|
||||||
|
|
||||||
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте `TgWsProxy_macos_universal.dmg` (универсальная сборка для Apple Silicon и Intel).
|
||||||
|
|
||||||
|
1. Откройте образ
|
||||||
|
2. Перенесите `TG WS Proxy.app` в папку `Applications`
|
||||||
|
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
|
||||||
|
|
||||||
|
Минимально поддерживаемые версии:
|
||||||
|
|
||||||
|
- Intel macOS 10.15+
|
||||||
|
- Apple Silicon macOS 11.0+
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-macos
|
||||||
|
```
|
||||||
277
docs/README.md
277
docs/README.md
@@ -1,28 +1,86 @@
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> ### 🎉 Поддержать меня
|
> ### [🎉 Поддержать меня](./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) **Попробуйте скачать версию для Windows 7 (по функциональности она не отличается)**
|
||||||
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
||||||
>
|
>
|
||||||
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
|
> Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
|
||||||
|
|
||||||
# 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>
|
||||||
|
|
||||||
|
## Навигация
|
||||||
|
|
||||||
|
- **🚀 Быстрый старт**
|
||||||
|
- **[Windows](./README.windows.md)**
|
||||||
|
- **[macOS](./README.macos.md)**
|
||||||
|
- **[Linux](./README.linux.md)**
|
||||||
|
- **[Docker](./README.docker.md)**
|
||||||
|
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md)
|
||||||
|
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
|
||||||
|
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
||||||
|
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
||||||
|
- [Установка из исходников](./BuildFromSource.md)
|
||||||
|
- [Руководство для контрибьюторов](../CONTRIBUTING.md)
|
||||||
|
|
||||||
|
## Windows: быстрый вход
|
||||||
|
|
||||||
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||||
|
|
||||||
|
- `TgWsProxy_windows.exe` (Windows 10+)
|
||||||
|
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||||
|
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||||
|
|
||||||
|
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
|
||||||
|
|
||||||
|
### Меню трея
|
||||||
|
|
||||||
|
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
|
||||||
|
- **Скопировать ссылку** — скопировать ссылку для подключения
|
||||||
|
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||||
|
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
|
||||||
|
- **Открыть логи** — открыть файл логов
|
||||||
|
- **Выход** — остановить прокси и закрыть приложение
|
||||||
|
|
||||||
|
### Настройка Telegram Desktop
|
||||||
|
|
||||||
|
**Автоматическая настройка**
|
||||||
|
|
||||||
|
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
|
||||||
|
|
||||||
|
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
|
||||||
|
|
||||||
|
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
|
||||||
|
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
|
||||||
|
3. Подключитесь
|
||||||
|
|
||||||
|
**Ручная настройка**
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
@@ -33,192 +91,19 @@ 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]
|
||||||
|
> ### Не грузит фото/видео?
|
||||||
### Windows
|
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
|
||||||
|
> **Если это не помогло, полностью очистите это поле**
|
||||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
> Подобная проблема встречается на аккаунтах без Premium
|
||||||
|
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md)
|
||||||
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
|
|
||||||
|
|
||||||
**Меню трея:**
|
|
||||||
|
|
||||||
- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку
|
|
||||||
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
|
||||||
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
|
|
||||||
- **Открыть логи** — открыть файл логов
|
|
||||||
- **Выход** — остановить прокси и закрыть приложение
|
|
||||||
|
|
||||||
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
|
|
||||||
|
|
||||||
1. Открыть образ
|
|
||||||
2. Перенести **TG WS Proxy.app** в папку **Applications**
|
|
||||||
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
Для 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)
|
|
||||||
|
|
||||||
```shell
|
|
||||||
# Установка без AUR-helper
|
|
||||||
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
|
|
||||||
cd tg-ws-proxy-bin
|
|
||||||
makepkg -si
|
|
||||||
|
|
||||||
# При помощи AUR-helper
|
|
||||||
paru -S tg-ws-proxy-bin
|
|
||||||
|
|
||||||
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
|
|
||||||
# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
|
|
||||||
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
|
|
||||||
```
|
|
||||||
|
|
||||||
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x TgWsProxy_linux_amd64
|
|
||||||
./TgWsProxy_linux_amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
|
||||||
|
|
||||||
## Установка из исходников
|
|
||||||
|
|
||||||
### Консольный proxy
|
|
||||||
|
|
||||||
Для запуска только proxy без tray-интерфейса достаточно базовой установки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
tg-ws-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows 7/10+
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
tg-ws-proxy-tray-win
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
tg-ws-proxy-tray-macos
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
tg-ws-proxy-tray-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
### Консольный режим из исходников
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Аргументы:**
|
|
||||||
|
|
||||||
| Аргумент | По умолчанию | Описание |
|
|
||||||
|---|---|---|
|
|
||||||
| `--port` | `1443` | Порт прокси |
|
|
||||||
| `--host` | `127.0.0.1` | Хост прокси |
|
|
||||||
| `--secret` | `random` | 32 hex chars secret для авторизации клиентов |
|
|
||||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
|
|
||||||
| `--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-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
|
|
||||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
|
||||||
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
|
|
||||||
| `--log-file` | выкл. | Путь до файла, в который сохранять логи |
|
|
||||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) |
|
|
||||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
|
||||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
|
||||||
|
|
||||||
**Примеры:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Стандартный запуск
|
|
||||||
tg-ws-proxy
|
|
||||||
|
|
||||||
# Другой порт и дополнительные DC
|
|
||||||
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
|
||||||
|
|
||||||
# С подробным логированием
|
|
||||||
tg-ws-proxy -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI-скрипты (pyproject.toml)
|
|
||||||
|
|
||||||
CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`.
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[project.scripts]
|
|
||||||
tg-ws-proxy = "proxy.tg_ws_proxy:main"
|
|
||||||
tg-ws-proxy-tray-win = "windows:main"
|
|
||||||
tg-ws-proxy-tray-macos = "macos:main"
|
|
||||||
tg-ws-proxy-tray-linux = "linux:main"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Настройка Telegram Desktop
|
|
||||||
|
|
||||||
### Автоматически
|
|
||||||
|
|
||||||
ПКМ по иконке в трее → **«Открыть в Telegram»**
|
|
||||||
|
|
||||||
### Вручную
|
|
||||||
|
|
||||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
|
||||||
2. Добавить прокси:
|
|
||||||
- **Тип:** MTProto
|
|
||||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
|
||||||
- **Порт:** `1443` (или переопределенный вами)
|
|
||||||
- **Secret:** из настроек или логов
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
Tray-приложение хранит данные в:
|
|
||||||
|
|
||||||
- **Windows:** `%APPDATA%/TgWsProxy`
|
|
||||||
- **macOS:** `~/Library/Application Support/TgWsProxy`
|
|
||||||
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
"port": 1443,
|
|
||||||
"secret": "...",
|
|
||||||
"dc_ip": [
|
|
||||||
"2:149.154.167.220",
|
|
||||||
"4:149.154.167.220"
|
|
||||||
],
|
|
||||||
"verbose": false,
|
|
||||||
"buf_kb": 256,
|
|
||||||
"pool_size": 4,
|
|
||||||
"log_max_mb": 5.0,
|
|
||||||
"check_updates": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему).
|
|
||||||
|
|
||||||
## Автоматическая сборка
|
## Автоматическая сборка
|
||||||
|
|
||||||
Проект содержит спецификации 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)) для автоматической сборки.
|
||||||
|
|
||||||
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||||
|
|
||||||
@@ -229,6 +114,14 @@ Tray-приложение хранит данные в:
|
|||||||
- Apple Silicon macOS 11.0+
|
- Apple Silicon macOS 11.0+
|
||||||
- Linux x86_64 (требуется AppIndicator для системного трея)
|
- Linux x86_64 (требуется AppIndicator для системного трея)
|
||||||
|
|
||||||
|
## Контрибьюторы
|
||||||
|
|
||||||
|
Спасибо всем, кто помогает развивать проект ❤️
|
||||||
|
|
||||||
|
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
[MIT License](../LICENSE)
|
||||||
|
|||||||
52
docs/README.windows.md
Normal file
52
docs/README.windows.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# TG WS Proxy для Windows
|
||||||
|
|
||||||
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||||
|
|
||||||
|
- `TgWsProxy_windows.exe` (Windows 10+)
|
||||||
|
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||||
|
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||||
|
|
||||||
|
Сборки публикуются автоматически через [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
||||||
|
|
||||||
|
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
|
||||||
|
|
||||||
|
## Меню трея
|
||||||
|
|
||||||
|
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
|
||||||
|
- **Скопировать ссылку** — скопировать ссылку для подключения
|
||||||
|
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||||
|
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
|
||||||
|
- **Открыть логи** — открыть файл логов
|
||||||
|
- **Выход** — остановить прокси и закрыть приложение
|
||||||
|
|
||||||
|
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (эту проверку можно отключить в настройках).
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
### Автоматическая настройка
|
||||||
|
|
||||||
|
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
|
||||||
|
|
||||||
|
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
|
||||||
|
|
||||||
|
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
|
||||||
|
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
|
||||||
|
3. Подключитесь
|
||||||
|
|
||||||
|
### Ручная настройка
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-win
|
||||||
|
```
|
||||||
31
docs/TrayConfig.md
Normal file
31
docs/TrayConfig.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Файлы конфигурации Tray-приложения
|
||||||
|
|
||||||
|
Tray-приложение хранит данные в:
|
||||||
|
|
||||||
|
- **Windows:** `%APPDATA%/TgWsProxy`
|
||||||
|
- **macOS:** `~/Library/Application Support/TgWsProxy`
|
||||||
|
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 1443,
|
||||||
|
"secret": "...",
|
||||||
|
"dc_ip": [
|
||||||
|
"2:149.154.167.220",
|
||||||
|
"4:149.154.167.220"
|
||||||
|
],
|
||||||
|
"verbose": false,
|
||||||
|
"buf_kb": 256,
|
||||||
|
"pool_size": 4,
|
||||||
|
"log_max_mb": 5.0,
|
||||||
|
"check_updates": true,
|
||||||
|
"cfproxy": true,
|
||||||
|
"cfproxy_user_domain": "",
|
||||||
|
"cfproxy_worker_domain": "",
|
||||||
|
"appearance": "auto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки).
|
||||||
|
На Windows в конфиге может быть `autostart` (автозапуск при входе в систему).
|
||||||
33
linux.py
33
linux.py
@@ -12,7 +12,7 @@ import pyperclip
|
|||||||
import pystray
|
import pystray
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import get_link_host
|
||||||
|
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
||||||
@@ -138,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _edit_config_dialog() -> None:
|
def _edit_config_dialog() -> None:
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
_show_error("customtkinter не установлен.")
|
_show_error("customtkinter не установлен.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -156,21 +156,40 @@ def _edit_config_dialog() -> None:
|
|||||||
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
||||||
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
|
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
|
||||||
|
|
||||||
|
_original_appearance = ctk.get_appearance_mode()
|
||||||
|
|
||||||
def _finish() -> None:
|
def _finish() -> None:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
|
def _cancel() -> None:
|
||||||
|
ctk.set_appearance_mode(_original_appearance)
|
||||||
|
_finish()
|
||||||
|
|
||||||
def on_save() -> None:
|
def on_save() -> None:
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
|
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
|
||||||
if isinstance(merged, str):
|
if isinstance(merged, str):
|
||||||
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_ui_only_keys = {"appearance", "check_updates"}
|
||||||
|
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
|
||||||
|
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
|
||||||
|
|
||||||
|
if not config_changed:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
save_config(merged)
|
save_config(merged)
|
||||||
_config.update(merged)
|
_config.update(merged)
|
||||||
log.info("Config saved: %s", merged)
|
log.info("Config saved: %s", merged)
|
||||||
_tray_icon.menu = _build_menu()
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
|
if not proxy_changed:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
do_restart = messagebox.askyesno(
|
do_restart = messagebox.askyesno(
|
||||||
"Перезапустить?",
|
"Перезапустить?",
|
||||||
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||||
@@ -180,8 +199,8 @@ def _edit_config_dialog() -> None:
|
|||||||
if do_restart:
|
if do_restart:
|
||||||
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
|
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
|
||||||
|
|
||||||
root.protocol("WM_DELETE_WINDOW", _finish)
|
root.protocol("WM_DELETE_WINDOW", _cancel)
|
||||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
|
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
|
||||||
|
|
||||||
ctk_run_dialog(_build)
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
@@ -193,7 +212,7 @@ def _show_first_run() -> None:
|
|||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
if FIRST_RUN_MARKER.exists():
|
if FIRST_RUN_MARKER.exists():
|
||||||
return
|
return
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
FIRST_RUN_MARKER.touch()
|
FIRST_RUN_MARKER.touch()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -227,7 +246,7 @@ def _show_first_run() -> None:
|
|||||||
def _build_menu():
|
def _build_menu():
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
return pystray.Menu(
|
||||||
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
||||||
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
||||||
@@ -273,7 +292,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:
|
||||||
|
|||||||
72
macos.py
72
macos.py
@@ -24,8 +24,8 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pyperclip = None
|
pyperclip = None
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
||||||
from proxy import __version__
|
from proxy.tg_ws_proxy import _run
|
||||||
|
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
|
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
|
||||||
@@ -41,6 +41,8 @@ _app: Optional[object] = None
|
|||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
_exiting: bool = False
|
_exiting: bool = False
|
||||||
|
|
||||||
|
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
||||||
|
|
||||||
# osascript dialogs
|
# osascript dialogs
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +111,32 @@ def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> O
|
|||||||
return r.stdout.rstrip("\r\n")
|
return r.stdout.rstrip("\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_cfworker_domain(default: str) -> Optional[str]:
|
||||||
|
value = default
|
||||||
|
while True:
|
||||||
|
script = (
|
||||||
|
f'set d to display dialog "{_esc("Cloudflare Worker домен (например, name.account.workers.dev):")}" '
|
||||||
|
f'default answer "{_esc(value)}" '
|
||||||
|
f'with title "TG WS Proxy" '
|
||||||
|
f'buttons {{"Закрыть", "?", "OK"}} '
|
||||||
|
f'default button "OK" cancel button "Закрыть"\n'
|
||||||
|
f'return (button returned of d) & "\\n" & (text returned of d)'
|
||||||
|
)
|
||||||
|
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
out_lines = r.stdout.splitlines()
|
||||||
|
button = out_lines[0].strip() if out_lines else ""
|
||||||
|
value = out_lines[1].strip() if len(out_lines) > 1 else value
|
||||||
|
|
||||||
|
if button == "?":
|
||||||
|
webbrowser.open(_CFWORKER_HELP_URL)
|
||||||
|
continue
|
||||||
|
if button == "OK":
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
# menubar icon
|
# menubar icon
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +181,7 @@ def _run_proxy_thread() -> None:
|
|||||||
stop_ev = _asyncio.Event()
|
stop_ev = _asyncio.Event()
|
||||||
_async_stop = (loop, stop_ev)
|
_async_stop = (loop, stop_ev)
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Proxy thread crashed: %s", exc)
|
log.error("Proxy thread crashed: %s", exc)
|
||||||
if "Address already in use" in str(exc):
|
if "Address already in use" in str(exc):
|
||||||
@@ -176,7 +204,7 @@ def _start_proxy() -> None:
|
|||||||
if not apply_proxy_config(_config):
|
if not apply_proxy_config(_config):
|
||||||
_show_error("Ошибка конфигурации DC → IP.")
|
_show_error("Ошибка конфигурации DC → IP.")
|
||||||
return
|
return
|
||||||
pc = tg_ws_proxy.proxy_config
|
pc = proxy_config
|
||||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||||
_proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
|
_proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
|
||||||
_proxy_thread.start()
|
_proxy_thread.start()
|
||||||
@@ -309,7 +337,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()
|
||||||
|
|
||||||
@@ -362,7 +390,7 @@ def _edit_config_dialog() -> None:
|
|||||||
return
|
return
|
||||||
dc_lines = [s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()]
|
dc_lines = [s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()]
|
||||||
try:
|
try:
|
||||||
tg_ws_proxy.parse_dc_ip_list(dc_lines)
|
parse_dc_ip_list(dc_lines)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
_show_error(str(e))
|
_show_error(str(e))
|
||||||
return
|
return
|
||||||
@@ -396,23 +424,21 @@ def _edit_config_dialog() -> None:
|
|||||||
if cfproxy is None:
|
if cfproxy is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
cfproxy_priority = True
|
|
||||||
if cfproxy:
|
|
||||||
cfproxy_priority_result = _ask_yes_no_close("Приоритет CfProxy (пробовать раньше прямого TCP)?")
|
|
||||||
if cfproxy_priority_result is None:
|
|
||||||
return
|
|
||||||
cfproxy_priority = cfproxy_priority_result
|
|
||||||
|
|
||||||
cfproxy_domain = _osascript_input(
|
cfproxy_domain = _osascript_input(
|
||||||
"Домен CF-прокси:\n"
|
"Свой CF-домен (оставьте пустым для автоматического выбора):\n"
|
||||||
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.\n"
|
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
||||||
"pclead.co.uk готовый настроенный домен. Подробнее про настройку читайте в репозитории - docs/CfProxy.md",
|
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")),
|
||||||
cfg.get("cfproxy_domain", DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk")),
|
|
||||||
)
|
)
|
||||||
if cfproxy_domain is None:
|
if cfproxy_domain is None:
|
||||||
return
|
return
|
||||||
cfproxy_domain = cfproxy_domain.strip()
|
cfproxy_domain = cfproxy_domain.strip()
|
||||||
|
|
||||||
|
cfworker_domain = _ask_cfworker_domain(
|
||||||
|
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", ""))
|
||||||
|
)
|
||||||
|
if cfworker_domain is None:
|
||||||
|
return
|
||||||
|
|
||||||
new_cfg = {
|
new_cfg = {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
@@ -424,8 +450,8 @@ def _edit_config_dialog() -> None:
|
|||||||
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
|
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
|
||||||
"check_updates": cfg.get("check_updates", True),
|
"check_updates": cfg.get("check_updates", True),
|
||||||
"cfproxy": cfproxy,
|
"cfproxy": cfproxy,
|
||||||
"cfproxy_priority": cfproxy_priority,
|
"cfproxy_user_domain": cfproxy_domain,
|
||||||
"cfproxy_domain": cfproxy_domain or DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk"),
|
"cfproxy_worker_domain": cfworker_domain,
|
||||||
}
|
}
|
||||||
save_config(new_cfg)
|
save_config(new_cfg)
|
||||||
log.info("Config saved: %s", new_cfg)
|
log.info("Config saved: %s", new_cfg)
|
||||||
@@ -451,7 +477,7 @@ def _show_first_run() -> None:
|
|||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
tg_url = tg_proxy_url(_config)
|
tg_url = tg_proxy_url(_config)
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
f"Прокси запущен и работает в строке меню.\n\n"
|
f"Прокси запущен и работает в строке меню.\n\n"
|
||||||
@@ -520,7 +546,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
|
|||||||
|
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
|
|
||||||
self._open_tg_item = rumps.MenuItem(
|
self._open_tg_item = rumps.MenuItem(
|
||||||
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
|
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
|
||||||
@@ -560,7 +586,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
|
|||||||
def update_menu_title(self) -> None:
|
def update_menu_title(self) -> None:
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
|
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
|
||||||
|
|
||||||
|
|
||||||
@@ -611,7 +637,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:
|
||||||
|
|||||||
@@ -46,11 +46,25 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||||
|
|||||||
@@ -25,11 +25,25 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
|
||||||
if not os.path.exists(icon_path):
|
if not os.path.exists(icon_path):
|
||||||
icon_path = None
|
icon_path = None
|
||||||
|
|||||||
@@ -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, 7, 0, 0),
|
||||||
prodvers=(1, 0, 0, 0),
|
prodvers=(1, 7, 0, 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.7.0.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.7.0.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,13 +26,27 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||||
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
__version__ = "1.5.1"
|
from .config import parse_dc_ip_list, proxy_config
|
||||||
|
from .utils import get_link_host, build_github_opener
|
||||||
|
|
||||||
|
__version__ = "1.7.0"
|
||||||
|
|
||||||
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener"]
|
||||||
43
proxy/balancer.py
Normal file
43
proxy/balancer.py
Normal 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()
|
||||||
403
proxy/bridge.py
Normal file
403
proxy/bridge.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
from .stats import stats
|
||||||
|
from .balancer import balancer
|
||||||
|
from .config import proxy_config
|
||||||
|
from .raw_websocket import RawWebSocket
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
_st_I_le = struct.Struct('<I')
|
||||||
|
|
||||||
|
ZERO_64 = b'\x00' * 64
|
||||||
|
DC_DEFAULT_IPS: Dict[int, str] = {
|
||||||
|
1: '149.154.175.50',
|
||||||
|
2: '149.154.167.51',
|
||||||
|
3: '149.154.175.100',
|
||||||
|
4: '149.154.167.91',
|
||||||
|
5: '149.154.171.5',
|
||||||
|
203: '91.105.192.100'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoCtx:
|
||||||
|
__slots__ = ('clt_dec', 'clt_enc', 'tg_enc', 'tg_dec')
|
||||||
|
|
||||||
|
def __init__(self, clt_dec, clt_enc, tg_enc, tg_dec):
|
||||||
|
self.clt_dec = clt_dec # decrypt from client
|
||||||
|
self.clt_enc = clt_enc # encrypt to client
|
||||||
|
self.tg_enc = tg_enc # encrypt to telegram
|
||||||
|
self.tg_dec = tg_dec # decrypt from telegram
|
||||||
|
|
||||||
|
|
||||||
|
class MsgSplitter:
|
||||||
|
"""
|
||||||
|
Splits TCP stream data into individual MTProto transport packets
|
||||||
|
so each can be sent as a separate WS frame.
|
||||||
|
"""
|
||||||
|
__slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
||||||
|
|
||||||
|
def __init__(self, relay_init: bytes, proto_int: int):
|
||||||
|
cipher = Cipher(algorithms.AES(relay_init[8:40]),
|
||||||
|
modes.CTR(relay_init[40:56]))
|
||||||
|
self._dec = cipher.encryptor()
|
||||||
|
self._dec.update(ZERO_64)
|
||||||
|
self._proto = proto_int
|
||||||
|
self._cipher_buf = bytearray()
|
||||||
|
self._plain_buf = bytearray()
|
||||||
|
self._disabled = False
|
||||||
|
|
||||||
|
def split(self, chunk: bytes) -> List[bytes]:
|
||||||
|
if not chunk:
|
||||||
|
return []
|
||||||
|
if self._disabled:
|
||||||
|
return [chunk]
|
||||||
|
|
||||||
|
self._cipher_buf.extend(chunk)
|
||||||
|
self._plain_buf.extend(self._dec.update(chunk))
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
while self._cipher_buf:
|
||||||
|
packet_len = self._next_packet_len()
|
||||||
|
if packet_len is None:
|
||||||
|
break
|
||||||
|
if packet_len <= 0:
|
||||||
|
parts.append(bytes(self._cipher_buf))
|
||||||
|
self._cipher_buf.clear()
|
||||||
|
self._plain_buf.clear()
|
||||||
|
self._disabled = True
|
||||||
|
break
|
||||||
|
parts.append(bytes(self._cipher_buf[:packet_len]))
|
||||||
|
del self._cipher_buf[:packet_len]
|
||||||
|
del self._plain_buf[:packet_len]
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def flush(self) -> List[bytes]:
|
||||||
|
if not self._cipher_buf:
|
||||||
|
return []
|
||||||
|
tail = bytes(self._cipher_buf)
|
||||||
|
self._cipher_buf.clear()
|
||||||
|
self._plain_buf.clear()
|
||||||
|
return [tail]
|
||||||
|
|
||||||
|
def _next_packet_len(self) -> Optional[int]:
|
||||||
|
if not self._plain_buf:
|
||||||
|
return None
|
||||||
|
if self._proto == PROTO_ABRIDGED_INT:
|
||||||
|
return self._next_abridged_len()
|
||||||
|
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||||
|
PROTO_PADDED_INTERMEDIATE_INT):
|
||||||
|
return self._next_intermediate_len()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _next_abridged_len(self) -> Optional[int]:
|
||||||
|
first = self._plain_buf[0]
|
||||||
|
if first in (0x7F, 0xFF):
|
||||||
|
if len(self._plain_buf) < 4:
|
||||||
|
return None
|
||||||
|
payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4
|
||||||
|
header_len = 4
|
||||||
|
else:
|
||||||
|
payload_len = (first & 0x7F) * 4
|
||||||
|
header_len = 1
|
||||||
|
if payload_len <= 0:
|
||||||
|
return 0
|
||||||
|
packet_len = header_len + payload_len
|
||||||
|
if len(self._plain_buf) < packet_len:
|
||||||
|
return None
|
||||||
|
return packet_len
|
||||||
|
|
||||||
|
def _next_intermediate_len(self) -> Optional[int]:
|
||||||
|
if len(self._plain_buf) < 4:
|
||||||
|
return None
|
||||||
|
payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF
|
||||||
|
if payload_len <= 0:
|
||||||
|
return 0
|
||||||
|
packet_len = 4 + payload_len
|
||||||
|
if len(self._plain_buf) < packet_len:
|
||||||
|
return None
|
||||||
|
return packet_len
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
|
dc: int, is_media: bool, media_tag: str,
|
||||||
|
ctx: CryptoCtx, splitter=None):
|
||||||
|
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
||||||
|
use_cf = proxy_config.fallback_cfproxy
|
||||||
|
worker_domain = proxy_config.cfproxy_worker_domain
|
||||||
|
|
||||||
|
methods: List[str] = []
|
||||||
|
|
||||||
|
if worker_domain and fallback_dst:
|
||||||
|
methods.append('cf_worker')
|
||||||
|
if use_cf:
|
||||||
|
methods.append('cf')
|
||||||
|
if fallback_dst:
|
||||||
|
methods.append('tcp')
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
if method == 'cf_worker' and fallback_dst:
|
||||||
|
ok = await _cfproxy_worker_fallback(
|
||||||
|
reader, writer, relay_init, label, ctx,
|
||||||
|
dc=dc, is_media=is_media, fallback_dst=fallback_dst,
|
||||||
|
splitter=splitter)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
elif method == 'cf':
|
||||||
|
ok = await _cfproxy_fallback(
|
||||||
|
reader, writer, relay_init, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=splitter)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
elif method == 'tcp' and fallback_dst:
|
||||||
|
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
|
||||||
|
label, dc, media_tag, fallback_dst)
|
||||||
|
ok = await _tcp_fallback(
|
||||||
|
reader, writer, fallback_dst, 443,
|
||||||
|
relay_init, label, ctx)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc: int, is_media: bool,
|
||||||
|
fallback_dst: str,
|
||||||
|
splitter=None):
|
||||||
|
media_tag = ' media' if is_media else ''
|
||||||
|
worker_domain = proxy_config.cfproxy_worker_domain
|
||||||
|
if not worker_domain:
|
||||||
|
return False
|
||||||
|
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
'media': '1' if is_media else '0',
|
||||||
|
})
|
||||||
|
path = f'/apiws?{query}'
|
||||||
|
|
||||||
|
log.info("[%s] DC%d%s -> trying CF worker for %s",
|
||||||
|
label, dc, media_tag, fallback_dst)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||||
|
timeout=10.0, path=path)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] DC%d%s CF worker failed: %s",
|
||||||
|
label, dc, media_tag, repr(exc))
|
||||||
|
return False
|
||||||
|
|
||||||
|
stats.connections_cfproxy += 1
|
||||||
|
await ws.send(relay_init)
|
||||||
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=splitter)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc: int, is_media: bool,
|
||||||
|
splitter=None):
|
||||||
|
media_tag = ' media' if is_media else ''
|
||||||
|
ws = None
|
||||||
|
chosen_domain = None
|
||||||
|
|
||||||
|
log.info("[%s] DC%d%s -> trying CF proxy",
|
||||||
|
label, dc, media_tag)
|
||||||
|
|
||||||
|
for base_domain in balancer.get_domains_for_dc(dc):
|
||||||
|
domain = f'kws{dc}.{base_domain}'
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
|
||||||
|
chosen_domain = base_domain
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] DC%d%s CF proxy failed: %s",
|
||||||
|
label, dc, media_tag, repr(exc))
|
||||||
|
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
|
||||||
|
log.info("[%s] Switched active CF domain", label)
|
||||||
|
|
||||||
|
stats.connections_cfproxy += 1
|
||||||
|
await ws.send(relay_init)
|
||||||
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=splitter)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
|
||||||
|
try:
|
||||||
|
rr, rw = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(dst, port), timeout=10)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] TCP fallback to %s:%d failed: %s",
|
||||||
|
label, dst, port, repr(exc))
|
||||||
|
return False
|
||||||
|
|
||||||
|
stats.connections_tcp_fallback += 1
|
||||||
|
rw.write(relay_init)
|
||||||
|
await rw.drain()
|
||||||
|
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc=None, is_media=False,
|
||||||
|
splitter: Optional[MsgSplitter] = None):
|
||||||
|
"""
|
||||||
|
Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
|
||||||
|
client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS
|
||||||
|
WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP
|
||||||
|
"""
|
||||||
|
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
|
||||||
|
|
||||||
|
up_bytes = 0
|
||||||
|
down_bytes = 0
|
||||||
|
up_packets = 0
|
||||||
|
down_packets = 0
|
||||||
|
start_time = asyncio.get_running_loop().time()
|
||||||
|
|
||||||
|
async def tcp_to_ws():
|
||||||
|
nonlocal up_bytes, up_packets
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
if splitter:
|
||||||
|
tail = splitter.flush()
|
||||||
|
if tail:
|
||||||
|
await ws.send(tail[0])
|
||||||
|
break
|
||||||
|
n = len(chunk)
|
||||||
|
stats.bytes_up += n
|
||||||
|
up_bytes += n
|
||||||
|
up_packets += 1
|
||||||
|
plain = ctx.clt_dec.update(chunk)
|
||||||
|
chunk = ctx.tg_enc.update(plain)
|
||||||
|
if splitter:
|
||||||
|
parts = splitter.split(chunk)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if len(parts) > 1:
|
||||||
|
await ws.send_batch(parts)
|
||||||
|
else:
|
||||||
|
await ws.send(parts[0])
|
||||||
|
else:
|
||||||
|
await ws.send(chunk)
|
||||||
|
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||||
|
|
||||||
|
async def ws_to_tcp():
|
||||||
|
nonlocal down_bytes, down_packets
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.recv()
|
||||||
|
if data is None:
|
||||||
|
break
|
||||||
|
n = len(data)
|
||||||
|
stats.bytes_down += n
|
||||||
|
down_bytes += n
|
||||||
|
down_packets += 1
|
||||||
|
plain = ctx.tg_dec.update(data)
|
||||||
|
data = ctx.clt_enc.update(plain)
|
||||||
|
writer.write(data)
|
||||||
|
await writer.drain()
|
||||||
|
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||||
|
asyncio.create_task(ws_to_tcp())]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
elapsed = asyncio.get_running_loop().time() - start_time
|
||||||
|
log.info("[%s] %s WS session closed: "
|
||||||
|
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||||
|
label, dc_tag,
|
||||||
|
human_bytes(up_bytes), up_packets,
|
||||||
|
human_bytes(down_bytes), down_packets,
|
||||||
|
elapsed)
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
|
||||||
|
label, ctx: CryptoCtx):
|
||||||
|
"""Bidirectional TCP <-> TCP with re-encryption."""
|
||||||
|
|
||||||
|
async def forward(src, dst_w, is_up):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await src.read(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
n = len(data)
|
||||||
|
if is_up:
|
||||||
|
stats.bytes_up += n
|
||||||
|
plain = ctx.clt_dec.update(data)
|
||||||
|
data = ctx.tg_enc.update(plain)
|
||||||
|
else:
|
||||||
|
stats.bytes_down += n
|
||||||
|
plain = ctx.tg_dec.update(data)
|
||||||
|
data = ctx.clt_enc.update(plain)
|
||||||
|
dst_w.write(data)
|
||||||
|
await dst_w.drain()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] forward ended: %s", label, e)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(forward(reader, remote_writer, True)),
|
||||||
|
asyncio.create_task(forward(remote_reader, writer, False)),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
for w in (writer, remote_writer):
|
||||||
|
try:
|
||||||
|
w.close()
|
||||||
|
await w.wait_closed()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
178
proxy/config.py
Normal file
178
proxy/config.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
import socket as _socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List
|
||||||
|
from urllib.request import Request
|
||||||
|
|
||||||
|
from .balancer import balancer
|
||||||
|
from .utils import build_github_opener
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
CFPROXY_DOMAINS_URL = (
|
||||||
|
"https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main"
|
||||||
|
"/.github/cfproxy-domains.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CFPROXY_ENC: List[str] = [
|
||||||
|
'virkgj.com',
|
||||||
|
'vmmzovy.com',
|
||||||
|
'mkuosckvso.com',
|
||||||
|
'zaewayzmplad.com',
|
||||||
|
'twdmbzcm.com',
|
||||||
|
'awzwsldi.com',
|
||||||
|
'clngqrflngqin.com',
|
||||||
|
'tjacxbqtj.com',
|
||||||
|
'bxaxtxmrw.com',
|
||||||
|
'dmohrsgmohcrwb.com'
|
||||||
|
]
|
||||||
|
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||||
|
|
||||||
|
|
||||||
|
def _dd(s: str) -> str:
|
||||||
|
"""Only for decoding CF proxy domains"""
|
||||||
|
if not s[-4:] == '.com':
|
||||||
|
return s
|
||||||
|
p, n = s[:-4], sum(c.isalpha() for c in s[:-4])
|
||||||
|
return ''.join(
|
||||||
|
chr((ord(c) - (97 if c > '`' else 65) - n) % 26 + (97 if c > '`' else 65))
|
||||||
|
if c.isalpha() else c for c in p
|
||||||
|
) + _S
|
||||||
|
|
||||||
|
|
||||||
|
CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC]
|
||||||
|
_CFPROXY_MIN_VALID_DOMAINS = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProxyConfig:
|
||||||
|
port: int = 1443
|
||||||
|
host: str = '127.0.0.1'
|
||||||
|
secret: str = field(default_factory=lambda: os.urandom(16).hex())
|
||||||
|
dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
|
||||||
|
buffer_size: int = 256 * 1024
|
||||||
|
pool_size: int = 4
|
||||||
|
fallback_cfproxy: bool = True
|
||||||
|
cfproxy_user_domain: str = ''
|
||||||
|
cfproxy_worker_domain: str = ''
|
||||||
|
fake_tls_domain: str = ''
|
||||||
|
proxy_protocol: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
proxy_config = ProxyConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_cfproxy_domain_list() -> List[str]:
|
||||||
|
try:
|
||||||
|
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
||||||
|
headers={'User-Agent': 'tg-ws-proxy'})
|
||||||
|
with build_github_opener().open(req, timeout=10) as resp:
|
||||||
|
text = resp.read().decode('utf-8', errors='replace')
|
||||||
|
encoded = [
|
||||||
|
line.strip() for line in text.splitlines()
|
||||||
|
if line.strip() and not line.startswith('#')
|
||||||
|
]
|
||||||
|
return [_dd(d) for d in encoded]
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_domain(domain: str) -> bool:
|
||||||
|
if not domain or len(domain) > 253:
|
||||||
|
return False
|
||||||
|
if domain.startswith('.') or domain.endswith('.'):
|
||||||
|
return False
|
||||||
|
labels = domain.split('.')
|
||||||
|
if len(labels) < 2:
|
||||||
|
return False
|
||||||
|
for label in labels:
|
||||||
|
if not label or len(label) > 63:
|
||||||
|
return False
|
||||||
|
if label[0] == '-' or label[-1] == '-':
|
||||||
|
return False
|
||||||
|
if not all(ch.isalnum() or ch == '-' for ch in label):
|
||||||
|
return False
|
||||||
|
# TLD should contain letters and be at least 2 chars.
|
||||||
|
tld = labels[-1]
|
||||||
|
if len(tld) < 2 or not any(ch.isalpha() for ch in tld):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_domain_pool(domains: List[str]) -> List[str]:
|
||||||
|
seen = set()
|
||||||
|
normalized: List[str] = []
|
||||||
|
for domain in domains:
|
||||||
|
item = domain.strip().lower()
|
||||||
|
if not _is_valid_domain(item):
|
||||||
|
continue
|
||||||
|
if item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_cfproxy_domains() -> None:
|
||||||
|
if proxy_config.cfproxy_user_domain:
|
||||||
|
return
|
||||||
|
|
||||||
|
fetched = _fetch_cfproxy_domain_list()
|
||||||
|
pool = _normalize_domain_pool(fetched)
|
||||||
|
if len(pool) >= _CFPROXY_MIN_VALID_DOMAINS:
|
||||||
|
balancer.update_domains_list(pool)
|
||||||
|
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool))
|
||||||
|
return
|
||||||
|
|
||||||
|
if fetched:
|
||||||
|
log.warning(
|
||||||
|
"Ignoring fetched CF proxy domains due to low-quality payload "
|
||||||
|
"(total=%d, valid=%d, required>=%d); keeping current domain pool",
|
||||||
|
len(fetched), len(pool), _CFPROXY_MIN_VALID_DOMAINS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"CF proxy domain refresh failed or empty response; "
|
||||||
|
"keeping current domain pool",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_refresh_stop: threading.Event = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def start_cfproxy_domain_refresh() -> None:
|
||||||
|
global _refresh_stop
|
||||||
|
_refresh_stop.set()
|
||||||
|
_refresh_stop = threading.Event()
|
||||||
|
stop = _refresh_stop
|
||||||
|
|
||||||
|
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]:
|
||||||
|
dc_redirects: Dict[int, str] = {}
|
||||||
|
for entry in dc_ip_list:
|
||||||
|
if ':' not in entry:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
|
||||||
|
dc_s, ip_s = entry.split(':', 1)
|
||||||
|
try:
|
||||||
|
dc_n = int(dc_s)
|
||||||
|
_socket.inet_aton(ip_s)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
raise ValueError(f"Invalid --dc-ip {entry!r}")
|
||||||
|
dc_redirects[dc_n] = ip_s
|
||||||
|
return dc_redirects
|
||||||
256
proxy/fake_tls.py
Normal file
256
proxy/fake_tls.py
Normal 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
|
||||||
237
proxy/raw_websocket.py
Normal file
237
proxy/raw_websocket.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import asyncio
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from .config import proxy_config
|
||||||
|
|
||||||
|
|
||||||
|
_st_BB = struct.Struct('>BB')
|
||||||
|
_st_BBH = struct.Struct('>BBH')
|
||||||
|
_st_BBQ = struct.Struct('>BBQ')
|
||||||
|
_st_BB4s = struct.Struct('>BB4s')
|
||||||
|
_st_BBH4s = struct.Struct('>BBH4s')
|
||||||
|
_st_BBQ4s = struct.Struct('>BBQ4s')
|
||||||
|
_st_H = struct.Struct('>H')
|
||||||
|
_st_Q = struct.Struct('>Q')
|
||||||
|
|
||||||
|
_ssl_ctx = ssl.create_default_context()
|
||||||
|
_ssl_ctx.check_hostname = False
|
||||||
|
_ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
class WsHandshakeError(Exception):
|
||||||
|
def __init__(self, status_code: int, status_line: str,
|
||||||
|
headers: Optional[dict] = None, location: Optional[str] = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.status_line = status_line
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.location = location
|
||||||
|
super().__init__(f"HTTP {status_code}: {status_line}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_redirect(self) -> bool:
|
||||||
|
return self.status_code in (301, 302, 303, 307, 308)
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_mask(data: bytes, mask: bytes) -> bytes:
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
n = len(data)
|
||||||
|
mask_rep = (mask * (n // 4 + 1))[:n]
|
||||||
|
return (int.from_bytes(data, 'big') ^
|
||||||
|
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
|
||||||
|
|
||||||
|
|
||||||
|
def set_sock_opts(transport, buffer_size):
|
||||||
|
sock = transport.get_extra_info('socket')
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, buffer_size)
|
||||||
|
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, buffer_size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RawWebSocket:
|
||||||
|
__slots__ = ('reader', 'writer', '_closed')
|
||||||
|
|
||||||
|
OP_BINARY = 0x2
|
||||||
|
OP_CLOSE = 0x8
|
||||||
|
OP_PING = 0x9
|
||||||
|
OP_PONG = 0xA
|
||||||
|
|
||||||
|
def __init__(self, reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter):
|
||||||
|
self.reader = reader
|
||||||
|
self.writer = writer
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||||
|
path: str = '/apiws') -> 'RawWebSocket':
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||||
|
server_hostname=domain),
|
||||||
|
timeout=min(timeout, 10))
|
||||||
|
|
||||||
|
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||||
|
|
||||||
|
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
|
||||||
|
req = (
|
||||||
|
f'GET {path} HTTP/1.1\r\n'
|
||||||
|
f'Host: {domain}\r\n'
|
||||||
|
f'Upgrade: websocket\r\n'
|
||||||
|
f'Connection: Upgrade\r\n'
|
||||||
|
f'Sec-WebSocket-Key: {ws_key}\r\n'
|
||||||
|
f'Sec-WebSocket-Version: 13\r\n'
|
||||||
|
f'Sec-WebSocket-Protocol: binary\r\n'
|
||||||
|
f'\r\n'
|
||||||
|
)
|
||||||
|
writer.write(req.encode())
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
response_lines: list[str] = []
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await asyncio.wait_for(reader.readline(),
|
||||||
|
timeout=timeout)
|
||||||
|
if line in (b'\r\n', b'\n', b''):
|
||||||
|
break
|
||||||
|
response_lines.append(
|
||||||
|
line.decode('utf-8', errors='replace').strip())
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not response_lines:
|
||||||
|
writer.close()
|
||||||
|
raise WsHandshakeError(0, 'empty response')
|
||||||
|
|
||||||
|
first_line = response_lines[0]
|
||||||
|
parts = first_line.split(' ', 2)
|
||||||
|
try:
|
||||||
|
status_code = int(parts[1]) if len(parts) >= 2 else 0
|
||||||
|
except ValueError:
|
||||||
|
status_code = 0
|
||||||
|
|
||||||
|
if status_code == 101:
|
||||||
|
return RawWebSocket(reader, writer)
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for hl in response_lines[1:]:
|
||||||
|
if ':' in hl:
|
||||||
|
k, v = hl.split(':', 1)
|
||||||
|
headers[k.strip().lower()] = v.strip()
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
raise WsHandshakeError(status_code, first_line, headers,
|
||||||
|
location=headers.get('location'))
|
||||||
|
|
||||||
|
async def send(self, data: bytes):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
frame = self._build_frame(self.OP_BINARY, data, mask=True)
|
||||||
|
self.writer.write(frame)
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def send_batch(self, parts: List[bytes]):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
for part in parts:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def recv(self) -> Optional[bytes]:
|
||||||
|
while not self._closed:
|
||||||
|
opcode, payload = await self._read_frame()
|
||||||
|
|
||||||
|
if opcode == self.OP_CLOSE:
|
||||||
|
self._closed = True
|
||||||
|
try:
|
||||||
|
self.writer.write(self._build_frame(
|
||||||
|
self.OP_CLOSE,
|
||||||
|
payload[:2] if payload else b'', mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
if opcode == self.OP_PING:
|
||||||
|
try:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_PONG, payload, mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if opcode == self.OP_PONG:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if opcode in (0x1, 0x2):
|
||||||
|
return payload
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
try:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_CLOSE, b'', mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_frame(opcode: int, data: bytes,
|
||||||
|
mask: bool = False) -> bytes:
|
||||||
|
length = len(data)
|
||||||
|
fb = 0x80 | opcode
|
||||||
|
if not mask:
|
||||||
|
if length < 126:
|
||||||
|
return _st_BB.pack(fb, length) + data
|
||||||
|
if length < 65536:
|
||||||
|
return _st_BBH.pack(fb, 126, length) + data
|
||||||
|
return _st_BBQ.pack(fb, 127, length) + data
|
||||||
|
mask_key = os.urandom(4)
|
||||||
|
masked = _xor_mask(data, mask_key)
|
||||||
|
if length < 126:
|
||||||
|
return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
|
||||||
|
if length < 65536:
|
||||||
|
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
|
||||||
|
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
|
||||||
|
|
||||||
|
async def _read_frame(self) -> Tuple[int, bytes]:
|
||||||
|
hdr = await self.reader.readexactly(2)
|
||||||
|
opcode = hdr[0] & 0x0F
|
||||||
|
length = hdr[1] & 0x7F
|
||||||
|
if length == 126:
|
||||||
|
length = _st_H.unpack(await self.reader.readexactly(2))[0]
|
||||||
|
elif length == 127:
|
||||||
|
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
|
||||||
|
if hdr[1] & 0x80:
|
||||||
|
mask_key = await self.reader.readexactly(4)
|
||||||
|
payload = await self.reader.readexactly(length)
|
||||||
|
return opcode, _xor_mask(payload, mask_key)
|
||||||
|
payload = await self.reader.readexactly(length)
|
||||||
|
return opcode, payload
|
||||||
35
proxy/stats.py
Normal file
35
proxy/stats.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from .utils import human_bytes
|
||||||
|
|
||||||
|
class _Stats:
|
||||||
|
def __init__(self):
|
||||||
|
self.connections_total = 0
|
||||||
|
self.connections_active = 0
|
||||||
|
self.connections_ws = 0
|
||||||
|
self.connections_tcp_fallback = 0
|
||||||
|
self.connections_cfproxy = 0
|
||||||
|
self.connections_bad = 0
|
||||||
|
self.connections_masked = 0
|
||||||
|
self.ws_errors = 0
|
||||||
|
self.bytes_up = 0
|
||||||
|
self.bytes_down = 0
|
||||||
|
self.pool_hits = 0
|
||||||
|
self.pool_misses = 0
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
pool_total = self.pool_hits + self.pool_misses
|
||||||
|
pool_s = (f"{self.pool_hits}/{pool_total}"
|
||||||
|
if pool_total else "n/a")
|
||||||
|
return (f"total={self.connections_total} "
|
||||||
|
f"active={self.connections_active} "
|
||||||
|
f"ws={self.connections_ws} "
|
||||||
|
f"tcp_fb={self.connections_tcp_fallback} "
|
||||||
|
f"cf={self.connections_cfproxy} "
|
||||||
|
f"bad={self.connections_bad} "
|
||||||
|
f"masked={self.connections_masked} "
|
||||||
|
f"err={self.ws_errors} "
|
||||||
|
f"pool={pool_s} "
|
||||||
|
f"up={human_bytes(self.bytes_up)} "
|
||||||
|
f"down={human_bytes(self.bytes_down)}")
|
||||||
|
|
||||||
|
|
||||||
|
stats = _Stats()
|
||||||
1022
proxy/tg_ws_proxy.py
1022
proxy/tg_ws_proxy.py
File diff suppressed because it is too large
Load Diff
87
proxy/utils.py
Normal file
87
proxy/utils.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import socket as _socket
|
||||||
|
import urllib.request
|
||||||
|
import http.client
|
||||||
|
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from urllib.request import Request
|
||||||
|
|
||||||
|
|
||||||
|
ZERO_64 = b'\x00' * 64
|
||||||
|
HANDSHAKE_LEN = 64
|
||||||
|
SKIP_LEN = 8
|
||||||
|
PREKEY_LEN = 32
|
||||||
|
KEY_LEN = 32
|
||||||
|
IV_LEN = 16
|
||||||
|
PROTO_TAG_POS = 56
|
||||||
|
DC_IDX_POS = 60
|
||||||
|
|
||||||
|
PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef'
|
||||||
|
PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee'
|
||||||
|
PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd'
|
||||||
|
|
||||||
|
PROTO_ABRIDGED_INT = 0xEFEFEFEF
|
||||||
|
PROTO_INTERMEDIATE_INT = 0xEEEEEEEE
|
||||||
|
PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
|
||||||
|
|
||||||
|
RESERVED_FIRST_BYTES = {0xEF}
|
||||||
|
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
||||||
|
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
|
||||||
|
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
||||||
|
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
||||||
|
|
||||||
|
_GITHUB_IPS: Dict[str, str] = {
|
||||||
|
"release-assets.githubusercontent.com": "185.199.109.133",
|
||||||
|
"raw.githubusercontent.com": "185.199.109.133",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def human_bytes(n: int) -> str:
|
||||||
|
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||||
|
if abs(n) < 1024:
|
||||||
|
return f"{n:.1f}{unit}"
|
||||||
|
n /= 1024 # type: ignore
|
||||||
|
return f"{n:.1f}TB"
|
||||||
|
|
||||||
|
|
||||||
|
def get_link_host(host: str) -> Optional[str]:
|
||||||
|
if host == '0.0.0.0':
|
||||||
|
try:
|
||||||
|
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
|
||||||
|
_s.connect(('8.8.8.8', 80))
|
||||||
|
link_host = _s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
link_host = '127.0.0.1'
|
||||||
|
return link_host
|
||||||
|
else:
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||||
|
def https_open(self, req: Request):
|
||||||
|
host = req.host.split(":")[0]
|
||||||
|
ip = _GITHUB_IPS.get(host)
|
||||||
|
if not ip:
|
||||||
|
return super().https_open(req)
|
||||||
|
pinned = ip
|
||||||
|
|
||||||
|
class _Conn(http.client.HTTPSConnection):
|
||||||
|
def connect(self):
|
||||||
|
self.sock = _socket.create_connection(
|
||||||
|
(pinned, self.port or 443),
|
||||||
|
self.timeout,
|
||||||
|
self.source_address,
|
||||||
|
)
|
||||||
|
if self._tunnel_host:
|
||||||
|
self._tunnel()
|
||||||
|
self.sock = self._context.wrap_socket(
|
||||||
|
self.sock, server_hostname=self._tunnel_host or self.host
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.do_open(_Conn, req)
|
||||||
|
except Exception:
|
||||||
|
return super().https_open(req)
|
||||||
|
|
||||||
|
|
||||||
|
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||||
|
return urllib.request.build_opener(_PinnedHTTPSHandler())
|
||||||
@@ -71,3 +71,6 @@ packages = ["proxy", "ui", "utils"]
|
|||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
path = "proxy/__init__.py"
|
path = "proxy/__init__.py"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = ["F403", "F405"]
|
||||||
@@ -51,8 +51,11 @@ def ctk_theme_for_platform() -> CtkTheme:
|
|||||||
return CtkTheme()
|
return CtkTheme()
|
||||||
|
|
||||||
|
|
||||||
def apply_ctk_appearance(ctk: Any) -> None:
|
_APPEARANCE_MODE_MAP = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
ctk.set_appearance_mode("auto")
|
|
||||||
|
|
||||||
|
def apply_ctk_appearance(ctk: Any, mode: str = "auto") -> None:
|
||||||
|
ctk.set_appearance_mode(_APPEARANCE_MODE_MAP.get(mode, "system"))
|
||||||
ctk.set_default_color_theme("blue")
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import __version__, get_link_host, parse_dc_ip_list
|
||||||
from proxy import __version__
|
from proxy.balancer import balancer
|
||||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||||
|
|
||||||
|
|
||||||
from ui.ctk_theme import (
|
from ui.ctk_theme import (
|
||||||
FIRST_RUN_FRAME_PAD,
|
FIRST_RUN_FRAME_PAD,
|
||||||
CtkTheme,
|
CtkTheme,
|
||||||
@@ -16,6 +18,8 @@ from ui.ctk_theme import (
|
|||||||
)
|
)
|
||||||
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
_TIP_HOST = (
|
_TIP_HOST = (
|
||||||
"Адрес, на котором прокси принимает подключения.\n"
|
"Адрес, на котором прокси принимает подключения.\n"
|
||||||
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
|
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
|
||||||
@@ -54,20 +58,34 @@ _TIP_CHECK_UPDATES = "При запуске проверять наличие о
|
|||||||
_TIP_CFPROXY = (
|
_TIP_CFPROXY = (
|
||||||
"Использовать Cloudflare прокси для недоступных датацентров"
|
"Использовать Cloudflare прокси для недоступных датацентров"
|
||||||
)
|
)
|
||||||
_TIP_CFPROXY_PRIORITY = (
|
|
||||||
"Пробовать CF-прокси раньше прямого TCP-подключения"
|
|
||||||
)
|
|
||||||
_TIP_CFPROXY_DOMAIN = (
|
_TIP_CFPROXY_DOMAIN = (
|
||||||
"Домен, проксируемый через Cloudflare, для WS-подключения"
|
"Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n"
|
||||||
|
"Если не указан — выбирается автоматически из поддерживаемых доменов"
|
||||||
|
)
|
||||||
|
_TIP_CFPROXY_USER_DOMAIN_CB = (
|
||||||
|
"Указать свой домен вместо автоматического выбора"
|
||||||
|
)
|
||||||
|
_TIP_CFWORKER_DOMAIN = (
|
||||||
|
"Домен Cloudflare Worker (например, name.account.workers.dev).\n"
|
||||||
|
"Прокси передает через него подключение к Telegram DC по IP"
|
||||||
)
|
)
|
||||||
_TIP_SAVE = "Сохранить настройки"
|
_TIP_SAVE = "Сохранить настройки"
|
||||||
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
||||||
|
|
||||||
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
||||||
|
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
||||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
||||||
|
_CFWORKER_TEST_DST = {
|
||||||
|
1: '149.154.175.50',
|
||||||
|
2: '149.154.167.51',
|
||||||
|
3: '149.154.175.100',
|
||||||
|
4: '149.154.167.91',
|
||||||
|
5: '149.154.171.5',
|
||||||
|
203: '91.105.192.100',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
def _run_connectivity_test(cases: list) -> dict:
|
||||||
import base64
|
import base64
|
||||||
import ssl
|
import ssl
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
@@ -76,15 +94,14 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
|||||||
ctx.check_hostname = False
|
ctx.check_hostname = False
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
results = {}
|
results = {}
|
||||||
for dc in _CFPROXY_TEST_DCS:
|
for dc, connect_host, sni_host, req_host, path in cases:
|
||||||
host = f"kws{dc}.{domain}"
|
|
||||||
try:
|
try:
|
||||||
with _socket.create_connection((host, 443), timeout=5) as raw:
|
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
|
||||||
with ctx.wrap_socket(raw, server_hostname=host) as ssock:
|
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
|
||||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||||
req = (
|
req = (
|
||||||
f"GET /apiws HTTP/1.1\r\n"
|
f"GET {path} HTTP/1.1\r\n"
|
||||||
f"Host: {host}\r\n"
|
f"Host: {req_host}\r\n"
|
||||||
f"Upgrade: websocket\r\n"
|
f"Upgrade: websocket\r\n"
|
||||||
f"Connection: Upgrade\r\n"
|
f"Connection: Upgrade\r\n"
|
||||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||||
@@ -115,27 +132,72 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
||||||
|
cases = []
|
||||||
|
for dc in _CFPROXY_TEST_DCS:
|
||||||
|
host = f"kws{dc}.{domain}"
|
||||||
|
cases.append((dc, host, host, host, "/apiws"))
|
||||||
|
return _run_connectivity_test(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfworker_connectivity_test(domain: str) -> dict:
|
||||||
|
cases = []
|
||||||
|
for dc in _CFPROXY_TEST_DCS:
|
||||||
|
dst = _CFWORKER_TEST_DST[dc]
|
||||||
|
path = f"/apiws?dst={dst}&dc={dc}&media=0"
|
||||||
|
cases.append((dc, domain, domain, domain, path))
|
||||||
|
return _run_connectivity_test(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
||||||
|
merged: dict = {}
|
||||||
|
best_domain = None
|
||||||
|
for domain in reversed(domains):
|
||||||
|
res = _run_cfproxy_connectivity_test(domain)
|
||||||
|
if all(v is True for v in res.values()):
|
||||||
|
return domain, res
|
||||||
|
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 _show_connectivity_results(title_base: str, results: dict,
|
||||||
|
domain: str = '', label_prefix: str = 'DC',
|
||||||
|
auto_mode: bool = False,
|
||||||
|
unavailable_message: str = '') -> None:
|
||||||
import tkinter as _tk
|
import tkinter as _tk
|
||||||
from tkinter import messagebox as _mb
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
ok = [dc for dc, v in results.items() if v is True]
|
ok = [dc for dc, v in results.items() if v is True]
|
||||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
if auto_mode:
|
||||||
if len(ok) == len(_CFPROXY_TEST_DCS):
|
if domain:
|
||||||
title = "CF-прокси: всё работает"
|
title = f"{title_base}: доступен"
|
||||||
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
|
msg = f"\u2713 {title_base} работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
|
||||||
elif not ok:
|
else:
|
||||||
title = "CF-прокси: недоступен"
|
title = f"{title_base}: недоступен"
|
||||||
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
|
msg = unavailable_message
|
||||||
msg += "\n".join(f" kws{dc}: {v}" for dc, v in fail)
|
|
||||||
else:
|
else:
|
||||||
title = "CF-прокси: частично работает"
|
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||||
msg = (
|
if len(ok) == len(_CFPROXY_TEST_DCS):
|
||||||
f"Домен: {domain}\n\n"
|
title = f"{title_base}: всё работает"
|
||||||
f"\u2713 Работают: {', '.join(f'kws{dc}' for dc in ok)}\n\n"
|
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
|
||||||
f"\u2717 Недоступны:\n"
|
elif not ok:
|
||||||
+ "\n".join(f" kws{dc}: {v}" for dc, v in fail)
|
title = f"{title_base}: недоступен"
|
||||||
)
|
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
|
||||||
|
msg += "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
|
||||||
|
else:
|
||||||
|
title = f"{title_base}: частично работает"
|
||||||
|
msg = (
|
||||||
|
f"Домен: {domain}\n\n"
|
||||||
|
f"\u2713 Работают: {', '.join(f'{label_prefix}{dc}' for dc in ok)}\n\n"
|
||||||
|
f"\u2717 Недоступны:\n"
|
||||||
|
+ "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
|
||||||
|
)
|
||||||
|
|
||||||
root = _tk.Tk()
|
root = _tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
try:
|
try:
|
||||||
@@ -147,6 +209,11 @@ def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
|||||||
|
|
||||||
_INNER_W = 396
|
_INNER_W = 396
|
||||||
|
|
||||||
|
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"]
|
||||||
|
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
|
||||||
|
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
|
||||||
|
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
|
|
||||||
|
|
||||||
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
||||||
opts = dict(
|
opts = dict(
|
||||||
@@ -248,8 +315,9 @@ class TrayConfigFormWidgets:
|
|||||||
autostart_var: Optional[Any]
|
autostart_var: Optional[Any]
|
||||||
check_updates_var: Optional[Any]
|
check_updates_var: Optional[Any]
|
||||||
cfproxy_var: Optional[Any] = None
|
cfproxy_var: Optional[Any] = None
|
||||||
cfproxy_priority_var: Optional[Any] = None
|
cfproxy_user_domain_var: Optional[Any] = None
|
||||||
cfproxy_domain_var: Optional[Any] = None
|
cfproxy_worker_domain_var: Optional[Any] = None
|
||||||
|
appearance_var: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
def install_tray_config_form(
|
def install_tray_config_form(
|
||||||
@@ -265,7 +333,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")
|
||||||
@@ -273,8 +341,47 @@ def install_tray_config_form(
|
|||||||
header, text=f"v{__version__}",
|
header, text=f"v{__version__}",
|
||||||
font=(theme.ui_font_family, 12),
|
font=(theme.ui_font_family, 12),
|
||||||
text_color=theme.text_secondary, anchor="e",
|
text_color=theme.text_secondary, anchor="e",
|
||||||
|
).pack(side="right", padx=(4, 0))
|
||||||
|
appearance_var = ctk.StringVar(
|
||||||
|
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_appearance_change(choice: str) -> None:
|
||||||
|
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto")
|
||||||
|
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||||
|
cfg["appearance"] = cfg_val
|
||||||
|
|
||||||
|
ctk.CTkComboBox(
|
||||||
|
header,
|
||||||
|
values=_APPEARANCE_OPTIONS,
|
||||||
|
variable=appearance_var,
|
||||||
|
width=102,
|
||||||
|
height=28,
|
||||||
|
font=(theme.ui_font_family, 12),
|
||||||
|
text_color=theme.text_secondary,
|
||||||
|
fg_color=theme.field_bg,
|
||||||
|
border_color=theme.field_border,
|
||||||
|
button_color=theme.field_border,
|
||||||
|
button_hover_color=theme.text_secondary,
|
||||||
|
dropdown_fg_color=theme.field_bg,
|
||||||
|
dropdown_text_color=theme.text_primary,
|
||||||
|
dropdown_hover_color=theme.field_border,
|
||||||
|
corner_radius=8,
|
||||||
|
state="readonly",
|
||||||
|
command=_on_appearance_change,
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -331,7 +438,7 @@ def install_tray_config_form(
|
|||||||
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
|
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
|
||||||
|
|
||||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_row.pack(fill="x", pady=(0, 6))
|
cf_row.pack(fill="x", pady=(0, 4))
|
||||||
|
|
||||||
cfproxy_var = ctk.BooleanVar(
|
cfproxy_var = ctk.BooleanVar(
|
||||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||||
@@ -340,63 +447,171 @@ def install_tray_config_form(
|
|||||||
cf_cb.pack(side="left", padx=(0, 16))
|
cf_cb.pack(side="left", padx=(0, 16))
|
||||||
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
|
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
|
||||||
|
|
||||||
cfproxy_priority_var = ctk.BooleanVar(
|
|
||||||
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
|
|
||||||
)
|
|
||||||
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет CF-прокси", cfproxy_priority_var)
|
|
||||||
cf_prio_cb.pack(side="left")
|
|
||||||
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
|
|
||||||
|
|
||||||
cf_domain_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
|
||||||
cf_domain_row.pack(fill="x")
|
|
||||||
|
|
||||||
cf_domain_col, cfproxy_domain_var = _labeled_entry(
|
|
||||||
ctk, cf_domain_row, theme, "Домен",
|
|
||||||
cfg.get("cfproxy_domain", default_config.get("cfproxy_domain", "pclead.co.uk")),
|
|
||||||
tip=_TIP_CFPROXY_DOMAIN, width=160, pack_fill=True,
|
|
||||||
)
|
|
||||||
cf_domain_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
|
||||||
|
|
||||||
_cf_test_btn = [None]
|
_cf_test_btn = [None]
|
||||||
|
|
||||||
def _on_cf_test():
|
def _on_cf_test():
|
||||||
domain = cfproxy_domain_var.get().strip()
|
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else ""
|
||||||
if not domain:
|
|
||||||
return
|
|
||||||
btn = _cf_test_btn[0]
|
btn = _cf_test_btn[0]
|
||||||
if btn:
|
if btn:
|
||||||
btn.configure(text="...", state="disabled")
|
btn.configure(text="...", state="disabled")
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
def _worker():
|
if user_domain:
|
||||||
res = _run_cfproxy_connectivity_test(domain)
|
def _worker():
|
||||||
if btn:
|
try:
|
||||||
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
res = _run_cfproxy_connectivity_test(user_domain)
|
||||||
btn.after(0, lambda: _cfproxy_show_test_results(domain, res))
|
if btn:
|
||||||
_threading.Thread(target=_worker, daemon=True).start()
|
btn.after(
|
||||||
|
0,
|
||||||
|
lambda: _show_connectivity_results(
|
||||||
|
"CF-прокси", res, domain=user_domain, label_prefix='kws',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("CF proxy test failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
if btn:
|
||||||
|
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
||||||
|
_threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
else:
|
||||||
|
def _worker_auto():
|
||||||
|
try:
|
||||||
|
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
|
||||||
|
if btn:
|
||||||
|
btn.after(
|
||||||
|
0,
|
||||||
|
lambda: _show_connectivity_results(
|
||||||
|
"CF-прокси", res,
|
||||||
|
domain=ok_domain or '',
|
||||||
|
auto_mode=True,
|
||||||
|
unavailable_message=(
|
||||||
|
"\u2717 Ни один из автоматических CF-доменов не отвечает."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("CF proxy auto-test failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
if btn:
|
||||||
|
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
||||||
|
_threading.Thread(target=_worker_auto, daemon=True).start()
|
||||||
|
|
||||||
cf_test_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
|
|
||||||
cf_test_col.pack(side="left", anchor="s", padx=(0, 6))
|
|
||||||
ctk.CTkLabel(cf_test_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
|
||||||
_cf_test_widget = ctk.CTkButton(
|
_cf_test_widget = ctk.CTkButton(
|
||||||
cf_test_col, text="Тест", width=56, height=36,
|
cf_row, text="Тест", width=56, height=28,
|
||||||
font=(theme.ui_font_family, 13), corner_radius=10,
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=_on_cf_test,
|
command=_on_cf_test,
|
||||||
)
|
)
|
||||||
_cf_test_widget.pack()
|
_cf_test_widget.pack(side="right")
|
||||||
_cf_test_btn[0] = _cf_test_widget
|
_cf_test_btn[0] = _cf_test_widget
|
||||||
|
|
||||||
cf_help_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
|
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_help_col.pack(side="left", anchor="s")
|
cf_custom_row.pack(fill="x")
|
||||||
ctk.CTkLabel(cf_help_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
|
||||||
|
saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||||
|
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain))
|
||||||
|
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
|
||||||
|
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||||
|
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
|
||||||
|
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
cf_help_col, text="?", width=36, height=36,
|
cf_custom_row, text="?", width=28, height=32,
|
||||||
font=(theme.ui_font_family, 18), corner_radius=10,
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
||||||
).pack()
|
).pack(side="right")
|
||||||
|
|
||||||
|
cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain)
|
||||||
|
cf_domain_entry = _entry(
|
||||||
|
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||||
|
height=32, radius=8,
|
||||||
|
)
|
||||||
|
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
|
attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN)
|
||||||
|
|
||||||
|
def _sync_domain_entry(*_):
|
||||||
|
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
||||||
|
cf_domain_entry.configure(state=state)
|
||||||
|
if not cf_custom_cb_var.get():
|
||||||
|
cfproxy_user_domain_var.set("")
|
||||||
|
|
||||||
|
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||||
|
_sync_domain_entry()
|
||||||
|
|
||||||
|
cf_worker_inner = _config_section(ctk, frame, theme, "Cloudflare Worker")
|
||||||
|
|
||||||
|
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
|
cf_worker_row.pack(fill="x", pady=(0, 4))
|
||||||
|
cf_worker_lbl = _label(ctk, cf_worker_row, theme, "Cloudflare Worker домен", size=11)
|
||||||
|
cf_worker_lbl.pack(anchor="w", pady=(0, 2))
|
||||||
|
|
||||||
|
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
|
cf_worker_input.pack(fill="x")
|
||||||
|
|
||||||
|
cfproxy_worker_domain_var = ctk.StringVar(
|
||||||
|
value=cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||||
|
)
|
||||||
|
cf_worker_entry = _entry(
|
||||||
|
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
||||||
|
height=32, radius=8,
|
||||||
|
)
|
||||||
|
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
|
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], _TIP_CFWORKER_DOMAIN)
|
||||||
|
|
||||||
|
_cfworker_test_btn = [None]
|
||||||
|
|
||||||
|
def _sync_cfworker_test_button(*_):
|
||||||
|
btn = _cfworker_test_btn[0]
|
||||||
|
if btn is None:
|
||||||
|
return
|
||||||
|
enabled = bool(cfproxy_worker_domain_var.get().strip())
|
||||||
|
btn.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
|
def _on_cfworker_test():
|
||||||
|
domain = cfproxy_worker_domain_var.get().strip()
|
||||||
|
btn = _cfworker_test_btn[0]
|
||||||
|
if not domain or btn is None:
|
||||||
|
return
|
||||||
|
btn.configure(text="...", state="disabled")
|
||||||
|
import threading as _threading
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
res = _run_cfworker_connectivity_test(domain)
|
||||||
|
btn.after(
|
||||||
|
0,
|
||||||
|
lambda: _show_connectivity_results(
|
||||||
|
"CF Worker", res, domain=domain, label_prefix='DC',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("CF worker test failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
btn.after(0, lambda: btn.configure(text="Тест"))
|
||||||
|
btn.after(0, _sync_cfworker_test_button)
|
||||||
|
|
||||||
|
_threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
cf_worker_input, text="?", width=28, height=32,
|
||||||
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=lambda: webbrowser.open(_CFWORKER_HELP_URL),
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
_cfworker_test_widget = ctk.CTkButton(
|
||||||
|
cf_worker_input, text="Тест", width=56, height=32,
|
||||||
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=_on_cfworker_test,
|
||||||
|
)
|
||||||
|
_cfworker_test_widget.pack(side="right", padx=(0, 6))
|
||||||
|
_cfworker_test_btn[0] = _cfworker_test_widget
|
||||||
|
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||||
|
_sync_cfworker_test_button()
|
||||||
|
|
||||||
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
||||||
|
|
||||||
@@ -487,8 +702,9 @@ def install_tray_config_form(
|
|||||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
adv_entries=adv_entries, adv_keys=adv_keys,
|
||||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||||
cfproxy_var=cfproxy_var,
|
cfproxy_var=cfproxy_var,
|
||||||
cfproxy_priority_var=cfproxy_priority_var,
|
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||||
cfproxy_domain_var=cfproxy_domain_var,
|
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||||
|
appearance_var=appearance_var,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -536,7 +752,7 @@ def validate_config_form(
|
|||||||
if line.strip()
|
if line.strip()
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
tg_ws_proxy.parse_dc_ip_list(lines)
|
parse_dc_ip_list(lines)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return str(e)
|
return str(e)
|
||||||
|
|
||||||
@@ -567,12 +783,12 @@ def validate_config_form(
|
|||||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||||
if widgets.cfproxy_var is not None:
|
if widgets.cfproxy_var is not None:
|
||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||||
if widgets.cfproxy_priority_var is not None:
|
if widgets.cfproxy_user_domain_var is not None:
|
||||||
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
|
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip()
|
||||||
if widgets.cfproxy_domain_var is not None:
|
if widgets.cfproxy_worker_domain_var is not None:
|
||||||
domain = widgets.cfproxy_domain_var.get().strip()
|
new_cfg["cfproxy_worker_domain"] = widgets.cfproxy_worker_domain_var.get().strip()
|
||||||
if domain:
|
if widgets.appearance_var is not None:
|
||||||
new_cfg["cfproxy_domain"] = domain
|
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
|
||||||
return new_cfg
|
return new_cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -621,7 +837,7 @@ def populate_first_run_window(
|
|||||||
secret: str,
|
secret: str,
|
||||||
on_done: Callable[[bool], None],
|
on_done: Callable[[bool], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
fpx, fpy = FIRST_RUN_FRAME_PAD
|
fpx, fpy = FIRST_RUN_FRAME_PAD
|
||||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
|||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"cfproxy": True,
|
"cfproxy": True,
|
||||||
"cfproxy_priority": True,
|
"cfproxy_user_domain": "",
|
||||||
"cfproxy_domain": "pclead.co.uk",
|
"cfproxy_worker_domain": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from typing import Any, Callable, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
||||||
from proxy import __version__
|
from proxy.tg_ws_proxy import _run
|
||||||
from utils.default_config import default_tray_config
|
from utils.default_config import default_tray_config
|
||||||
|
|
||||||
log = logging.getLogger("tg-ws-tray")
|
log = logging.getLogger("tg-ws-tray")
|
||||||
@@ -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),
|
||||||
@@ -239,9 +240,9 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
|||||||
_async_stop = (loop, stop_ev)
|
_async_stop = (loop, stop_ev)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Proxy thread crashed: %s", exc)
|
log.error("Proxy thread crashed: %s", 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"
|
||||||
@@ -257,12 +258,12 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
|||||||
def apply_proxy_config(cfg: dict) -> bool:
|
def apply_proxy_config(cfg: dict) -> bool:
|
||||||
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
||||||
try:
|
try:
|
||||||
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
|
dc_redirects = parse_dc_ip_list(dc_ip_list)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
log.error("Bad config dc_ip: %s", e)
|
log.error("Bad config dc_ip: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pc = tg_ws_proxy.proxy_config
|
pc = proxy_config
|
||||||
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||||
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||||
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
@@ -270,8 +271,8 @@ def apply_proxy_config(cfg: dict) -> bool:
|
|||||||
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||||||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||||
pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
|
pc.cfproxy_user_domain = cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
|
||||||
pc.fallback_cfproxy_domain = cfg.get("cfproxy_domain", DEFAULT_CONFIG["cfproxy_domain"])
|
pc.cfproxy_worker_domain = cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
|||||||
on_error("Ошибка конфигурации DC → IP.")
|
on_error("Ошибка конфигурации DC → IP.")
|
||||||
return
|
return
|
||||||
|
|
||||||
pc = tg_ws_proxy.proxy_config
|
pc = proxy_config
|
||||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||||
_proxy_thread = threading.Thread(
|
_proxy_thread = threading.Thread(
|
||||||
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
||||||
@@ -315,7 +316,7 @@ def tg_proxy_url(cfg: dict) -> str:
|
|||||||
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||||
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -401,7 +402,7 @@ _ctk_root: Any = None
|
|||||||
_ctk_root_ready = threading.Event()
|
_ctk_root_ready = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
def ensure_ctk_thread(ctk: Any) -> bool:
|
def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
|
||||||
global _ctk_root
|
global _ctk_root
|
||||||
if ctk is None:
|
if ctk is None:
|
||||||
return False
|
return False
|
||||||
@@ -413,7 +414,7 @@ def ensure_ctk_thread(ctk: Any) -> bool:
|
|||||||
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
||||||
|
|
||||||
install_tkinter_variable_del_guard()
|
install_tkinter_variable_del_guard()
|
||||||
apply_ctk_appearance(ctk)
|
apply_ctk_appearance(ctk, mode)
|
||||||
_ctk_root = ctk.CTk()
|
_ctk_root = ctk.CTk()
|
||||||
_ctk_root.withdraw()
|
_ctk_root.withdraw()
|
||||||
_ctk_root_ready.set()
|
_ctk_root_ready.set()
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from itertools import zip_longest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request
|
||||||
|
from proxy.utils import build_github_opener
|
||||||
|
|
||||||
REPO = "Flowseal/tg-ws-proxy"
|
REPO = "Flowseal/tg-ws-proxy"
|
||||||
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||||
@@ -30,6 +31,7 @@ _state: Dict[str, Any] = {
|
|||||||
"latest": None,
|
"latest": None,
|
||||||
"html_url": None,
|
"html_url": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
|
"assets": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ def _parse_version_tuple(s: str) -> tuple:
|
|||||||
return (0,)
|
return (0,)
|
||||||
parts = []
|
parts = []
|
||||||
for seg in s.split("."):
|
for seg in s.split("."):
|
||||||
digits = "".join(c for c in seg if c.isdigit())
|
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
|
||||||
if digits:
|
if digits:
|
||||||
try:
|
try:
|
||||||
parts.append(int(digits))
|
parts.append(int(digits))
|
||||||
@@ -134,7 +136,7 @@ def fetch_latest_release(
|
|||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||||
code = getattr(resp, "status", None) or resp.getcode()
|
code = getattr(resp, "status", None) or resp.getcode()
|
||||||
new_etag = resp.headers.get("ETag")
|
new_etag = resp.headers.get("ETag")
|
||||||
raw = resp.read().decode("utf-8", errors="replace")
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
@@ -162,6 +164,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 +184,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 +204,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 +232,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
|
||||||
|
|||||||
323
windows.py
323
windows.py
@@ -2,13 +2,17 @@ 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
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import winreg
|
import winreg
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from proxy.utils import build_github_opener
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyperclip
|
import pyperclip
|
||||||
@@ -30,7 +34,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
Image = None
|
Image = None
|
||||||
|
|
||||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
from proxy import get_link_host
|
||||||
|
|
||||||
from utils.win32_theme import (
|
from utils.win32_theme import (
|
||||||
is_windows_dark_theme,
|
is_windows_dark_theme,
|
||||||
@@ -40,7 +44,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 (
|
||||||
@@ -56,6 +60,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")
|
||||||
|
|
||||||
@@ -68,7 +105,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:
|
||||||
@@ -83,6 +122,231 @@ 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:
|
||||||
|
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)
|
||||||
|
opener = build_github_opener()
|
||||||
|
with opener.open(download_url) as _resp:
|
||||||
|
with open(str(tmp_path), "wb") as _fout:
|
||||||
|
while True:
|
||||||
|
_chunk = _resp.read(65536)
|
||||||
|
if not _chunk:
|
||||||
|
break
|
||||||
|
_fout.write(_chunk)
|
||||||
|
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"
|
||||||
@@ -179,7 +443,11 @@ def _on_edit_config(icon=None, item=None) -> None:
|
|||||||
def _on_open_logs(icon=None, item=None) -> None:
|
def _on_open_logs(icon=None, item=None) -> None:
|
||||||
log.info("Opening log file: %s", LOG_FILE)
|
log.info("Opening log file: %s", LOG_FILE)
|
||||||
if LOG_FILE.exists():
|
if LOG_FILE.exists():
|
||||||
os.startfile(str(LOG_FILE))
|
try:
|
||||||
|
os.startfile(str(LOG_FILE))
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Failed to open log file: %s", exc)
|
||||||
|
_show_error(f"Не удалось открыть файл логов:\n{exc}")
|
||||||
else:
|
else:
|
||||||
_show_info("Файл логов ещё не создан.")
|
_show_info("Файл логов ещё не создан.")
|
||||||
|
|
||||||
@@ -200,7 +468,7 @@ def _on_exit(icon=None, item=None) -> None:
|
|||||||
# settings dialog
|
# settings dialog
|
||||||
|
|
||||||
def _edit_config_dialog() -> None:
|
def _edit_config_dialog() -> None:
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
_show_error("customtkinter не установлен.")
|
_show_error("customtkinter не установлен.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -228,16 +496,31 @@ def _edit_config_dialog() -> None:
|
|||||||
autostart_value=cfg.get("autostart", False),
|
autostart_value=cfg.get("autostart", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_original_appearance = ctk.get_appearance_mode()
|
||||||
|
|
||||||
def _finish() -> None:
|
def _finish() -> None:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
|
def _cancel() -> None:
|
||||||
|
ctk.set_appearance_mode(_original_appearance)
|
||||||
|
_finish()
|
||||||
|
|
||||||
def on_save() -> None:
|
def on_save() -> None:
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
|
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
|
||||||
if isinstance(merged, str):
|
if isinstance(merged, str):
|
||||||
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_ui_only_keys = {"appearance", "autostart", "check_updates"}
|
||||||
|
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
|
||||||
|
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
|
||||||
|
|
||||||
|
if not config_changed:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
save_config(merged)
|
save_config(merged)
|
||||||
_config.update(merged)
|
_config.update(merged)
|
||||||
log.info("Config saved: %s", merged)
|
log.info("Config saved: %s", merged)
|
||||||
@@ -245,6 +528,10 @@ def _edit_config_dialog() -> None:
|
|||||||
set_autostart_enabled(bool(merged.get("autostart", False)))
|
set_autostart_enabled(bool(merged.get("autostart", False)))
|
||||||
_tray_icon.menu = _build_menu()
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
|
if not proxy_changed:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
do_restart = messagebox.askyesno(
|
do_restart = messagebox.askyesno(
|
||||||
"Перезапустить?",
|
"Перезапустить?",
|
||||||
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||||
@@ -254,8 +541,8 @@ def _edit_config_dialog() -> None:
|
|||||||
if do_restart:
|
if do_restart:
|
||||||
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
|
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
|
||||||
|
|
||||||
root.protocol("WM_DELETE_WINDOW", _finish)
|
root.protocol("WM_DELETE_WINDOW", _cancel)
|
||||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
|
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
|
||||||
|
|
||||||
ctk_run_dialog(_build)
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
@@ -266,7 +553,7 @@ def _show_first_run() -> None:
|
|||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
if FIRST_RUN_MARKER.exists():
|
if FIRST_RUN_MARKER.exists():
|
||||||
return
|
return
|
||||||
if not ensure_ctk_thread(ctk):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
FIRST_RUN_MARKER.touch()
|
FIRST_RUN_MARKER.touch()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -301,7 +588,7 @@ def _build_menu():
|
|||||||
return None
|
return None
|
||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = tg_ws_proxy.get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
return pystray.Menu(
|
||||||
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
||||||
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
||||||
@@ -321,7 +608,7 @@ def run_tray() -> None:
|
|||||||
|
|
||||||
_config = load_config()
|
_config = load_config()
|
||||||
|
|
||||||
if is_windows_dark_theme:
|
if is_windows_dark_theme():
|
||||||
apply_windows_dark_theme()
|
apply_windows_dark_theme()
|
||||||
|
|
||||||
bootstrap(_config)
|
bootstrap(_config)
|
||||||
@@ -337,7 +624,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)
|
||||||
|
|
||||||
@@ -350,13 +637,27 @@ 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
|
||||||
|
|
||||||
|
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:
|
||||||
release_lock()
|
release_lock()
|
||||||
|
_release_win_mutex()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user