Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cdbca8893 | |||
| 0df9174ce2 | |||
| 7fc24fea95 | |||
| 5c40fa2574 | |||
| c8f6f8caf4 | |||
| d3d30799a1 | |||
| a0806d5a22 | |||
| eb22fadb7a | |||
| 43bca3a71b | |||
| 6b5fd72612 | |||
| 85b5e7f22a | |||
| fed772049b | |||
| 91d39a5ebe | |||
| 5cbac657dc | |||
| ee6c34e065 | |||
| ce6a456bd1 | |||
| 5bc5001c4d | |||
| 2afd80825b | |||
| 12fafbc8f4 | |||
| 5839ca2564 | |||
| e40c571009 | |||
| 96e5b4b639 | |||
| 13d2b1db6d | |||
| a29a1a8610 | |||
| 94010f1481 | |||
| 42172235c7 | |||
| b0010af130 | |||
| 784a7f659b | |||
| 21fe672963 | |||
| ed46ecce5a | |||
| 9562b11101 | |||
| dfdb993da5 | |||
| d4f8b51326 | |||
| ca431633d7 | |||
| ea4e8e790a | |||
| 05d6de269b | |||
| 1c4b103df2 | |||
| 23f0e4d426 | |||
| 49e62ca142 | |||
| 5915a0e1f3 | |||
| 7bc9e133c8 | |||
| 12d3d5e478 | |||
| 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 | |||
| df98baf961 | |||
| 34dde32033 | |||
| b8bd062663 | |||
| 8e1e3fcc45 | |||
| 097bb9d0b7 | |||
| 19fbf7494a | |||
| 4b0bc2f4d2 | |||
| 7850e1f5b4 | |||
| 63d5bafd3e | |||
| 7eaba0b29c | |||
| 6c94d3a39d | |||
| 746cd66b35 | |||
| e5d8ff7769 | |||
| 3ee82e5114 | |||
| db1308e3f5 | |||
| 6231499c39 | |||
| 826554abfb | |||
| 7f44c524c8 | |||
| 6310fcd6eb | |||
| 081b150b3d | |||
| 15001980dc | |||
| da4b521aba | |||
| 07facfe18c | |||
| 7a886dff26 | |||
| 17e37f9ca0 | |||
| 968827445f | |||
| be8d178e5c | |||
| 46426c45b0 | |||
| c4a044542c | |||
| af74009b11 | |||
| 6766db9812 | |||
| 95f99be26b | |||
| 0d11062c92 | |||
| b3a9bc6a8f | |||
| c179c299bb | |||
| bd4746004e | |||
| 77a0b837d9 | |||
| 5d28a50740 | |||
| 7a1e2f3f5b | |||
| c0183bf448 | |||
| f95b9b7da0 | |||
| f3d05f7efc | |||
| e3d4578eed | |||
| e1004e5e73 | |||
| 4304c71f89 | |||
| 3cb1929dc8 |
@@ -0,0 +1,28 @@
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
packaging/
|
||||
windows.py
|
||||
icon.ico
|
||||
*.spec
|
||||
*.spec.bak
|
||||
*.manifest
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -0,0 +1,9 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.py text diff=python
|
||||
*.spec text linguist-language=Python
|
||||
|
||||
*.toml text
|
||||
*.txt text
|
||||
|
||||
*.ico binary
|
||||
@@ -0,0 +1,11 @@
|
||||
# Default owners
|
||||
* @Flowseal
|
||||
|
||||
# Automation and repository settings
|
||||
.github/** @Flowseal
|
||||
|
||||
# Documentation
|
||||
docs/** @Flowseal
|
||||
|
||||
# Core proxy implementation
|
||||
proxy/** @Flowseal
|
||||
@@ -0,0 +1 @@
|
||||
custom: ['https://nowpayments.io/donation/flowseal']
|
||||
@@ -1,20 +1,23 @@
|
||||
name: 🐛 Проблема
|
||||
title: '[Проблема] '
|
||||
description: Сообщить о проблеме
|
||||
labels: ['type: проблема', 'status: нуждается в сортировке']
|
||||
labels: ['bug']
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
- type: input
|
||||
id: app_version
|
||||
attributes:
|
||||
label: Опишите вашу проблему
|
||||
description: Чётко опишите проблему с которой вы столкнулись
|
||||
placeholder: Описание проблемы
|
||||
label: Версия TG WS Proxy
|
||||
description: Укажите версию приложения (например, v1.2.3)
|
||||
placeholder: vX.Y.Z
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additions
|
||||
id: description
|
||||
attributes:
|
||||
label: Дополнительные детали
|
||||
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы.
|
||||
label: Опишите вашу проблему
|
||||
description: Чётко опишите проблему, с которой вы столкнулись
|
||||
placeholder: Описание проблемы
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,6 @@
|
||||
blank_issues_enabled: false
|
||||
|
||||
contact_links:
|
||||
- name: 📚 Документация
|
||||
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
|
||||
about: Ознакомьтесь с документацией перед созданием issue
|
||||
@@ -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: Любые дополнительные материалы по предложению
|
||||
@@ -0,0 +1,20 @@
|
||||
virkgj.com
|
||||
vmmzovy.com
|
||||
mkuosckvso.com
|
||||
zaewayzmplad.com
|
||||
twdmbzcm.com
|
||||
awzwsldi.com
|
||||
clngqrflngqin.com
|
||||
tjacxbqtj.com
|
||||
bxaxtxmrw.com
|
||||
dmohrsgmohcrwb.com
|
||||
vwbmtmoi.com
|
||||
khgrre.com
|
||||
ulihssf.com
|
||||
tmhqsdqmfpmk.com
|
||||
xwuwoqbm.com
|
||||
orgcnunpj.com
|
||||
zhkuldz.com
|
||||
zypoljnslxa.com
|
||||
efabnxaowuzs.com
|
||||
zaftuzsftqdq.com
|
||||
@@ -17,72 +17,199 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-windows-x64:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install ".[win10]"
|
||||
- name: Setup MSVC 14.40 toolset
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
toolset: 14.40
|
||||
|
||||
- name: Install pyinstaller
|
||||
run: pip install "pyinstaller==6.13.0"
|
||||
- name: Install dependencies
|
||||
run: pip install .
|
||||
|
||||
- name: Build PyInstaller bootloader from source
|
||||
env:
|
||||
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
|
||||
run: pyinstaller packaging/windows.spec --noconfirm
|
||||
|
||||
- name: Strip Rich PE header
|
||||
shell: bash
|
||||
run: |
|
||||
python -c "
|
||||
import struct, pathlib
|
||||
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||
data = bytearray(exe.read_bytes())
|
||||
rich = data.find(b'Rich')
|
||||
if rich == -1:
|
||||
raise SystemExit('Rich header not found')
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
raise SystemExit('DanS marker not found')
|
||||
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||
exe.write_bytes(data)
|
||||
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||
"
|
||||
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy
|
||||
path: |
|
||||
dist/TgWsProxy_windows.exe
|
||||
name: TgWsProxy-windows-x64
|
||||
path: dist/TgWsProxy_windows.exe
|
||||
|
||||
build-windows-arm64:
|
||||
runs-on: windows-11-arm
|
||||
env:
|
||||
CRYPTOGRAPHY_VERSION: "46.0.5"
|
||||
ARM64_WHEELHOUSE: wheelhouse-arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
architecture: arm64
|
||||
cache: "pip"
|
||||
|
||||
- name: Restore ARM64 cryptography wheel
|
||||
id: cryptography-wheel-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.ARM64_WHEELHOUSE }}
|
||||
key: windows-arm64-py311-cryptography-${{ env.CRYPTOGRAPHY_VERSION }}-${{ hashFiles('pyproject.toml', '.github/workflows/build.yml') }}
|
||||
|
||||
- name: Install ARM64 OpenSSL
|
||||
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
vcpkg install openssl:arm64-windows-static
|
||||
$opensslDir = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static"
|
||||
"OPENSSL_DIR=$opensslDir" >> $env:GITHUB_ENV
|
||||
"OPENSSL_STATIC=1" >> $env:GITHUB_ENV
|
||||
"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Build ARM64 cryptography wheel
|
||||
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir $env:ARM64_WHEELHOUSE
|
||||
pip wheel --no-deps --wheel-dir $env:ARM64_WHEELHOUSE "cryptography==$env:CRYPTOGRAPHY_VERSION"
|
||||
|
||||
- name: Install dependencies & pyinstaller
|
||||
run: pip install --find-links $env:ARM64_WHEELHOUSE . "pyinstaller==6.13.0"
|
||||
|
||||
- name: Build EXE with PyInstaller
|
||||
run: pyinstaller packaging/windows.spec --noconfirm
|
||||
|
||||
- name: Strip Rich PE header
|
||||
shell: bash
|
||||
run: |
|
||||
python -c "
|
||||
import struct, pathlib
|
||||
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||
data = bytearray(exe.read_bytes())
|
||||
rich = data.find(b'Rich')
|
||||
if rich == -1:
|
||||
print('Rich header not found, skipping')
|
||||
raise SystemExit(0)
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
print('DanS marker not found, skipping')
|
||||
raise SystemExit(0)
|
||||
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||
exe.write_bytes(data)
|
||||
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||
"
|
||||
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-windows-arm64
|
||||
path: dist/TgWsProxy_windows_arm64.exe
|
||||
|
||||
build-win7:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
suffix: 64bit
|
||||
- arch: x86
|
||||
suffix: 32bit
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python 3.8 (last version supporting Win7)
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.8"
|
||||
architecture: ${{ matrix.arch }}
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies & pyinstaller
|
||||
run: pip install . "pyinstaller==5.13.2"
|
||||
|
||||
- name: Install dependencies (Win7-compatible)
|
||||
run: pip install ".[win7]"
|
||||
|
||||
- name: Install pyinstaller
|
||||
run: pip install "pyinstaller==5.13.2"
|
||||
|
||||
- name: Build EXE with PyInstaller (Win7)
|
||||
- name: Build EXE with PyInstaller
|
||||
run: pyinstaller packaging/windows.spec --noconfirm
|
||||
|
||||
- name: Strip Rich PE header
|
||||
shell: bash
|
||||
run: |
|
||||
python -c "
|
||||
import struct, pathlib
|
||||
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||
data = bytearray(exe.read_bytes())
|
||||
rich = data.find(b'Rich')
|
||||
if rich == -1:
|
||||
raise SystemExit('Rich header not found')
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
raise SystemExit('DanS marker not found')
|
||||
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||
exe.write_bytes(data)
|
||||
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||
"
|
||||
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7.exe
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-win7
|
||||
path: dist/TgWsProxy_windows_7.exe
|
||||
name: TgWsProxy-win7-${{ matrix.suffix }}
|
||||
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install universal2 Python
|
||||
run: |
|
||||
@@ -142,33 +269,13 @@ jobs:
|
||||
-w wheelhouse/universal2
|
||||
|
||||
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
|
||||
python3.12 -m pip install ".[macos]"
|
||||
python3.12 -m pip install .
|
||||
python3.12 -m pip install pyinstaller==6.13.0
|
||||
|
||||
- name: Create macOS icon from ICO
|
||||
- name: Create macOS icon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3.12 - <<'PY'
|
||||
from PIL import Image
|
||||
|
||||
image = Image.open('icon.ico')
|
||||
image = image.resize((1024, 1024), Image.LANCZOS)
|
||||
image.save('icon_1024.png', 'PNG')
|
||||
PY
|
||||
|
||||
mkdir -p icon.iconset
|
||||
sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png
|
||||
sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png
|
||||
sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png
|
||||
sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png
|
||||
sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png
|
||||
sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png
|
||||
sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png
|
||||
sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png
|
||||
sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png
|
||||
sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png
|
||||
iconutil -c icns icon.iconset -o icon.icns
|
||||
rm -rf icon.iconset icon_1024.png
|
||||
python3.12 macos.py --render-app-icon icon.icns
|
||||
|
||||
- name: Build app with PyInstaller
|
||||
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
||||
@@ -176,6 +283,11 @@ jobs:
|
||||
- name: Validate universal2 app bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \
|
||||
'dist/TG WS Proxy.app/Contents/Info.plist')"
|
||||
test -n "$ICON_FILE"
|
||||
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
|
||||
|
||||
found=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if file "$file" | grep -q "Mach-O"; then
|
||||
@@ -199,25 +311,34 @@ jobs:
|
||||
- name: Create DMG
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_NAME="TG WS Proxy"
|
||||
DMG_TEMP="dist/dmg_temp"
|
||||
|
||||
rm -rf "$DMG_TEMP"
|
||||
mkdir -p "$DMG_TEMP"
|
||||
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
|
||||
ln -s /Applications "$DMG_TEMP/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "$APP_NAME" \
|
||||
-srcfolder "$DMG_TEMP" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
packaging/dmg/build_dmg.sh \
|
||||
"dist/TG WS Proxy.app" \
|
||||
"TG WS Proxy" \
|
||||
"dist/TgWsProxy_macos_universal.dmg"
|
||||
|
||||
rm -rf "$DMG_TEMP"
|
||||
- name: Validate DMG
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for DMG in "dist/TgWsProxy_macos_universal.dmg"; do
|
||||
MOUNT_DIR="$(mktemp -d)"
|
||||
DEVICE="$(hdiutil attach \
|
||||
-readonly \
|
||||
-nobrowse \
|
||||
-mountpoint "$MOUNT_DIR" \
|
||||
"$DMG" \
|
||||
| awk '/^\/dev\// { print $1; exit }')"
|
||||
|
||||
test -d "$MOUNT_DIR/TG WS Proxy.app"
|
||||
test -L "$MOUNT_DIR/Applications"
|
||||
test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications"
|
||||
test -f "$MOUNT_DIR/.background/background.tiff"
|
||||
test -f "$MOUNT_DIR/.DS_Store"
|
||||
hdiutil detach "$DEVICE"
|
||||
rmdir "$MOUNT_DIR"
|
||||
done
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-macOS
|
||||
path: dist/TgWsProxy_macos_universal.dmg
|
||||
@@ -226,7 +347,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
@@ -244,7 +365,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install ".[linux]"
|
||||
.venv/bin/pip install .
|
||||
.venv/bin/pip install "pyinstaller==6.13.0"
|
||||
|
||||
- name: Build binary with PyInstaller
|
||||
@@ -301,49 +422,102 @@ jobs:
|
||||
Maintainer: Flowseal
|
||||
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
|
||||
Description: Telegram Desktop WebSocket Bridge Proxy
|
||||
SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI.
|
||||
MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI.
|
||||
EOF
|
||||
|
||||
dpkg-deb --build --root-owner-group \
|
||||
"$PKG_ROOT" \
|
||||
"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
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-linux
|
||||
path: |
|
||||
dist/TgWsProxy_linux_amd64
|
||||
dist/TgWsProxy_linux_amd64.deb
|
||||
dist/TgWsProxy_linux_amd64.rpm
|
||||
|
||||
release:
|
||||
needs: [build, build-win7, build-macos, build-linux]
|
||||
needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.make_release == 'true' }}
|
||||
steps:
|
||||
- name: Download main build
|
||||
uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: TgWsProxy
|
||||
path: dist
|
||||
|
||||
- name: Download Win7 build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: TgWsProxy-win7
|
||||
path: dist
|
||||
|
||||
- name: Download macOS build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: TgWsProxy-macOS
|
||||
path: dist
|
||||
|
||||
- name: Download Linux build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: TgWsProxy-linux
|
||||
pattern: TgWsProxy*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -351,13 +525,21 @@ jobs:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||
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: |
|
||||
dist/TgWsProxy_windows.exe
|
||||
dist/TgWsProxy_windows_7.exe
|
||||
dist/TgWsProxy_windows_arm64.exe
|
||||
dist/TgWsProxy_windows_7_64bit.exe
|
||||
dist/TgWsProxy_windows_7_32bit.exe
|
||||
dist/TgWsProxy_macos_universal.dmg
|
||||
dist/TgWsProxy_linux_amd64
|
||||
dist/TgWsProxy_linux_amd64.deb
|
||||
dist/TgWsProxy_linux_amd64.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
|
||||
### Если проблема осталась, пожалуйста, приложите по возможности логи.
|
||||
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
|
||||
@@ -6,6 +6,8 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
*.spec.bak
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
@@ -22,9 +24,4 @@ Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# Project-specific (not for the repo)
|
||||
scan_ips.py
|
||||
scan.txt
|
||||
AyuGramDesktop-dev/
|
||||
tweb-master/
|
||||
/icon.icns
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential cargo libffi-dev libssl-dev \
|
||||
&& python -m venv "$VIRTUAL_ENV" \
|
||||
&& "$VIRTUAL_ENV/bin/pip" install --upgrade pip setuptools wheel \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH=/opt/venv/bin:$PATH \
|
||||
TG_WS_PROXY_HOST=0.0.0.0 \
|
||||
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_CF_WORKER=""
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system app \
|
||||
&& useradd --system --gid app --create-home --home-dir /home/app app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY proxy ./proxy
|
||||
COPY docs/README.md LICENSE ./
|
||||
|
||||
USER app
|
||||
|
||||
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; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
||||
CMD []
|
||||
@@ -1,197 +0,0 @@
|
||||
> [!CAUTION]
|
||||
>
|
||||
> ### Реакция антивирусов
|
||||
>
|
||||
> Windows Defender часто ошибочно помечает приложение как **Wacatac**.
|
||||
> Если вы не можете скачать из-за блокировки, то:
|
||||
>
|
||||
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)
|
||||
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
||||
>
|
||||
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
|
||||
|
||||
# TG WS Proxy
|
||||
|
||||
**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
|
||||
|
||||
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
|
||||
|
||||
## Как это работает
|
||||
|
||||
```
|
||||
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC
|
||||
```
|
||||
|
||||
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
|
||||
2. Перехватывает подключения к IP-адресам Telegram
|
||||
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
||||
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
|
||||
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Windows
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
||||
|
||||
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
|
||||
|
||||
**Меню трея:**
|
||||
|
||||
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
|
||||
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||
- **Настройки...** — GUI-редактор конфигурации
|
||||
- **Открыть логи** — открыть файл логов
|
||||
- **Выход** — остановить прокси и закрыть приложение
|
||||
|
||||
### 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`**.
|
||||
|
||||
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
|
||||
|
||||
```bash
|
||||
chmod +x TgWsProxy_linux_amd64
|
||||
./TgWsProxy_linux_amd64
|
||||
```
|
||||
|
||||
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
### Консольный proxy
|
||||
|
||||
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy
|
||||
```
|
||||
|
||||
### Windows 10+
|
||||
|
||||
```bash
|
||||
pip install -e ".[win10]"
|
||||
tg-ws-proxy-tray-win
|
||||
```
|
||||
|
||||
### Windows 7
|
||||
|
||||
```bash
|
||||
pip install -e ".[win7]"
|
||||
tg-ws-proxy-tray-win
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
pip install -e ".[macos]"
|
||||
tg-ws-proxy-tray-macos
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
pip install -e ".[linux]"
|
||||
tg-ws-proxy-tray-linux
|
||||
```
|
||||
|
||||
### Консольный режим из исходников
|
||||
|
||||
```bash
|
||||
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
```
|
||||
|
||||
**Аргументы:**
|
||||
|
||||
| Аргумент | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `--port` | `1080` | Порт SOCKS5-прокси |
|
||||
| `--host` | `127.0.0.1` | Хост SOCKS5-прокси |
|
||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
|
||||
| `-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. Добавить прокси:
|
||||
- **Тип:** SOCKS5
|
||||
- **Сервер:** `127.0.0.1`
|
||||
- **Порт:** `1080`
|
||||
- **Логин/Пароль:** оставить пустыми
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Tray-приложение хранит данные в:
|
||||
|
||||
- **Windows:** `%APPDATA%/TgWsProxy`
|
||||
- **macOS:** `~/Library/Application Support/TgWsProxy`
|
||||
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 1080,
|
||||
"dc_ip": [
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220"
|
||||
],
|
||||
"verbose": false
|
||||
}
|
||||
```
|
||||
|
||||
## Автоматическая сборка
|
||||
|
||||
Проект содержит спецификации 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)) для автоматической сборки.
|
||||
|
||||
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||
|
||||
- Windows 10+ для `TgWsProxy_windows.exe`
|
||||
- Windows 7 для `TgWsProxy_windows_7.exe`
|
||||
- Intel macOS 10.15+
|
||||
- Apple Silicon macOS 11.0+
|
||||
- Linux x86_64 (требуется AppIndicator для системного трея)
|
||||
|
||||
## Лицензия
|
||||
|
||||
[MIT License](LICENSE)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 проверяются и принимаются быстрее.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Cloudflare-прокси
|
||||
|
||||
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
|
||||
|
||||
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
|
||||
|
||||
## Зачем мне настраивать свой домен?
|
||||
|
||||
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
|
||||
|
||||
## Настройка своего домена
|
||||
|
||||
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
|
||||
|
||||
2. В `SSL/TLS` → `Overview` выставьте режим **Flexible**.
|
||||
|
||||
3. В `DNS` → `Records` добавьте следующие `A`-записи через `+ Add Record`:
|
||||
- Name=`kws1` IPv4=`149.154.175.50`
|
||||
- Name=`kws2` IPv4=`149.154.167.51`
|
||||
- Name=`kws3` IPv4=`149.154.175.100`
|
||||
- Name=`kws4` IPv4=`149.154.167.91`
|
||||
- Name=`kws5` IPv4=`149.154.171.5`
|
||||
- Name=`kws203` IPv4=`91.105.192.100`
|
||||
|
||||
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
|
||||
|
||||
5. В настройках `TgWsProxy` замените домен на свой.
|
||||
|
||||
## Благодарности
|
||||
|
||||
- Идея: https://github.com/Nekogram/WSProxy
|
||||
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
> [!TIP]
|
||||
>
|
||||
> ### 🎉 Поддержать меня
|
||||
>
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
Проект полностью бесплатен для всех.
|
||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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` |
|
||||
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
|
||||
|
||||
Пример с ручным указанием секрета:
|
||||
|
||||
```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:** из настроек или логов
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
<div align="center">
|
||||
<br />
|
||||
<p>
|
||||
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
##
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> ### [🎉 Поддержать меня](./Funding.md)
|
||||
>
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
> ### Реакция антивирусов
|
||||
>
|
||||
> Антивирусы часто ошибочно помечают приложение как вирус из-за упаковщика.
|
||||
> Если вы не можете скачать из-за блокировки антивирусом, то:
|
||||
>
|
||||
> 1) **Попробуйте скачать версию для Windows 7 (по функциональности она не отличается)**
|
||||
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
||||
>
|
||||
> Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
|
||||
|
||||
# TG WS Proxy
|
||||
|
||||
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
||||
|
||||
<picture>
|
||||
<source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
|
||||
<img src="./images/preview-white.png">
|
||||
</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+ x64)
|
||||
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||
- `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:** из настроек или логов
|
||||
|
||||
## Как это работает
|
||||
|
||||
```
|
||||
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
|
||||
```
|
||||
|
||||
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
|
||||
2. Перехватывает подключения к IP-адресам Telegram
|
||||
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
||||
4. Устанавливает WebSocket-соединение (TLS) к соответствующему DC через домены Telegram
|
||||
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### Не грузит фото/видео?
|
||||
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
|
||||
> **Если это не помогло, полностью очистите это поле**
|
||||
> Подобная проблема встречается на аккаунтах без Premium
|
||||
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md)
|
||||
|
||||
## Автоматическая сборка
|
||||
|
||||
Проект содержит спецификации 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)) для автоматической сборки.
|
||||
|
||||
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||
|
||||
- Windows 10+ x64 для `TgWsProxy_windows.exe`
|
||||
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
|
||||
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
|
||||
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
|
||||
- Intel macOS 10.15+
|
||||
- Apple Silicon macOS 11.0+
|
||||
- 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)
|
||||
@@ -0,0 +1,57 @@
|
||||
# TG WS Proxy для Windows
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||
|
||||
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||
- `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:** из настроек или логов
|
||||
|
||||
## Портативный режим
|
||||
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
|
||||
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-win
|
||||
```
|
||||
@@ -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` (автозапуск при входе в систему).
|
||||
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_PATH="${1:?Usage: build_dmg.sh <App.app> <Volume Name> <output.dmg> [assets_dir]}"
|
||||
VOL_NAME="${2:?missing volume name}"
|
||||
OUT_DMG="${3:?missing output dmg path}"
|
||||
ASSETS_DIR="${4:-$(cd "$(dirname "${BASH_SOURCE[0]}")/assets" && pwd)}"
|
||||
|
||||
WIN_W=660
|
||||
WIN_H=440
|
||||
ICON_SIZE=128
|
||||
APP_X=145
|
||||
APPS_X=515
|
||||
ICON_Y=220
|
||||
|
||||
APP_NAME="$(basename "$APP_PATH")"
|
||||
WORK="$(mktemp -d)"
|
||||
STAGE="$WORK/stage"
|
||||
RW_DMG="$WORK/rw.dmg"
|
||||
MOUNT="/Volumes/$VOL_NAME"
|
||||
DEVICE=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$DEVICE" ]; then
|
||||
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$STAGE/.background"
|
||||
cp -R "$APP_PATH" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
|
||||
tiffutil -cathidpicheck \
|
||||
"$ASSETS_DIR/background-light.png" \
|
||||
"$ASSETS_DIR/background-light@2x.png" \
|
||||
-out "$STAGE/.background/background.tiff"
|
||||
|
||||
hdiutil create \
|
||||
-volname "$VOL_NAME" \
|
||||
-srcfolder "$STAGE" \
|
||||
-fs HFS+ \
|
||||
-format UDRW \
|
||||
-ov \
|
||||
"$RW_DMG"
|
||||
|
||||
DEVICE="$(hdiutil attach \
|
||||
-readwrite \
|
||||
-noverify \
|
||||
-noautoopen \
|
||||
-mountpoint "$MOUNT" \
|
||||
"$RW_DMG" \
|
||||
| awk '/^\/dev\// { print $1; exit }')"
|
||||
test -n "$DEVICE"
|
||||
test -d "$MOUNT/$APP_NAME"
|
||||
|
||||
sleep 2
|
||||
|
||||
osascript <<APPLESCRIPT
|
||||
tell application "Finder"
|
||||
tell disk "$VOL_NAME"
|
||||
open
|
||||
set current view of container window to icon view
|
||||
set toolbar visible of container window to false
|
||||
set statusbar visible of container window to false
|
||||
set the bounds of container window to {200, 140, 200 + $WIN_W, 140 + $WIN_H}
|
||||
set theViewOptions to the icon view options of container window
|
||||
set arrangement of theViewOptions to not arranged
|
||||
set icon size of theViewOptions to $ICON_SIZE
|
||||
set text size of theViewOptions to 13
|
||||
set background picture of theViewOptions to file ".background:background.tiff"
|
||||
set position of item "$APP_NAME" of container window to {$APP_X, $ICON_Y}
|
||||
set position of item "Applications" of container window to {$APPS_X, $ICON_Y}
|
||||
close
|
||||
open
|
||||
update
|
||||
delay 2
|
||||
end tell
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
|
||||
SetFile -a C "$MOUNT" 2>/dev/null || true
|
||||
sync
|
||||
|
||||
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 \
|
||||
|| { sleep 3; hdiutil detach "$DEVICE" -force; }
|
||||
DEVICE=""
|
||||
|
||||
rm -f "$OUT_DMG"
|
||||
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$OUT_DMG"
|
||||
|
||||
echo "Created $OUT_DMG"
|
||||
@@ -12,6 +12,8 @@ block_cipher = None
|
||||
import customtkinter
|
||||
ctk_path = os.path.dirname(customtkinter.__file__)
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
|
||||
gi_hiddenimports = collect_submodules('gi')
|
||||
gi_datas = collect_data_files('gi')
|
||||
@@ -26,7 +28,7 @@ a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas,
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + gi_datas + typelib_datas,
|
||||
hiddenimports=[
|
||||
'pystray._appindicator',
|
||||
'PIL._tkinter_finder',
|
||||
@@ -46,11 +48,25 @@ a = Analysis(
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
'PIL._webp',
|
||||
'PIL._imagingtk',
|
||||
],
|
||||
noarchive=False,
|
||||
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')
|
||||
if os.path.exists(icon_path):
|
||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||
|
||||
@@ -5,11 +5,13 @@ import os
|
||||
|
||||
block_cipher = None
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
datas=[(_i18n_path, 'ui/i18n')],
|
||||
hiddenimports=[
|
||||
'rumps',
|
||||
'objc',
|
||||
@@ -25,11 +27,25 @@ a = Analysis(
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
'PIL._webp',
|
||||
'PIL._imagingtk',
|
||||
],
|
||||
noarchive=False,
|
||||
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')
|
||||
if not os.path.exists(icon_path):
|
||||
icon_path = None
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# UTF-8
|
||||
#
|
||||
# For more details about fixed file info 'ffi' see:
|
||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 8, 0, 0),
|
||||
prodvers=(1, 8, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo(
|
||||
[
|
||||
StringTable(
|
||||
u'040904B0',
|
||||
[
|
||||
StringStruct(u'CompanyName', u'Flowseal'),
|
||||
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||
StringStruct(u'FileVersion', u'1.8.0.0'),
|
||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||
StringStruct(u'ProductVersion', u'1.8.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
@@ -9,11 +9,13 @@ block_cipher = None
|
||||
import customtkinter
|
||||
ctk_path = os.path.dirname(customtkinter.__file__)
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/')],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')],
|
||||
hiddenimports=[
|
||||
'pystray._win32',
|
||||
'PIL._tkinter_finder',
|
||||
@@ -26,14 +28,29 @@ a = Analysis(
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
'PIL._webp',
|
||||
'PIL._imagingtk',
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
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')
|
||||
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
||||
if os.path.exists(icon_path):
|
||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||
|
||||
@@ -50,7 +67,7 @@ exe = EXE(
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
@@ -60,4 +77,5 @@ exe = EXE(
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=icon_path if os.path.exists(icon_path) else None,
|
||||
version=version_path if os.path.exists(version_path) else None,
|
||||
)
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
__version__ = "1.1.3"
|
||||
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from .utils import get_link_host, build_github_opener
|
||||
|
||||
__version__ = "1.8.0"
|
||||
|
||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
AES-CTR shim.
|
||||
|
||||
Prefers `cryptography` if available (desktop / Docker). Falls back to a
|
||||
ctypes wrapper over the system OpenSSL `libcrypto` for environments where
|
||||
installing `cryptography` is painful (Entware on routers, embedded boxes
|
||||
without a Rust toolchain). The public surface mimics the small subset of
|
||||
`cryptography.hazmat.primitives.ciphers` that this project actually uses:
|
||||
Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor().update(data)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import ( # noqa: F401
|
||||
Cipher, algorithms, modes,
|
||||
)
|
||||
except ImportError:
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
|
||||
def _load_libcrypto():
|
||||
name = ctypes.util.find_library("crypto")
|
||||
candidates = []
|
||||
if name:
|
||||
candidates.append(name)
|
||||
candidates += [
|
||||
"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so.1.0.0",
|
||||
"libcrypto.so", "/opt/lib/libcrypto.so",
|
||||
"/opt/lib/libcrypto.so.1.1", "/opt/lib/libcrypto.so.3",
|
||||
]
|
||||
last_err = None
|
||||
for c in candidates:
|
||||
try:
|
||||
return ctypes.CDLL(c)
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
raise RuntimeError(
|
||||
"libcrypto not found; install openssl-util or "
|
||||
"`opkg install libopenssl`. Last error: %r" % last_err
|
||||
)
|
||||
|
||||
_libcrypto = _load_libcrypto()
|
||||
|
||||
_libcrypto.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
|
||||
_libcrypto.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
|
||||
_libcrypto.EVP_aes_128_ctr.restype = ctypes.c_void_p
|
||||
_libcrypto.EVP_aes_192_ctr.restype = ctypes.c_void_p
|
||||
_libcrypto.EVP_aes_256_ctr.restype = ctypes.c_void_p
|
||||
_libcrypto.EVP_EncryptInit_ex.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
|
||||
ctypes.c_char_p, ctypes.c_char_p,
|
||||
]
|
||||
_libcrypto.EVP_EncryptInit_ex.restype = ctypes.c_int
|
||||
_libcrypto.EVP_EncryptUpdate.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.c_char_p, ctypes.c_int,
|
||||
]
|
||||
_libcrypto.EVP_EncryptUpdate.restype = ctypes.c_int
|
||||
|
||||
_EVP_BY_KEY = {
|
||||
16: _libcrypto.EVP_aes_128_ctr,
|
||||
24: _libcrypto.EVP_aes_192_ctr,
|
||||
32: _libcrypto.EVP_aes_256_ctr,
|
||||
}
|
||||
|
||||
class algorithms:
|
||||
class AES:
|
||||
__slots__ = ("key",)
|
||||
|
||||
def __init__(self, key: bytes):
|
||||
if len(key) not in _EVP_BY_KEY:
|
||||
raise ValueError("AES key must be 16/24/32 bytes")
|
||||
self.key = bytes(key)
|
||||
|
||||
class modes:
|
||||
class CTR:
|
||||
__slots__ = ("iv",)
|
||||
|
||||
def __init__(self, iv: bytes):
|
||||
if len(iv) != 16:
|
||||
raise ValueError("CTR IV must be 16 bytes")
|
||||
self.iv = bytes(iv)
|
||||
|
||||
class _CtrStream:
|
||||
__slots__ = ("_ctx",)
|
||||
|
||||
def __init__(self, key: bytes, iv: bytes):
|
||||
ctx = _libcrypto.EVP_CIPHER_CTX_new()
|
||||
if not ctx:
|
||||
raise RuntimeError("EVP_CIPHER_CTX_new failed")
|
||||
self._ctx = ctx
|
||||
evp = _EVP_BY_KEY[len(key)]()
|
||||
if _libcrypto.EVP_EncryptInit_ex(ctx, evp, None, key, iv) != 1:
|
||||
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||
self._ctx = None
|
||||
raise RuntimeError("EVP_EncryptInit_ex failed")
|
||||
|
||||
def update(self, data: bytes) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
outlen = ctypes.c_int(0)
|
||||
buf = ctypes.create_string_buffer(len(data) + 16)
|
||||
if _libcrypto.EVP_EncryptUpdate(
|
||||
self._ctx, buf, ctypes.byref(outlen), bytes(data), len(data)
|
||||
) != 1:
|
||||
raise RuntimeError("EVP_EncryptUpdate failed")
|
||||
return buf.raw[:outlen.value]
|
||||
|
||||
def __del__(self):
|
||||
ctx = getattr(self, "_ctx", None)
|
||||
if ctx:
|
||||
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||
self._ctx = None
|
||||
|
||||
class Cipher:
|
||||
__slots__ = ("_key", "_iv")
|
||||
|
||||
def __init__(self, algorithm, mode):
|
||||
if not isinstance(algorithm, algorithms.AES):
|
||||
raise TypeError("only AES is supported")
|
||||
if not isinstance(mode, modes.CTR):
|
||||
raise TypeError("only CTR mode is supported")
|
||||
self._key = algorithm.key
|
||||
self._iv = mode.iv
|
||||
|
||||
def encryptor(self) -> _CtrStream:
|
||||
return _CtrStream(self._key, self._iv)
|
||||
|
||||
# CTR is symmetric — decryption == encryption with the same keystream.
|
||||
decryptor = encryptor
|
||||
@@ -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()
|
||||
@@ -0,0 +1,424 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import random
|
||||
|
||||
from typing import 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
|
||||
from .pool import cf_worker_pool
|
||||
from ._aes import Cipher, algorithms, modes
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
_st_I_le = struct.Struct('<I')
|
||||
|
||||
ZERO_64 = b'\x00' * 64
|
||||
|
||||
|
||||
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 = []
|
||||
offset = 0
|
||||
buf_len = len(self._cipher_buf)
|
||||
# Walk the buffer with an offset instead of deleting each packet from
|
||||
# the front. Front-deletion on a bytearray shifts the remaining bytes,
|
||||
# so a chunk holding many small packets degrades to O(N^2); a single
|
||||
# trailing del keeps splitting O(N).
|
||||
while offset < buf_len:
|
||||
packet_len = self._next_packet_len(offset, buf_len - offset)
|
||||
if packet_len is None:
|
||||
break
|
||||
if packet_len <= 0:
|
||||
parts.append(bytes(self._cipher_buf[offset:]))
|
||||
offset = buf_len
|
||||
self._disabled = True
|
||||
break
|
||||
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||
offset += packet_len
|
||||
|
||||
if offset:
|
||||
del self._cipher_buf[:offset]
|
||||
del self._plain_buf[:offset]
|
||||
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, offset: int, avail: int) -> Optional[int]:
|
||||
if avail <= 0:
|
||||
return None
|
||||
if self._proto == PROTO_ABRIDGED_INT:
|
||||
return self._next_abridged_len(offset, avail)
|
||||
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||
PROTO_PADDED_INTERMEDIATE_INT):
|
||||
return self._next_intermediate_len(offset, avail)
|
||||
return 0
|
||||
|
||||
def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
|
||||
first = self._plain_buf[offset]
|
||||
if first in (0x7F, 0xFF):
|
||||
if avail < 4:
|
||||
return None
|
||||
payload_len = int.from_bytes(
|
||||
self._plain_buf[offset + 1:offset + 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 avail < packet_len:
|
||||
return None
|
||||
return packet_len
|
||||
|
||||
def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
|
||||
if avail < 4:
|
||||
return None
|
||||
payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
|
||||
if payload_len <= 0:
|
||||
return 0
|
||||
packet_len = 4 + payload_len
|
||||
if avail < 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_domains = proxy_config.cfproxy_worker_domains
|
||||
|
||||
methods: List[str] = []
|
||||
|
||||
if worker_domains 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_domains = proxy_config.cfproxy_worker_domains
|
||||
if not worker_domains:
|
||||
return False
|
||||
|
||||
random.shuffle(worker_domains)
|
||||
|
||||
for worker_domain in worker_domains:
|
||||
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
|
||||
if ws:
|
||||
log.info("[%s] DC%d%s -> CF worker pool hit for %s",
|
||||
label, dc, media_tag, fallback_dst)
|
||||
else:
|
||||
query = urlencode({
|
||||
'dst': fallback_dst,
|
||||
'dc': str(dc),
|
||||
})
|
||||
path = f'/apiws?{query}'
|
||||
|
||||
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
||||
label, dc, media_tag, worker_domain, 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 %s failed: %s",
|
||||
label, dc, media_tag, worker_domain, repr(exc))
|
||||
continue
|
||||
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
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()
|
||||
close_reason = 'normal'
|
||||
|
||||
async def tcp_to_ws():
|
||||
nonlocal up_bytes, up_packets, close_reason
|
||||
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:
|
||||
return
|
||||
except (ConnectionError, OSError) as e:
|
||||
close_reason = f"client: {type(e).__name__}"
|
||||
except Exception as e:
|
||||
close_reason = f"client: {type(e).__name__}: {e}"
|
||||
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||
|
||||
async def ws_to_tcp():
|
||||
nonlocal down_bytes, down_packets, close_reason
|
||||
try:
|
||||
while True:
|
||||
data = await ws.recv()
|
||||
if data is None:
|
||||
if close_reason == 'normal':
|
||||
close_reason = 'upstream: ws_close'
|
||||
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:
|
||||
return
|
||||
except (ConnectionError, OSError) as e:
|
||||
close_reason = f"upstream: {type(e).__name__}"
|
||||
except asyncio.IncompleteReadError:
|
||||
close_reason = 'upstream: tcp_reset'
|
||||
except Exception as e:
|
||||
close_reason = f"upstream: {type(e).__name__}: {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): "
|
||||
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||
label, dc_tag, close_reason,
|
||||
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
|
||||
@@ -0,0 +1,218 @@
|
||||
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',
|
||||
'vwbmtmoi.com',
|
||||
'khgrre.com',
|
||||
'ulihssf.com',
|
||||
'tmhqsdqmfpmk.com',
|
||||
'xwuwoqbm.com',
|
||||
'orgcnunpj.com',
|
||||
'zhkuldz.com',
|
||||
'zypoljnslxa.com',
|
||||
'efabnxaowuzs.com',
|
||||
'zaftuzsftqdq.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_domains: List[str] = field(default_factory=list)
|
||||
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||
fake_tls_domain: str = ''
|
||||
proxy_protocol: bool = False
|
||||
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
|
||||
|
||||
def coerce_domain_list(value) -> List[str]:
|
||||
if isinstance(value, str):
|
||||
items = value.replace(',', ' ').replace(';', ' ').split()
|
||||
elif isinstance(value, (list, tuple)):
|
||||
items: List[str] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, str):
|
||||
items.extend(entry.replace(',', ' ').replace(';', ' ').split())
|
||||
else:
|
||||
return []
|
||||
seen = set()
|
||||
result: List[str] = []
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
key = item.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
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_domains:
|
||||
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:
|
||||
err = ValueError(
|
||||
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
|
||||
err.entry = entry
|
||||
err.kind = "format"
|
||||
raise err
|
||||
dc_s, ip_s = entry.split(':', 1)
|
||||
try:
|
||||
dc_n = int(dc_s)
|
||||
_socket.inet_aton(ip_s)
|
||||
except (ValueError, OSError):
|
||||
err = ValueError(f"Invalid --dc-ip {entry!r}")
|
||||
err.entry = entry
|
||||
err.kind = "invalid"
|
||||
raise err
|
||||
dc_redirects[dc_n] = ip_s
|
||||
return dc_redirects
|
||||
@@ -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
|
||||
@@ -0,0 +1,216 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from collections import deque
|
||||
from urllib.parse import urlencode
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
|
||||
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||
from .stats import stats
|
||||
from .config import proxy_config
|
||||
from .utils import ws_domains, DC_DEFAULT_IPS
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
class _WsPool:
|
||||
WS_POOL_MAX_AGE = 120.0
|
||||
|
||||
def __init__(self):
|
||||
self._idle: Dict[Tuple[int, bool], deque] = {}
|
||||
self._refilling: Set[Tuple[int, bool]] = set()
|
||||
self.fronting_until: float = 0.0
|
||||
|
||||
async def get(self, dc: int, is_media: bool,
|
||||
target_ip: str, domains: List[str]
|
||||
) -> Optional[RawWebSocket]:
|
||||
key = (dc, is_media)
|
||||
now = time.monotonic()
|
||||
|
||||
bucket = self._idle.get(key)
|
||||
if bucket is None:
|
||||
bucket = deque()
|
||||
self._idle[key] = bucket
|
||||
while bucket:
|
||||
ws, created = bucket.popleft()
|
||||
age = now - created
|
||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||
or ws.writer.transport.is_closing()):
|
||||
asyncio.create_task(self._quiet_close(ws))
|
||||
continue
|
||||
stats.pool_hits += 1
|
||||
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
||||
dc, 'm' if is_media else '', age, len(bucket))
|
||||
self._schedule_refill(key, target_ip, domains)
|
||||
return ws
|
||||
|
||||
stats.pool_misses += 1
|
||||
self._schedule_refill(key, target_ip, domains)
|
||||
return None
|
||||
|
||||
def _schedule_refill(self, key, target_ip, domains):
|
||||
if key in self._refilling:
|
||||
return
|
||||
self._refilling.add(key)
|
||||
asyncio.create_task(self._refill(key, target_ip, domains))
|
||||
|
||||
async def _refill(self, key, target_ip, domains):
|
||||
dc, is_media = key
|
||||
try:
|
||||
bucket = self._idle.setdefault(key, deque())
|
||||
needed = proxy_config.pool_size - len(bucket)
|
||||
if needed <= 0:
|
||||
return
|
||||
tasks = [asyncio.create_task(
|
||||
self._connect_one(target_ip, domains, time.monotonic() < self.fronting_until))
|
||||
for _ in range(needed)]
|
||||
for t in tasks:
|
||||
try:
|
||||
ws = await t
|
||||
if ws:
|
||||
bucket.append((ws, time.monotonic()))
|
||||
except Exception:
|
||||
pass
|
||||
log.debug("WS pool refilled DC%d%s: %d ready",
|
||||
dc, 'm' if is_media else '', len(bucket))
|
||||
finally:
|
||||
self._refilling.discard(key)
|
||||
|
||||
@staticmethod
|
||||
async def _connect_one(target_ip, domains, fronting_active) -> Optional[RawWebSocket]:
|
||||
for domain in domains:
|
||||
try:
|
||||
return await RawWebSocket.connect(
|
||||
target_ip, domain, timeout=8, sni="sprinthost.ru" if fronting_active else None)
|
||||
except WsHandshakeError as exc:
|
||||
if exc.is_redirect:
|
||||
continue
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _quiet_close(ws):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def warmup(self):
|
||||
for dc, target_ip in proxy_config.dc_redirects.items():
|
||||
if target_ip is None:
|
||||
continue
|
||||
for is_media in (False, True):
|
||||
domains = ws_domains(dc, is_media)
|
||||
self._schedule_refill((dc, is_media), target_ip, domains)
|
||||
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
|
||||
|
||||
def reset(self):
|
||||
self._idle.clear()
|
||||
self._refilling.clear()
|
||||
self.fronting_until = 0.0
|
||||
|
||||
|
||||
class _CfWorkerPool:
|
||||
WS_POOL_MAX_AGE = 100.0
|
||||
|
||||
def __init__(self):
|
||||
self._idle: Dict[Tuple[int, str], deque] = {}
|
||||
self._refilling: Set[Tuple[int, str]] = set()
|
||||
|
||||
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
|
||||
now = time.monotonic()
|
||||
key = (dc, worker_domain)
|
||||
|
||||
bucket = self._idle.get(key)
|
||||
if bucket is None:
|
||||
bucket = deque()
|
||||
self._idle[key] = bucket
|
||||
while bucket:
|
||||
ws, created = bucket.popleft()
|
||||
age = now - created
|
||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||
or ws.writer.transport.is_closing()):
|
||||
asyncio.create_task(self._quiet_close(ws))
|
||||
continue
|
||||
stats.cf_pool_hits += 1
|
||||
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
|
||||
dc, age, len(bucket))
|
||||
self._schedule_refill(key, fallback_dst)
|
||||
return ws
|
||||
|
||||
stats.cf_pool_misses += 1
|
||||
self._schedule_refill(key, fallback_dst)
|
||||
return None
|
||||
|
||||
def _schedule_refill(self, key, fallback_dst):
|
||||
if key in self._refilling:
|
||||
return
|
||||
self._refilling.add(key)
|
||||
asyncio.create_task(self._refill(key, fallback_dst))
|
||||
|
||||
async def _refill(self, key, fallback_dst):
|
||||
dc, worker_domain = key
|
||||
try:
|
||||
bucket = self._idle.setdefault(key, deque())
|
||||
needed = proxy_config.pool_size - len(bucket)
|
||||
if needed <= 0:
|
||||
return
|
||||
tasks = [asyncio.create_task(
|
||||
self._connect_one(worker_domain, fallback_dst, dc))
|
||||
for _ in range(needed)]
|
||||
for t in tasks:
|
||||
try:
|
||||
ws = await t
|
||||
if ws:
|
||||
bucket.append((ws, time.monotonic()))
|
||||
except Exception:
|
||||
pass
|
||||
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||
dc, len(bucket))
|
||||
finally:
|
||||
self._refilling.discard(key)
|
||||
|
||||
@staticmethod
|
||||
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
|
||||
query = urlencode({
|
||||
'dst': fallback_dst,
|
||||
'dc': str(dc),
|
||||
})
|
||||
path = f'/apiws?{query}'
|
||||
try:
|
||||
return await RawWebSocket.connect(
|
||||
worker_domain, worker_domain, timeout=8, path=path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _quiet_close(ws):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def warmup(self):
|
||||
cf_fallbacks = {
|
||||
dc: ip for dc, ip in DC_DEFAULT_IPS.items()
|
||||
if dc not in proxy_config.dc_redirects
|
||||
}
|
||||
|
||||
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
|
||||
return
|
||||
|
||||
for worker_domain in proxy_config.cfproxy_worker_domains:
|
||||
for dc, fallback_dst in cf_fallbacks.items():
|
||||
self._schedule_refill((dc, worker_domain), fallback_dst)
|
||||
|
||||
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||
|
||||
def reset(self):
|
||||
self._idle.clear()
|
||||
self._refilling.clear()
|
||||
|
||||
|
||||
ws_pool = _WsPool()
|
||||
cf_worker_pool = _CfWorkerPool()
|
||||
@@ -0,0 +1,267 @@
|
||||
import os
|
||||
import ssl
|
||||
import logging
|
||||
import base64
|
||||
import struct
|
||||
import asyncio
|
||||
import socket as _socket
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from .config import proxy_config
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
|
||||
_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', *,
|
||||
sni: Optional[str] = None) -> 'RawWebSocket':
|
||||
if sni is None:
|
||||
sni = domain
|
||||
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||
server_hostname=sni),
|
||||
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
|
||||
code, reason = self._parse_close(payload)
|
||||
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
|
||||
code, reason)
|
||||
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
|
||||
|
||||
_WS_CLOSE_REASONS = {
|
||||
1000: 'normal', 1001: 'going_away', 1002: 'protocol_error',
|
||||
1003: 'unsupported_data', 1006: 'abnormal', 1007: 'bad_data',
|
||||
1008: 'policy_violation', 1009: 'too_big', 1010: 'missing_extension',
|
||||
1011: 'internal_error',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _parse_close(cls, payload: Optional[bytes]) -> Tuple[Optional[int], str]:
|
||||
if not payload or len(payload) < 2:
|
||||
return None, ''
|
||||
try:
|
||||
code = int.from_bytes(payload[:2], 'big')
|
||||
text = payload[2:].decode('utf-8', errors='replace')
|
||||
name = cls._WS_CLOSE_REASONS.get(code)
|
||||
return code, f"{text} ({name})" if name else text
|
||||
except Exception:
|
||||
return None, ''
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,43 @@
|
||||
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_fronting = 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
|
||||
self.cf_pool_hits = 0
|
||||
self.cf_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")
|
||||
cf_pool_total = self.cf_pool_hits + self.cf_pool_misses
|
||||
cf_pool_s = (f"{self.cf_pool_hits}/{cf_pool_total}"
|
||||
if cf_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"front={self.connections_fronting} "
|
||||
f"bad={self.connections_bad} "
|
||||
f"masked={self.connections_masked} "
|
||||
f"err={self.ws_errors} "
|
||||
f"pool={pool_s} "
|
||||
f"cf_pool={cf_pool_s} "
|
||||
f"up={human_bytes(self.bytes_up)} "
|
||||
f"down={human_bytes(self.bytes_down)}")
|
||||
|
||||
|
||||
stats = _Stats()
|
||||
@@ -0,0 +1,104 @@
|
||||
import socket as _socket
|
||||
import urllib.request
|
||||
import http.client
|
||||
|
||||
from typing import Optional, Dict, List
|
||||
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",
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
def ws_domains(dc: int, is_media) -> List[str]:
|
||||
if dc == 203:
|
||||
dc = 2
|
||||
if is_media is None or is_media:
|
||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
||||
|
||||
|
||||
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())
|
||||
@@ -7,7 +7,7 @@ name = "tg-ws-proxy"
|
||||
dynamic=["version"]
|
||||
|
||||
description = "Telegram Desktop WebSocket Bridge Proxy"
|
||||
readme = "README.md"
|
||||
readme = "docs/README.md"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
license = { name = "MIT", file = "LICENSE" }
|
||||
@@ -18,59 +18,37 @@ authors = [
|
||||
|
||||
keywords = [
|
||||
"telegram",
|
||||
"tdesktop",
|
||||
"proxy",
|
||||
"websocket"
|
||||
"bypass",
|
||||
"websocket",
|
||||
"mtproto",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Environment :: MacOS X :: Cocoa",
|
||||
"Environment :: Win32 (MS Windows)",
|
||||
"Environment :: X11 Applications :: GTK",
|
||||
"Intended Audience :: Customer Service",
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: System :: Networking :: Firewalls",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"pyperclip==1.9.0",
|
||||
|
||||
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
|
||||
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
|
||||
"Pillow==10.4.0; platform_system == 'Windows' and python_version < '3.9'",
|
||||
|
||||
"psutil==7.0.0; platform_system != 'Windows' or python_version >= '3.9'",
|
||||
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
|
||||
]
|
||||
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
|
||||
|
||||
[project.optional-dependencies]
|
||||
win7 = [
|
||||
"customtkinter==5.2.2",
|
||||
"Pillow==10.4.0",
|
||||
"psutil==5.9.8",
|
||||
"pystray==0.19.5",
|
||||
"pyperclip==1.9.0",
|
||||
]
|
||||
|
||||
win10 = [
|
||||
"customtkinter==5.2.2",
|
||||
"Pillow==12.1.1",
|
||||
"psutil==7.0.0",
|
||||
"pystray==0.19.5",
|
||||
"pyperclip==1.9.0",
|
||||
]
|
||||
|
||||
macos = [
|
||||
"Pillow==12.1.0",
|
||||
"psutil==7.0.0",
|
||||
"pyperclip==1.9.0",
|
||||
"rumps==0.4.0",
|
||||
]
|
||||
|
||||
linux = [
|
||||
"customtkinter==5.2.2",
|
||||
"Pillow==12.1.1",
|
||||
"psutil==7.0.0",
|
||||
"pystray==0.19.5",
|
||||
"pyperclip==1.9.0",
|
||||
"customtkinter==5.2.2; platform_system != 'Darwin'",
|
||||
"pystray==0.19.5; platform_system != 'Darwin'",
|
||||
"rumps==0.4.0; platform_system == 'Darwin'",
|
||||
"Pillow==12.1.0; platform_system == 'Darwin'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -84,7 +62,7 @@ Source = "https://github.com/Flowseal/tg-ws-proxy"
|
||||
Issues = "https://github.com/Flowseal/tg-ws-proxy/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["proxy"]
|
||||
packages = ["proxy", "ui", "utils"]
|
||||
|
||||
[tool.hatch.build.force-include]
|
||||
"windows.py" = "windows.py"
|
||||
@@ -93,3 +71,6 @@ packages = ["proxy"]
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "proxy/__init__.py"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["F403", "F405"]
|
||||
@@ -1,246 +0,0 @@
|
||||
"""
|
||||
Stress-test: сравнение OLD vs NEW реализаций горячих функций прокси.
|
||||
|
||||
Тестируются:
|
||||
1. _build_frame — сборка WS-фрейма (masked binary)
|
||||
2. _build_frame — сборка WS-фрейма (unmasked)
|
||||
3. _socks5_reply — генерация SOCKS5-ответа
|
||||
4. _dc_from_init XOR-часть (bytes(a^b for …) vs int.from_bytes)
|
||||
5. mask key generation (os.urandom vs PRNG)
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
|
||||
# ── Размеры данных, типичные для Telegram ──────────────────────────
|
||||
SMALL = 64 # init-пакет / ack
|
||||
MEDIUM = 1024 # текстовое сообщение
|
||||
LARGE = 65536 # фото / голосовое
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# XOR mask (не менялся — для полноты)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
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')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# _build_frame
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def build_frame_old(opcode: int, data: bytes, mask: bool = False) -> bytes:
|
||||
"""Старая: bytearray + append/extend + os.urandom."""
|
||||
header = bytearray()
|
||||
header.append(0x80 | opcode)
|
||||
length = len(data)
|
||||
mask_bit = 0x80 if mask else 0x00
|
||||
|
||||
if length < 126:
|
||||
header.append(mask_bit | length)
|
||||
elif length < 65536:
|
||||
header.append(mask_bit | 126)
|
||||
header.extend(struct.pack('>H', length))
|
||||
else:
|
||||
header.append(mask_bit | 127)
|
||||
header.extend(struct.pack('>Q', length))
|
||||
|
||||
if mask:
|
||||
mask_key = os.urandom(4)
|
||||
header.extend(mask_key)
|
||||
return bytes(header) + xor_mask(data, mask_key)
|
||||
return bytes(header) + data
|
||||
|
||||
|
||||
# ── Новая: pre-compiled struct + PRNG ──────────────────────────────
|
||||
_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')
|
||||
|
||||
_mask_rng = random.Random(int.from_bytes(os.urandom(16), 'big'))
|
||||
_mask_pack = struct.Struct('>I').pack
|
||||
|
||||
def _random_mask_key() -> bytes:
|
||||
return _mask_pack(_mask_rng.getrandbits(32))
|
||||
|
||||
def build_frame_new(opcode: int, data: bytes, mask: bool = False) -> bytes:
|
||||
"""Новая: struct.pack + PRNG mask."""
|
||||
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 = _random_mask_key()
|
||||
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
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# _socks5_reply
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def socks5_reply_old(status):
|
||||
return bytes([0x05, status, 0x00, 0x01]) + b'\x00' * 6
|
||||
|
||||
_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
for s in (0x00, 0x05, 0x07, 0x08)}
|
||||
|
||||
def socks5_reply_new(status):
|
||||
return _SOCKS5_REPLIES[status]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# dc_from_init XOR (8 байт keystream ^ data)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def dc_xor_old(data8: bytes, ks8: bytes) -> bytes:
|
||||
"""Старая: генераторное выражение."""
|
||||
return bytes(a ^ b for a, b in zip(data8, ks8))
|
||||
|
||||
def dc_xor_new(data8: bytes, ks8: bytes) -> bytes:
|
||||
"""Новая: int.from_bytes."""
|
||||
return (int.from_bytes(data8, 'big') ^ int.from_bytes(ks8, 'big')).to_bytes(8, 'big')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# mask key: os.urandom(4) vs PRNG
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def mask_key_old() -> bytes:
|
||||
return os.urandom(4)
|
||||
|
||||
def mask_key_new() -> bytes:
|
||||
return _random_mask_key()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Бенчмарк
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def bench(func, args_list: list, iters: int) -> float:
|
||||
gc.collect()
|
||||
for i in range(min(100, iters)):
|
||||
func(*args_list[i % len(args_list)])
|
||||
start = time.perf_counter()
|
||||
for i in range(iters):
|
||||
func(*args_list[i % len(args_list)])
|
||||
elapsed = time.perf_counter() - start
|
||||
return elapsed / iters * 1_000_000 # мкс
|
||||
|
||||
|
||||
def compare(name: str, old_fn, new_fn, args_list: list, iters: int):
|
||||
t_old = bench(old_fn, args_list, iters)
|
||||
t_new = bench(new_fn, args_list, iters)
|
||||
speedup = t_old / t_new if t_new > 0 else float('inf')
|
||||
marker = '✅' if speedup >= 1.0 else '⚠️'
|
||||
print(f" {name:.<42s} OLD {t_old:8.3f} мкс | NEW {t_new:8.3f} мкс | {speedup:5.2f}x {marker}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
print("=" * 74)
|
||||
print(" Stress Test: OLD vs NEW (горячие функции tg_ws_proxy)")
|
||||
print("=" * 74)
|
||||
|
||||
N = 500_000
|
||||
|
||||
# # ── 1. _build_frame masked ────────────────────────────────────
|
||||
# print(f"\n── _build_frame masked ({N:,} итераций) ──")
|
||||
# for size, label in [(SMALL, "64B"), (MEDIUM, "1KB"), (LARGE, "64KB")]:
|
||||
# data_list = [(0x2, os.urandom(size), True) for _ in range(1000)]
|
||||
# compare(f"build_frame masked {label}",
|
||||
# build_frame_old, build_frame_new, data_list, N)
|
||||
|
||||
# # ── 2. _build_frame unmasked ──────────────────────────────────
|
||||
# print(f"\n── _build_frame unmasked ({N:,} итераций) ──")
|
||||
# for size, label in [(SMALL, "64B"), (MEDIUM, "1KB"), (LARGE, "64KB")]:
|
||||
# data_list = [(0x2, os.urandom(size), False) for _ in range(1000)]
|
||||
# compare(f"build_frame unmasked {label}",
|
||||
# build_frame_old, build_frame_new, data_list, N)
|
||||
|
||||
# # ── 3. mask key generation ────────────────────────────────────
|
||||
# print(f"\n── mask key: os.urandom(4) vs PRNG ({N:,} итераций) ──")
|
||||
# compare("mask_key", mask_key_old, mask_key_new, [()] * 100, N)
|
||||
|
||||
# # ── 4. _socks5_reply ─────────────────────────────────────────
|
||||
N2 = 2_000_000
|
||||
# print(f"\n── _socks5_reply ({N2:,} итераций) ──")
|
||||
# compare("socks5_reply", socks5_reply_old, socks5_reply_new,
|
||||
# [(s,) for s in (0x00, 0x05, 0x07, 0x08)], N2)
|
||||
|
||||
# # ── 5. dc_from_init XOR (8 bytes) ────────────────────────────
|
||||
# print(f"\n── dc_xor 8B: generator vs int.from_bytes ({N2:,} итераций) ──")
|
||||
# compare("dc_xor_8B", dc_xor_old, dc_xor_new,
|
||||
# [(os.urandom(8), os.urandom(8)) for _ in range(1000)], N2)
|
||||
|
||||
# ── 6. _read_frame struct.unpack vs pre-compiled ─────────────
|
||||
print(f"\n── struct unpack read-path ({N2:,} итераций) ──")
|
||||
_st_H_pre = struct.Struct('>H')
|
||||
_st_Q_pre = struct.Struct('>Q')
|
||||
h_bufs = [(os.urandom(2),) for _ in range(1000)]
|
||||
q_bufs = [(os.urandom(8),) for _ in range(1000)]
|
||||
compare("unpack >H",
|
||||
lambda b: struct.unpack('>H', b),
|
||||
lambda b: _st_H_pre.unpack(b),
|
||||
h_bufs, N2)
|
||||
compare("unpack >Q",
|
||||
lambda b: struct.unpack('>Q', b),
|
||||
lambda b: _st_Q_pre.unpack(b),
|
||||
q_bufs, N2)
|
||||
|
||||
# ── 7. dc_from_init: 2x unpack vs 1x merged ─────────────────
|
||||
print(f"\n── dc_from_init unpack: 2 calls vs 1 merged ({N2:,} итераций) ──")
|
||||
_st_Ih = struct.Struct('<Ih')
|
||||
plains = [(os.urandom(8),) for _ in range(1000)]
|
||||
def dc_unpack_old(p):
|
||||
return struct.unpack('<I', p[0:4])[0], struct.unpack('<h', p[4:6])[0]
|
||||
def dc_unpack_new(p):
|
||||
return _st_Ih.unpack(p[:6])
|
||||
compare("dc_unpack", dc_unpack_old, dc_unpack_new, plains, N2)
|
||||
|
||||
# ── 8. bytes() copy vs direct slice ──────────────────────────
|
||||
print(f"\n── bytes(slice) vs direct slice ({N2:,} итераций) ──")
|
||||
raw_data = [(os.urandom(64),) for _ in range(1000)]
|
||||
def slice_copy(d):
|
||||
return bytes(d[8:40]), bytes(d[40:56])
|
||||
def slice_direct(d):
|
||||
return d[8:40], d[40:56]
|
||||
compare("bytes(slice) vs slice", slice_copy, slice_direct, raw_data, N2)
|
||||
|
||||
# ── 9. MsgSplitter unpack_from: struct vs pre-compiled ───────
|
||||
print(f"\n── unpack_from <I: struct vs pre-compiled ({N2:,} итераций) ──")
|
||||
_st_I_le = struct.Struct('<I')
|
||||
splitter_bufs = [(os.urandom(64), 1) for _ in range(1000)]
|
||||
compare("unpack_from <I",
|
||||
lambda b, p: struct.unpack_from('<I', b, p),
|
||||
lambda b, p: _st_I_le.unpack_from(b, p),
|
||||
splitter_bufs, N2)
|
||||
|
||||
print("\n" + "=" * 74)
|
||||
print(" Готово!")
|
||||
print("=" * 74)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки.
|
||||
Ядро прокси — пакет `proxy`.
|
||||
"""
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tkinter
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
_tk_variable_del_guard_installed = False
|
||||
|
||||
|
||||
def install_tkinter_variable_del_guard() -> None:
|
||||
global _tk_variable_del_guard_installed
|
||||
if _tk_variable_del_guard_installed:
|
||||
return
|
||||
_orig = tkinter.Variable.__del__
|
||||
|
||||
def _safe_variable_del(self: Any, _orig: Any = _orig) -> None:
|
||||
try:
|
||||
_orig(self)
|
||||
except (RuntimeError, tkinter.TclError):
|
||||
pass
|
||||
|
||||
tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment]
|
||||
_tk_variable_del_guard_installed = True
|
||||
|
||||
CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560)
|
||||
CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14)
|
||||
FIRST_RUN_SIZE: Tuple[int, int] = (520, 480)
|
||||
FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtkTheme:
|
||||
tg_blue: tuple = ("#3390ec", "#3390ec")
|
||||
tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4")
|
||||
|
||||
bg: tuple = ("#ffffff", "#1e1e1e")
|
||||
field_bg: tuple = ("#f0f2f5", "#2b2b2b")
|
||||
field_border: tuple = ("#d6d9dc", "#3a3a3a")
|
||||
|
||||
text_primary: tuple = ("#000000", "#ffffff")
|
||||
text_secondary: tuple = ("#707579", "#aaaaaa")
|
||||
|
||||
ui_font_family: str = "Sans"
|
||||
mono_font_family: str = "Monospace"
|
||||
|
||||
|
||||
def ctk_theme_for_platform() -> CtkTheme:
|
||||
if sys.platform == "win32":
|
||||
return CtkTheme(ui_font_family="Segoe UI", mono_font_family="Consolas")
|
||||
return CtkTheme()
|
||||
|
||||
|
||||
_APPEARANCE_MODE_MAP = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
||||
sw = root.winfo_screenwidth()
|
||||
sh = root.winfo_screenheight()
|
||||
root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}")
|
||||
|
||||
|
||||
def create_ctk_toplevel(
|
||||
ctk: Any,
|
||||
*,
|
||||
title: str,
|
||||
width: int,
|
||||
height: int,
|
||||
theme: CtkTheme,
|
||||
topmost: bool = True,
|
||||
after_create: Optional[Callable[[Any], None]] = None,
|
||||
) -> Any:
|
||||
root = ctk.CTkToplevel()
|
||||
root.title(title)
|
||||
root.resizable(False, False)
|
||||
center_ctk_geometry(root, width, height)
|
||||
root.configure(fg_color=theme.bg)
|
||||
if topmost:
|
||||
root.attributes("-topmost", True)
|
||||
root.lift()
|
||||
root.focus_force()
|
||||
if after_create:
|
||||
_after_id = root.after(300, lambda: after_create(root))
|
||||
_orig_destroy = root.destroy
|
||||
|
||||
def _safe_destroy():
|
||||
try:
|
||||
root.after_cancel(_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
_orig_destroy()
|
||||
|
||||
root.destroy = _safe_destroy
|
||||
return root
|
||||
|
||||
|
||||
def main_content_frame(
|
||||
ctk: Any,
|
||||
root: Any,
|
||||
theme: CtkTheme,
|
||||
*,
|
||||
padx: int,
|
||||
pady: int,
|
||||
) -> Any:
|
||||
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
|
||||
frame.pack(fill="both", expand=True, padx=padx, pady=pady)
|
||||
return frame
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
||||
class CtkTooltip:
|
||||
def __init__(
|
||||
self,
|
||||
widget: Any,
|
||||
text: str,
|
||||
*,
|
||||
delay_ms: int = 450,
|
||||
wraplength: int = 320,
|
||||
) -> None:
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.delay_ms = delay_ms
|
||||
self.wraplength = wraplength
|
||||
self._after_id: Optional[str] = None
|
||||
self._tip: Optional[tk.Toplevel] = None
|
||||
widget.bind("<Enter>", self._schedule, add="+")
|
||||
widget.bind("<Leave>", self._hide, add="+")
|
||||
widget.bind("<Button>", self._hide, add="+")
|
||||
widget.bind("<Destroy>", self._on_destroy, add="+")
|
||||
|
||||
def _schedule(self, _event: Any = None) -> None:
|
||||
if self.widget is None:
|
||||
return
|
||||
self._cancel_after()
|
||||
self._after_id = self.widget.after(self.delay_ms, self._show)
|
||||
|
||||
def _cancel_after(self) -> None:
|
||||
if self._after_id is not None:
|
||||
try:
|
||||
self.widget.after_cancel(self._after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._after_id = None
|
||||
|
||||
def _show(self) -> None:
|
||||
self._after_id = None
|
||||
if self._tip is not None:
|
||||
return
|
||||
try:
|
||||
if not self.widget.winfo_exists():
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
tw = tk.Toplevel(self.widget.winfo_toplevel())
|
||||
tw.wm_overrideredirect(True)
|
||||
try:
|
||||
tw.wm_attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
tw.configure(bg="#2b2b2b")
|
||||
lbl = tk.Label(
|
||||
tw,
|
||||
text=self.text,
|
||||
justify="left",
|
||||
wraplength=self.wraplength,
|
||||
background="#2b2b2b",
|
||||
foreground="#f0f0f0",
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
padx=10,
|
||||
pady=8,
|
||||
font=("Segoe UI", 10) if _is_windows() else None,
|
||||
)
|
||||
lbl.pack()
|
||||
x = self.widget.winfo_rootx() + 12
|
||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||
tw.wm_geometry(f"+{x}+{y}")
|
||||
self._tip = tw
|
||||
|
||||
def _hide(self, _event: Any = None) -> None:
|
||||
self._cancel_after()
|
||||
if self._tip is not None:
|
||||
try:
|
||||
self._tip.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._tip = None
|
||||
|
||||
def _on_destroy(self, _event: Any = None) -> None:
|
||||
self._hide()
|
||||
self.widget = None
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
import sys
|
||||
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def attach_ctk_tooltip(
|
||||
widget: Any,
|
||||
text: str,
|
||||
*,
|
||||
delay_ms: int = 450,
|
||||
wraplength: int = 320,
|
||||
) -> None:
|
||||
CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength)
|
||||
|
||||
|
||||
def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None:
|
||||
for w in widgets:
|
||||
attach_ctk_tooltip(w, text, **kwargs)
|
||||
@@ -0,0 +1,999 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
|
||||
from proxy.balancer import balancer
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||
|
||||
|
||||
from ui.ctk_theme import (
|
||||
FIRST_RUN_FRAME_PAD,
|
||||
CtkTheme,
|
||||
main_content_frame,
|
||||
)
|
||||
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
||||
from ui.i18n import (
|
||||
label_from_language,
|
||||
language_from_label,
|
||||
language_option_labels,
|
||||
set_language,
|
||||
t,
|
||||
)
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
_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]
|
||||
_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_connectivity_test(cases: list) -> dict:
|
||||
import base64
|
||||
import ssl
|
||||
import socket as _socket
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
results = {}
|
||||
for dc, connect_host, sni_host, req_host, path in cases:
|
||||
try:
|
||||
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
|
||||
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {req_host}\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"
|
||||
).encode()
|
||||
ssock.sendall(req)
|
||||
ssock.settimeout(5)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = ssock.recv(512)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
|
||||
if "101" in first:
|
||||
results[dc] = True
|
||||
else:
|
||||
results[dc] = first or t("connectivity.no_response")
|
||||
ssock.close()
|
||||
raw.close()
|
||||
except _socket.timeout:
|
||||
results[dc] = t("connectivity.timeout")
|
||||
except OSError as exc:
|
||||
msg = str(exc)
|
||||
results[dc] = msg[:60] if len(msg) > 60 else msg
|
||||
return results
|
||||
|
||||
|
||||
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_multi_test(domains: list) -> dict:
|
||||
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
|
||||
|
||||
|
||||
def _run_cfworker_multi_test(domains: list) -> dict:
|
||||
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
|
||||
|
||||
|
||||
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
|
||||
from tkinter import messagebox as _mb
|
||||
|
||||
ok = [dc for dc, v in results.items() if v is True]
|
||||
total = len(_CFPROXY_TEST_DCS)
|
||||
if auto_mode:
|
||||
if domain:
|
||||
title = t("connectivity.available", title=title_base)
|
||||
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
|
||||
else:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
msg = unavailable_message
|
||||
else:
|
||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||
if len(ok) == total:
|
||||
title = t("connectivity.all_ok", title=title_base)
|
||||
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
|
||||
elif not ok:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
errors = "\n".join(
|
||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
||||
for dc, v in fail
|
||||
)
|
||||
msg = t("connectivity.none_ok", domain=domain, errors=errors)
|
||||
else:
|
||||
title = t("connectivity.partial", title=title_base)
|
||||
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
||||
fail_list = "\n".join(
|
||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
||||
for dc, v in fail
|
||||
)
|
||||
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||
|
||||
root = _tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
_mb.showinfo(title, msg, parent=root)
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
|
||||
label_prefix: str = 'DC') -> None:
|
||||
import tkinter as _tk
|
||||
from tkinter import messagebox as _mb
|
||||
|
||||
total = len(_CFPROXY_TEST_DCS)
|
||||
all_ok = True
|
||||
any_ok = False
|
||||
blocks = []
|
||||
for domain, results in per_domain.items():
|
||||
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 len(ok) == total:
|
||||
any_ok = True
|
||||
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
|
||||
elif not ok:
|
||||
all_ok = False
|
||||
blocks.append(t("connectivity.multi_fail", domain=domain))
|
||||
else:
|
||||
all_ok = False
|
||||
any_ok = True
|
||||
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
||||
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
|
||||
blocks.append(
|
||||
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||
)
|
||||
|
||||
if all_ok:
|
||||
title = t("connectivity.all_ok", title=title_base)
|
||||
elif any_ok:
|
||||
title = t("connectivity.partial", title=title_base)
|
||||
else:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
msg = "\n\n".join(blocks)
|
||||
|
||||
root = _tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
_mb.showinfo(title, msg, parent=root)
|
||||
root.destroy()
|
||||
|
||||
_INNER_W = 396
|
||||
|
||||
_APPEARANCE_KEYS = ("auto", "light", "dark")
|
||||
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||
|
||||
|
||||
def _appearance_options() -> List[str]:
|
||||
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
|
||||
|
||||
|
||||
def _appearance_from_cfg(value: str) -> str:
|
||||
if value in _APPEARANCE_KEYS:
|
||||
return t(f"appearance.{value}")
|
||||
return t("appearance.auto")
|
||||
|
||||
|
||||
def _appearance_to_cfg(label: str) -> str:
|
||||
for key in _APPEARANCE_KEYS:
|
||||
if t(f"appearance.{key}") == label:
|
||||
return key
|
||||
return "auto"
|
||||
|
||||
|
||||
def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
|
||||
combo.configure(values=[label for _, label in language_option_labels()])
|
||||
var.set(label_from_language(cfg_value))
|
||||
|
||||
|
||||
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
||||
opts = dict(
|
||||
font=(theme.ui_font_family, 13), corner_radius=radius,
|
||||
fg_color=theme.bg, border_color=theme.field_border,
|
||||
border_width=1, text_color=theme.text_primary,
|
||||
)
|
||||
if var is not None:
|
||||
opts["textvariable"] = var
|
||||
if width:
|
||||
opts["width"] = width
|
||||
opts["height"] = height
|
||||
opts.update(kw)
|
||||
return ctk.CTkEntry(parent, **opts)
|
||||
|
||||
|
||||
def _checkbox(ctk, parent, theme, text, variable):
|
||||
return ctk.CTkCheckBox(
|
||||
parent, text=text, variable=variable,
|
||||
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
corner_radius=6, border_width=2, border_color=theme.field_border,
|
||||
)
|
||||
|
||||
|
||||
def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
|
||||
weight = "bold" if bold else "normal"
|
||||
return ctk.CTkLabel(
|
||||
parent, text=text,
|
||||
font=(theme.ui_font_family, size, weight),
|
||||
text_color=theme.text_secondary if secondary else theme.text_primary,
|
||||
anchor="w", **kw,
|
||||
)
|
||||
|
||||
|
||||
def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
|
||||
col = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
lbl = _label(ctk, col, theme, label_text)
|
||||
lbl.pack(anchor="w", pady=(0, 2))
|
||||
var = ctk.StringVar(value=str(value))
|
||||
ent = _entry(ctk, col, theme, var=var, width=width)
|
||||
if pack_fill:
|
||||
ent.pack(fill="x")
|
||||
else:
|
||||
ent.pack(anchor="w")
|
||||
if tip:
|
||||
attach_tooltip_to_widgets([lbl, ent, col], tip)
|
||||
return col, var
|
||||
|
||||
|
||||
def tray_settings_scroll_and_footer(
|
||||
ctk: Any,
|
||||
content_parent: Any,
|
||||
theme: CtkTheme,
|
||||
) -> Tuple[Any, Any]:
|
||||
footer = ctk.CTkFrame(content_parent, fg_color=theme.bg)
|
||||
footer.pack(side="bottom", fill="x")
|
||||
scroll = ctk.CTkScrollableFrame(
|
||||
content_parent,
|
||||
fg_color=theme.bg,
|
||||
corner_radius=0,
|
||||
scrollbar_button_color=theme.field_border,
|
||||
scrollbar_button_hover_color=theme.text_secondary,
|
||||
)
|
||||
scroll.pack(fill="both", expand=True)
|
||||
try:
|
||||
scroll._parent_canvas.configure(yscrollincrement=4)
|
||||
except Exception:
|
||||
pass
|
||||
return scroll, footer
|
||||
|
||||
|
||||
def _config_section(
|
||||
ctk: Any,
|
||||
parent: Any,
|
||||
theme: CtkTheme,
|
||||
title: str,
|
||||
*,
|
||||
bottom_spacer: int = 6,
|
||||
) -> Any:
|
||||
wrap = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
wrap.pack(fill="x", pady=(0, bottom_spacer))
|
||||
_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
|
||||
card = ctk.CTkFrame(
|
||||
wrap, fg_color=theme.field_bg, corner_radius=10,
|
||||
border_width=1, border_color=theme.field_border,
|
||||
)
|
||||
card.pack(fill="x")
|
||||
inner = ctk.CTkFrame(card, fg_color="transparent")
|
||||
inner.pack(fill="x", padx=10, pady=8)
|
||||
return inner
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrayConfigFormWidgets:
|
||||
host_var: Any
|
||||
port_var: Any
|
||||
secret_var: Any
|
||||
dc_textbox: Any
|
||||
verbose_var: Any
|
||||
adv_entries: List[Any]
|
||||
adv_keys: Tuple[str, ...]
|
||||
autostart_var: Optional[Any]
|
||||
check_updates_var: Optional[Any]
|
||||
cfproxy_var: Optional[Any] = None
|
||||
cfproxy_user_domain_var: Optional[Any] = None
|
||||
cfproxy_worker_domain_var: Optional[Any] = None
|
||||
appearance_var: Optional[Any] = None
|
||||
language_var: Optional[Any] = None
|
||||
|
||||
|
||||
def install_tray_config_form(
|
||||
ctk: Any,
|
||||
frame: Any,
|
||||
theme: CtkTheme,
|
||||
cfg: dict,
|
||||
default_config: dict,
|
||||
*,
|
||||
show_autostart: bool = False,
|
||||
autostart_value: bool = False,
|
||||
on_language_change: Optional[Callable[[], None]] = None,
|
||||
) -> TrayConfigFormWidgets:
|
||||
lang_cfg = cfg.get("language", default_config["language"])
|
||||
set_language(lang_cfg)
|
||||
|
||||
header = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
header.pack(fill="x", pady=(0, 2))
|
||||
ctk.CTkLabel(
|
||||
header, text=t("settings.title"),
|
||||
font=(theme.ui_font_family, 17, "bold"),
|
||||
text_color=theme.text_primary, anchor="w",
|
||||
).pack(side="left")
|
||||
ctk.CTkLabel(
|
||||
header, text=f"v{__version__}",
|
||||
font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_secondary, anchor="e",
|
||||
).pack(side="right", padx=(4, 0))
|
||||
|
||||
appearance_var = ctk.StringVar(
|
||||
value=_appearance_from_cfg(cfg.get("appearance", "auto"))
|
||||
)
|
||||
|
||||
def _on_appearance_change(choice: str) -> None:
|
||||
cfg_val = _appearance_to_cfg(choice)
|
||||
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||
cfg["appearance"] = cfg_val
|
||||
|
||||
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))
|
||||
|
||||
ui_inner = _config_section(ctk, frame, theme, t("section.interface"))
|
||||
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
|
||||
ui_row.pack(fill="x")
|
||||
|
||||
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||
|
||||
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
|
||||
|
||||
language_var = ctk.StringVar(value=label_from_language(lang_cfg))
|
||||
_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
|
||||
anchor="w", pady=(0, 2)
|
||||
)
|
||||
language_combo = ctk.CTkComboBox(
|
||||
lang_col,
|
||||
values=[label for _, label in language_option_labels()],
|
||||
variable=language_var,
|
||||
height=32,
|
||||
font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_primary,
|
||||
fg_color=theme.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",
|
||||
)
|
||||
language_combo.pack(fill="x")
|
||||
_sync_language_combobox(language_combo, language_var, lang_cfg)
|
||||
|
||||
_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
|
||||
anchor="w", pady=(0, 2)
|
||||
)
|
||||
theme_combo = ctk.CTkComboBox(
|
||||
theme_col,
|
||||
values=_appearance_options(),
|
||||
variable=appearance_var,
|
||||
height=32,
|
||||
font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_primary,
|
||||
fg_color=theme.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,
|
||||
)
|
||||
theme_combo.pack(fill="x")
|
||||
|
||||
conn = _config_section(ctk, frame, theme, t("section.mtproto"))
|
||||
|
||||
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||
host_row.pack(fill="x")
|
||||
|
||||
host_col, host_var = _labeled_entry(
|
||||
ctk, host_row, theme, t("label.host"),
|
||||
cfg.get("host", default_config["host"]),
|
||||
tip=t("tip.host"), width=160, pack_fill=True,
|
||||
)
|
||||
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
port_col, port_var = _labeled_entry(
|
||||
ctk, host_row, theme, t("label.port"),
|
||||
cfg.get("port", default_config["port"]),
|
||||
tip=t("tip.port"), width=100,
|
||||
)
|
||||
port_col.pack(side="left")
|
||||
|
||||
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||
secret_row.pack(fill="x")
|
||||
|
||||
secret_col, secret_var = _labeled_entry(
|
||||
ctk, secret_row, theme, t("label.secret"),
|
||||
cfg.get("secret", default_config["secret"]),
|
||||
tip=t("tip.secret"), width=160, pack_fill=True,
|
||||
)
|
||||
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
|
||||
regen_col.pack(side="left", anchor="s")
|
||||
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
||||
ctk.CTkButton(
|
||||
regen_col, text="↺", width=36, height=36,
|
||||
font=(theme.ui_font_family, 18), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: secret_var.set(os.urandom(16).hex()),
|
||||
).pack()
|
||||
|
||||
dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
|
||||
dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
|
||||
dc_lbl.pack(anchor="w", pady=(0, 4))
|
||||
dc_textbox = ctk.CTkTextbox(
|
||||
dc_inner, width=_INNER_W, height=88,
|
||||
font=(theme.mono_font_family, 12), corner_radius=10,
|
||||
fg_color=theme.bg, border_color=theme.field_border,
|
||||
border_width=1, text_color=theme.text_primary,
|
||||
)
|
||||
dc_textbox.pack(fill="x")
|
||||
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
|
||||
|
||||
cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
|
||||
|
||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_row.pack(fill="x", pady=(0, 4))
|
||||
|
||||
cfproxy_var = ctk.BooleanVar(
|
||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||
)
|
||||
cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
|
||||
cf_cb.pack(side="left", padx=(0, 16))
|
||||
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
|
||||
|
||||
_cf_test_btn = [None]
|
||||
|
||||
def _on_cf_test():
|
||||
user_domains = (
|
||||
coerce_domain_list(cfproxy_user_domain_var.get())
|
||||
if cf_custom_cb_var.get() else []
|
||||
)
|
||||
btn = _cf_test_btn[0]
|
||||
if btn:
|
||||
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||
import threading as _threading
|
||||
if user_domains:
|
||||
def _worker():
|
||||
try:
|
||||
per = _run_cfproxy_multi_test(user_domains)
|
||||
if btn:
|
||||
btn.after(
|
||||
0,
|
||||
lambda: _show_multi_connectivity_results(
|
||||
t("connectivity.cfproxy_title"), per, 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=t("button.test"), 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(
|
||||
t("connectivity.cfproxy_title"), res,
|
||||
domain=ok_domain or '',
|
||||
auto_mode=True,
|
||||
unavailable_message=t("connectivity.cf_auto_fail"),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("CF proxy auto-test failed: %s", exc)
|
||||
finally:
|
||||
if btn:
|
||||
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
||||
_threading.Thread(target=_worker_auto, daemon=True).start()
|
||||
|
||||
_cf_test_widget = ctk.CTkButton(
|
||||
cf_row, text=t("button.test"), width=56, height=28,
|
||||
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_cf_test,
|
||||
)
|
||||
_cf_test_widget.pack(side="right")
|
||||
_cf_test_btn[0] = _cf_test_widget
|
||||
|
||||
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_custom_row.pack(fill="x")
|
||||
|
||||
saved_user_domains = coerce_domain_list(
|
||||
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||
)
|
||||
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
|
||||
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
|
||||
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
||||
|
||||
ctk.CTkButton(
|
||||
cf_custom_row, 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(_CFPROXY_HELP_URL),
|
||||
).pack(side="right")
|
||||
|
||||
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
||||
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, t("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, t("section.cfworker"))
|
||||
|
||||
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, t("label.cfworker_domains"), 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=", ".join(coerce_domain_list(
|
||||
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], t("tip.cfworker_domain"))
|
||||
|
||||
_cfworker_test_btn = [None]
|
||||
|
||||
def _sync_cfworker_test_button(*_):
|
||||
btn = _cfworker_test_btn[0]
|
||||
if btn is None:
|
||||
return
|
||||
enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||
btn.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
def _on_cfworker_test():
|
||||
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||
btn = _cfworker_test_btn[0]
|
||||
if not domains or btn is None:
|
||||
return
|
||||
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||
import threading as _threading
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
per = _run_cfworker_multi_test(domains)
|
||||
btn.after(
|
||||
0,
|
||||
lambda: _show_multi_connectivity_results(
|
||||
t("connectivity.cfworker_title"), per, label_prefix='DC',
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("CF worker test failed: %s", exc)
|
||||
finally:
|
||||
btn.after(0, lambda: btn.configure(text=t("button.test")))
|
||||
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=t("button.test"), 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, t("section.logs"))
|
||||
|
||||
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
||||
verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
|
||||
verbose_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
|
||||
|
||||
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
||||
adv_frame.pack(fill="x")
|
||||
|
||||
adv_rows = [
|
||||
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
|
||||
(t("label.pool_size"), "pool_size", t("tip.pool")),
|
||||
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
|
||||
]
|
||||
for label_text, key, tip in adv_rows:
|
||||
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
||||
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
|
||||
adv_l = _label(ctk, col, theme, label_text, size=11)
|
||||
adv_l.pack(anchor="w", pady=(0, 2))
|
||||
adv_e = _entry(
|
||||
ctk, col, theme, width=_INNER_W, height=32, radius=8,
|
||||
textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
|
||||
)
|
||||
adv_e.pack(fill="x")
|
||||
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
|
||||
|
||||
adv_entries = list(adv_frame.winfo_children())
|
||||
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
|
||||
|
||||
upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
|
||||
st = get_status()
|
||||
check_updates_var = ctk.BooleanVar(
|
||||
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
||||
)
|
||||
upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
|
||||
upd_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
||||
|
||||
if st.get("error"):
|
||||
upd_status = t("updates.status_error")
|
||||
elif not st.get("checked"):
|
||||
upd_status = t("updates.status_pending")
|
||||
elif st.get("has_update") and st.get("latest"):
|
||||
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||
elif st.get("ahead_of_release") and st.get("latest"):
|
||||
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||
else:
|
||||
upd_status = t("updates.status_latest")
|
||||
|
||||
_label(ctk, upd_inner, theme, upd_status, size=11,
|
||||
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
||||
|
||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ctk.CTkButton(
|
||||
upd_inner, text=t("button.open_release"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=lambda u=rel_url: webbrowser.open(u),
|
||||
).pack(anchor="w")
|
||||
|
||||
autostart_var = None
|
||||
if show_autostart:
|
||||
sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
|
||||
autostart_var = ctk.BooleanVar(value=autostart_value)
|
||||
as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
|
||||
as_cb.pack(anchor="w", pady=(0, 4))
|
||||
as_hint = _label(
|
||||
ctk, sys_inner, theme,
|
||||
t("label.autostart_hint"),
|
||||
size=11, justify="left", wraplength=_INNER_W,
|
||||
)
|
||||
as_hint.pack(anchor="w")
|
||||
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
|
||||
|
||||
return TrayConfigFormWidgets(
|
||||
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
||||
dc_textbox=dc_textbox, verbose_var=verbose_var,
|
||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||
cfproxy_var=cfproxy_var,
|
||||
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||
appearance_var=appearance_var,
|
||||
language_var=language_var,
|
||||
)
|
||||
|
||||
|
||||
def merge_adv_from_form(
|
||||
widgets: TrayConfigFormWidgets,
|
||||
base: Dict[str, Any],
|
||||
default_config: dict,
|
||||
) -> None:
|
||||
for i, key in enumerate(widgets.adv_keys):
|
||||
col_frame = widgets.adv_entries[i]
|
||||
entry = col_frame.winfo_children()[1]
|
||||
try:
|
||||
val = float(entry.get().strip())
|
||||
if key in ("buf_kb", "pool_size"):
|
||||
val = int(val)
|
||||
base[key] = val
|
||||
except ValueError:
|
||||
base[key] = default_config[key]
|
||||
|
||||
|
||||
def _dc_validation_message(error: ValueError) -> str:
|
||||
exc_entry = getattr(error, "entry", None)
|
||||
if exc_entry is None:
|
||||
return str(error)
|
||||
kind = getattr(error, "kind", "invalid")
|
||||
if kind == "format":
|
||||
return t("validation.dc_format", entry=exc_entry)
|
||||
return t("validation.dc_invalid", entry=exc_entry)
|
||||
|
||||
|
||||
def validate_config_form(
|
||||
widgets: TrayConfigFormWidgets,
|
||||
default_config: dict,
|
||||
*,
|
||||
include_autostart: bool,
|
||||
) -> Union[dict, str]:
|
||||
import socket as _sock
|
||||
|
||||
host_val = widgets.host_var.get().strip()
|
||||
try:
|
||||
_sock.inet_aton(host_val)
|
||||
except OSError:
|
||||
return t("validation.bad_host")
|
||||
|
||||
try:
|
||||
port_val = int(widgets.port_var.get().strip())
|
||||
if not (1 <= port_val <= 65535):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return t("validation.bad_port")
|
||||
|
||||
lines = [
|
||||
line.strip()
|
||||
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
try:
|
||||
parse_dc_ip_list(lines)
|
||||
except ValueError as e:
|
||||
return _dc_validation_message(e)
|
||||
|
||||
secret_val = widgets.secret_var.get().strip()
|
||||
if len(secret_val) != 32:
|
||||
return t("validation.bad_secret_len")
|
||||
try:
|
||||
bytes.fromhex(secret_val)
|
||||
except ValueError:
|
||||
return t("validation.bad_secret_hex")
|
||||
|
||||
new_cfg: Dict[str, Any] = {
|
||||
"host": host_val,
|
||||
"port": port_val,
|
||||
"secret": secret_val,
|
||||
"dc_ip": lines,
|
||||
"verbose": widgets.verbose_var.get(),
|
||||
}
|
||||
if include_autostart:
|
||||
new_cfg["autostart"] = (
|
||||
widgets.autostart_var.get()
|
||||
if widgets.autostart_var is not None
|
||||
else False
|
||||
)
|
||||
|
||||
merge_adv_from_form(widgets, new_cfg, default_config)
|
||||
if widgets.check_updates_var is not None:
|
||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||
if widgets.cfproxy_var is not None:
|
||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||
if widgets.cfproxy_user_domain_var is not None:
|
||||
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
|
||||
if widgets.cfproxy_worker_domain_var is not None:
|
||||
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
|
||||
if widgets.appearance_var is not None:
|
||||
new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
|
||||
if widgets.language_var is not None:
|
||||
new_cfg["language"] = language_from_label(widgets.language_var.get()).value
|
||||
return new_cfg
|
||||
|
||||
|
||||
def install_tray_config_buttons(
|
||||
ctk: Any,
|
||||
frame: Any,
|
||||
theme: CtkTheme,
|
||||
*,
|
||||
on_save: Callable[[], None],
|
||||
on_cancel: Callable[[], None],
|
||||
) -> None:
|
||||
ctk.CTkFrame(
|
||||
frame,
|
||||
fg_color=theme.field_border,
|
||||
height=1,
|
||||
corner_radius=0,
|
||||
).pack(fill="x", pady=(4, 10))
|
||||
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", pady=(0, 0))
|
||||
save_btn = ctk.CTkButton(
|
||||
btn_frame, text=t("button.save"), height=38,
|
||||
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff",
|
||||
command=on_save)
|
||||
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||
attach_ctk_tooltip(save_btn, t("tip.save"))
|
||||
cancel_btn = ctk.CTkButton(
|
||||
btn_frame, text=t("button.cancel"), height=38,
|
||||
font=(theme.ui_font_family, 14), corner_radius=10,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=on_cancel)
|
||||
cancel_btn.pack(side="right", fill="x", expand=True)
|
||||
attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
|
||||
|
||||
|
||||
def populate_first_run_window(
|
||||
ctk: Any,
|
||||
root: Any,
|
||||
theme: CtkTheme,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
secret: str,
|
||||
on_done: Callable[[bool], None],
|
||||
) -> None:
|
||||
link_host = get_link_host(host)
|
||||
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||
fpx, fpy = FIRST_RUN_FRAME_PAD
|
||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||
|
||||
title_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
title_frame.pack(anchor="w", pady=(0, 16), fill="x")
|
||||
|
||||
accent_bar = ctk.CTkFrame(title_frame, fg_color=theme.tg_blue,
|
||||
width=4, height=32, corner_radius=2)
|
||||
accent_bar.pack(side="left", padx=(0, 12))
|
||||
|
||||
ctk.CTkLabel(title_frame, text=t("first_run.title"),
|
||||
font=(theme.ui_font_family, 17, "bold"),
|
||||
text_color=theme.text_primary).pack(side="left")
|
||||
|
||||
sections = [
|
||||
(t("first_run.how_to"), True),
|
||||
(t("first_run.auto"), True),
|
||||
(t("first_run.auto_hint"), False),
|
||||
(t("first_run.auto_link", url=tg_url), False),
|
||||
("\n" + t("first_run.manual"), True),
|
||||
(t("first_run.manual_path"), False),
|
||||
(t("first_run.manual_mtproto", host=link_host, port=port), False),
|
||||
(t("first_run.manual_secret", secret=secret), False),
|
||||
]
|
||||
|
||||
textbox = ctk.CTkTextbox(
|
||||
frame,
|
||||
font=(theme.ui_font_family, 13),
|
||||
fg_color=theme.bg,
|
||||
border_width=0,
|
||||
text_color=theme.text_primary,
|
||||
activate_scrollbars=False,
|
||||
wrap="word",
|
||||
height=275,
|
||||
)
|
||||
textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold"))
|
||||
textbox._textbox.configure(spacing1=1, spacing3=1)
|
||||
for text, bold in sections:
|
||||
if text.startswith("\n"):
|
||||
textbox.insert("end", "\n")
|
||||
text = text[1:]
|
||||
if bold:
|
||||
textbox.insert("end", text + "\n", "bold")
|
||||
else:
|
||||
textbox.insert("end", text + "\n")
|
||||
textbox.configure(state="disabled")
|
||||
textbox.pack(anchor="w", fill="x")
|
||||
|
||||
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
|
||||
|
||||
ctk.CTkFrame(frame, fg_color=theme.field_border, height=1,
|
||||
corner_radius=0).pack(fill="x", pady=(0, 12))
|
||||
|
||||
auto_var = ctk.BooleanVar(value=True)
|
||||
_checkbox(ctk, frame, theme, t("first_run.open_now"),
|
||||
auto_var).pack(anchor="w", pady=(0, 16))
|
||||
|
||||
def on_ok():
|
||||
on_done(auto_var.get())
|
||||
|
||||
ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
|
||||
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff",
|
||||
command=on_ok).pack(pady=(0, 0))
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", on_ok)
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Union
|
||||
|
||||
LocaleInput = Union[str, "LocaleEnum"]
|
||||
|
||||
|
||||
class LocaleEnum(str, Enum):
|
||||
russian = "ru"
|
||||
english = "en"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: LocaleInput) -> LocaleEnum:
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError:
|
||||
return _DEFAULT_LOCALE
|
||||
|
||||
|
||||
_LOCALES_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_LOCALE = LocaleEnum.english
|
||||
|
||||
_translations: Dict[str, str] = {}
|
||||
_current_lang: LocaleEnum = _DEFAULT_LOCALE
|
||||
_config_value: LocaleEnum = _DEFAULT_LOCALE
|
||||
|
||||
_LANGUAGE_TO_LABEL: Dict[LocaleEnum, str] = {}
|
||||
_LABEL_TO_LANGUAGE: Dict[str, LocaleEnum] = {}
|
||||
|
||||
|
||||
def _locale_json_files() -> Tuple[str, ...]:
|
||||
return tuple(
|
||||
p.stem for p in sorted(_LOCALES_DIR.glob("*.json")) if p.stem != "manifest"
|
||||
)
|
||||
|
||||
|
||||
def supported_languages() -> Tuple[str, ...]:
|
||||
"""Locale codes that have a JSON catalog on disk (e.g. ru, en)."""
|
||||
return _locale_json_files()
|
||||
|
||||
|
||||
def content_locales() -> Tuple[LocaleEnum, ...]:
|
||||
return tuple(
|
||||
LocaleEnum(stem)
|
||||
for stem in _locale_json_files()
|
||||
if stem in LocaleEnum._value2member_map_
|
||||
)
|
||||
|
||||
|
||||
def detect_system_language() -> LocaleEnum:
|
||||
"""Pick the best locale from available catalogs, else Russian."""
|
||||
available = content_locales()
|
||||
if not available:
|
||||
return _DEFAULT_LOCALE
|
||||
|
||||
for getter in (locale.getlocale, locale.getdefaultlocale):
|
||||
try:
|
||||
loc = getter()
|
||||
if loc and loc[0]:
|
||||
code = loc[0].split("_")[0].lower()
|
||||
try:
|
||||
candidate = LocaleEnum(code)
|
||||
if candidate in available:
|
||||
return candidate
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
for env_key in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
val = os.environ.get(env_key, "")
|
||||
if val:
|
||||
code = val.split(".")[0].split("_")[0].lower()
|
||||
try:
|
||||
candidate = LocaleEnum(code)
|
||||
if candidate in available:
|
||||
return candidate
|
||||
except ValueError:
|
||||
pass
|
||||
return _DEFAULT_LOCALE
|
||||
|
||||
|
||||
def resolve_language(config_value: LocaleInput) -> LocaleEnum:
|
||||
loc = LocaleEnum.parse(config_value)
|
||||
if loc.value in supported_languages():
|
||||
return loc
|
||||
return _DEFAULT_LOCALE
|
||||
|
||||
|
||||
def _load_locale(lang: LocaleEnum) -> Dict[str, str]:
|
||||
path = _LOCALES_DIR / f"{lang.value}.json"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def set_language(config_value: LocaleInput) -> LocaleEnum:
|
||||
global _translations, _current_lang, _config_value
|
||||
_config_value = LocaleEnum.parse(config_value)
|
||||
_current_lang = resolve_language(_config_value)
|
||||
_translations = _load_locale(_current_lang)
|
||||
refresh_language_option_maps()
|
||||
return _current_lang
|
||||
|
||||
|
||||
def get_language() -> LocaleEnum:
|
||||
return _current_lang
|
||||
|
||||
|
||||
def get_config_language() -> LocaleEnum:
|
||||
return _config_value
|
||||
|
||||
|
||||
def t(key: str, **kwargs: Any) -> str:
|
||||
text = _translations.get(key, key)
|
||||
if kwargs:
|
||||
try:
|
||||
return text.format(**kwargs)
|
||||
except (KeyError, IndexError, ValueError):
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def language_option_labels() -> List[Tuple[LocaleEnum, str]]:
|
||||
"""Config values and display labels for the language combobox."""
|
||||
return [
|
||||
(loc, t(f"language.{loc.value}"))
|
||||
for loc in content_locales()
|
||||
]
|
||||
|
||||
|
||||
def language_label_for_config(value: LocaleInput) -> str:
|
||||
loc = LocaleEnum.parse(value)
|
||||
labels = language_option_labels()
|
||||
for cfg_val, label in labels:
|
||||
if cfg_val == loc:
|
||||
return label
|
||||
return labels[0][1] if labels else _DEFAULT_LOCALE.value
|
||||
|
||||
|
||||
def refresh_language_option_maps() -> None:
|
||||
global _LANGUAGE_TO_LABEL, _LABEL_TO_LANGUAGE
|
||||
_LANGUAGE_TO_LABEL = dict(language_option_labels())
|
||||
_LABEL_TO_LANGUAGE = {label: val for val, label in _LANGUAGE_TO_LABEL.items()}
|
||||
|
||||
|
||||
def language_from_label(label: str) -> LocaleEnum:
|
||||
return _LABEL_TO_LANGUAGE.get(label, _DEFAULT_LOCALE)
|
||||
|
||||
|
||||
def label_from_language(value: LocaleInput) -> str:
|
||||
loc = LocaleEnum.parse(value)
|
||||
return _LANGUAGE_TO_LABEL.get(
|
||||
loc,
|
||||
_LANGUAGE_TO_LABEL.get(_DEFAULT_LOCALE, _DEFAULT_LOCALE.value),
|
||||
)
|
||||
|
||||
|
||||
set_language(detect_system_language())
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"app.name": "TG WS Proxy",
|
||||
"app.error_title": "TG WS Proxy — Error",
|
||||
"app.settings_title": "TG WS Proxy — Settings",
|
||||
"app.update_title": "TG WS Proxy — Update",
|
||||
|
||||
"language.ru": "Русский",
|
||||
"language.en": "English",
|
||||
|
||||
"appearance.auto": "Auto",
|
||||
"appearance.light": "Light",
|
||||
"appearance.dark": "Dark",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.theme": "Theme",
|
||||
|
||||
"section.interface": "Interface",
|
||||
"section.mtproto": "MTProto Connection",
|
||||
"section.dc": "Telegram Data Centers (DC → IP)",
|
||||
"section.cfproxy": "Cloudflare Proxy",
|
||||
"section.cfworker": "Cloudflare Worker",
|
||||
"section.logs": "Logs & Performance",
|
||||
"section.updates": "Updates",
|
||||
"section.windows_startup": "Windows Startup",
|
||||
|
||||
"label.host": "IP address",
|
||||
"label.port": "Port",
|
||||
"label.secret": "Secret",
|
||||
"label.dc_hint": "One rule per line, format: number:IP",
|
||||
"label.cf_enable": "Enable CF proxy",
|
||||
"label.cf_custom_domain": "Custom domain",
|
||||
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
|
||||
"label.verbose": "Verbose logging",
|
||||
"label.buf_kb": "Buffer, KB (default 256)",
|
||||
"label.pool_size": "WebSocket session pool (default 4)",
|
||||
"label.log_max_mb": "Max log size, MB (default 5)",
|
||||
"label.check_updates": "Check for updates on startup",
|
||||
"label.autostart": "Start on system boot",
|
||||
"label.autostart_hint": "If you move the app to another folder, the autostart entry may reset.",
|
||||
|
||||
"tip.host": "Address the proxy listens on.\nUsually 127.0.0.1 for localhost, 0.0.0.0 for all interfaces",
|
||||
"tip.port": "Proxy port. Telegram Desktop proxy settings must use the same port",
|
||||
"tip.secret": "Secret key for client authorization",
|
||||
"tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used",
|
||||
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
|
||||
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
|
||||
"tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load",
|
||||
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
|
||||
"tip.autostart": "Launch TG WS Proxy on Windows login. If you move the app to another folder, autostart will reset",
|
||||
"tip.check_updates": "Check for updates on startup",
|
||||
"tip.cfproxy": "Use Cloudflare proxy for unreachable data centers",
|
||||
"tip.cfproxy_domain": "Your own domains proxied through Cloudflare for WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains",
|
||||
"tip.cfproxy_user_domain_cb": "Specify your own domains instead of automatic selection",
|
||||
"tip.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them",
|
||||
"tip.save": "Save settings",
|
||||
"tip.cancel": "Close without saving changes",
|
||||
|
||||
"button.save": "Save",
|
||||
"button.cancel": "Cancel",
|
||||
"button.test": "Test",
|
||||
"button.test_loading": "...",
|
||||
"button.open_release": "Open release page",
|
||||
"button.start": "Get started",
|
||||
"button.update": "Update",
|
||||
"button.page": "Page",
|
||||
"button.close": "Close",
|
||||
|
||||
"validation.bad_host": "Invalid IP address.",
|
||||
"validation.bad_port": "Port must be a number between 1 and 65535",
|
||||
"validation.bad_secret_len": "Secret must be exactly 32 hex characters (16 bytes).",
|
||||
"validation.bad_secret_hex": "Secret must contain only hex characters (0-9, a-f).",
|
||||
"validation.dc_format": "Invalid DC:IP format: {entry}",
|
||||
"validation.dc_invalid": "Invalid DC:IP entry: {entry}",
|
||||
|
||||
"connectivity.cfproxy_title": "CF Proxy",
|
||||
"connectivity.cfworker_title": "CF Worker",
|
||||
"connectivity.timeout": "timeout",
|
||||
"connectivity.no_response": "no response",
|
||||
"connectivity.available": "{title}: available",
|
||||
"connectivity.unavailable": "{title}: unavailable",
|
||||
"connectivity.all_ok": "{title}: all working",
|
||||
"connectivity.partial": "{title}: partially working",
|
||||
"connectivity.auto_ok": "✓ {title} works. {ok} of {total} servers reachable.",
|
||||
"connectivity.all_ok_domain": "✓ All {total} servers reachable via {domain}.",
|
||||
"connectivity.none_ok": "✗ No servers respond via {domain}.\n\nErrors:\n{errors}",
|
||||
"connectivity.partial_detail": "Domain: {domain}\n\n✓ Working: {ok_list}\n\n✗ Unreachable:\n{fail_list}",
|
||||
"connectivity.error_line": " {prefix}{dc}: {error}",
|
||||
"connectivity.cf_auto_fail": "✗ None of the automatic CF domains respond.",
|
||||
"connectivity.multi_all_ok": "✓ {domain}: all {total} servers reachable",
|
||||
"connectivity.multi_fail": "✗ {domain}: unavailable",
|
||||
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
|
||||
|
||||
"updates.status_error": "Could not reach GitHub. Check your network.",
|
||||
"updates.status_pending": "Status will appear after the background check on startup.",
|
||||
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
|
||||
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
|
||||
"updates.status_latest": "Latest known version from GitHub is installed.",
|
||||
|
||||
"first_run.title": "Proxy is running in the system tray",
|
||||
"first_run.how_to": "How to connect Telegram Desktop:",
|
||||
"first_run.auto": " Automatically:",
|
||||
"first_run.auto_hint": " Right-click tray icon → «Open in Telegram»",
|
||||
"first_run.auto_link": " Or copy the link, send it to yourself in TG and click it: {url}",
|
||||
"first_run.manual": " Manually:",
|
||||
"first_run.manual_path": " Settings → Advanced → Connection type → Proxy",
|
||||
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
||||
"first_run.manual_secret": " Secret: dd{secret}",
|
||||
"first_run.open_now": "Open proxy in Telegram now",
|
||||
|
||||
"tray.open_telegram": "Open in Telegram ({host}:{port})",
|
||||
"tray.copy_link": "Copy link",
|
||||
"tray.restart": "Restart proxy",
|
||||
"tray.settings": "Settings...",
|
||||
"tray.logs": "Open logs",
|
||||
"tray.exit": "Exit",
|
||||
|
||||
"dialog.restart_title": "Restart?",
|
||||
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
|
||||
"dialog.already_running": "Application is already running.",
|
||||
"dialog.log_not_found": "Log file has not been created yet.",
|
||||
"dialog.ctk_missing": "customtkinter is not installed.",
|
||||
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
|
||||
"dialog.copy_fail": "Failed to copy link:\n{error}",
|
||||
"dialog.open_tg_fail": "Could not open Telegram automatically.\n\n{detail}",
|
||||
"dialog.open_tg_fail_clipboard": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
|
||||
"dialog.open_tg_fail_manual": "Install pyperclip to copy to clipboard, or open manually:\n{url}",
|
||||
"dialog.pyperclip_missing": "Install pyperclip to copy to clipboard.",
|
||||
"dialog.log_open_fail": "Failed to open log file:\n{error}",
|
||||
"dialog.autostart_fail": "Failed to change autostart.\n\nTry running the app as a user with registry permissions.\n\nError: {error}",
|
||||
|
||||
"update.available": "New version available: {version}",
|
||||
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
|
||||
"update.downloading": "Downloading...",
|
||||
"update.replacing": "Replacing file...",
|
||||
"update.restarting": "Restarting...",
|
||||
"update.error": "Error: {msg}",
|
||||
"update.download_fail": "Download failed:\n{error}",
|
||||
"update.rename_fail": "Failed to rename file:\n{error}",
|
||||
"update.move_fail": "Failed to move file:\n{error}",
|
||||
|
||||
"error.dc_config": "DC → IP configuration error.",
|
||||
|
||||
"diagnostics.port_busy": "Failed to start proxy:\nPort is already in use by another application.\n\nClose the app using this port, or change the port in proxy settings and restart.",
|
||||
"diagnostics.permission": "Failed to start proxy:\nAccess to address/port denied (firewall, antivirus, or permissions).\n\nChange the port to a random value in 10000–50000 in settings, check firewall/antivirus, and restart.",
|
||||
"diagnostics.bad_address": "Failed to start proxy:\nInvalid or unavailable listen address.\n\nCheck the solution at the link opened in your browser.\nVerify host and port in proxy settings and restart.",
|
||||
|
||||
"ipv6.warning": "IPv6 connectivity is enabled on your computer.\n\nTelegram may try to connect over IPv6, which is not supported and may cause errors.\n\nIf the proxy does not work or logs show IPv6 connection attempts, try disabling IPv6 connection attempts in Telegram proxy settings. If that does not help, try disabling IPv6 system-wide.\n\nThis warning is shown only once."
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"app.name": "TG WS Proxy",
|
||||
"app.error_title": "TG WS Proxy — Ошибка",
|
||||
"app.settings_title": "TG WS Proxy — Настройки",
|
||||
"app.update_title": "TG WS Proxy — обновление",
|
||||
|
||||
"language.ru": "Русский",
|
||||
"language.en": "English",
|
||||
|
||||
"appearance.auto": "Авто",
|
||||
"appearance.light": "Светлая",
|
||||
"appearance.dark": "Тёмная",
|
||||
|
||||
"settings.title": "Настройки",
|
||||
"settings.language": "Language",
|
||||
"settings.theme": "Тема",
|
||||
|
||||
"section.interface": "Интерфейс",
|
||||
"section.mtproto": "Подключение MTProto",
|
||||
"section.dc": "Датацентры Telegram (DC → IP)",
|
||||
"section.cfproxy": "Cloudflare Proxy",
|
||||
"section.cfworker": "Cloudflare Worker",
|
||||
"section.logs": "Логи и производительность",
|
||||
"section.updates": "Обновления",
|
||||
"section.windows_startup": "Запуск Windows",
|
||||
|
||||
"label.host": "IP-адрес",
|
||||
"label.port": "Порт",
|
||||
"label.secret": "Secret",
|
||||
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
|
||||
"label.cf_enable": "Включить CF-прокси",
|
||||
"label.cf_custom_domain": "Свой домен",
|
||||
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
|
||||
"label.verbose": "Подробное логирование (verbose)",
|
||||
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
|
||||
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
|
||||
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
|
||||
"label.check_updates": "Проверять обновления при запуске",
|
||||
"label.autostart": "Автозапуск при включении компьютера",
|
||||
"label.autostart_hint": "Если переместить программу в другую папку, запись автозапуска может сброситься.",
|
||||
|
||||
"tip.host": "Адрес, на котором прокси принимает подключения.\nОбычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы",
|
||||
"tip.port": "Порт прокси. В Telegram Desktop в настройках прокси должен быть указан тот же порт",
|
||||
"tip.secret": "Секретный ключ для авторизации клиентов",
|
||||
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
|
||||
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
|
||||
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
|
||||
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
|
||||
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
|
||||
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
|
||||
"tip.check_updates": "При запуске проверять наличие обновлений",
|
||||
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
|
||||
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов",
|
||||
"tip.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора",
|
||||
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
|
||||
"tip.save": "Сохранить настройки",
|
||||
"tip.cancel": "Закрыть окно без сохранения изменений",
|
||||
|
||||
"button.save": "Сохранить",
|
||||
"button.cancel": "Отмена",
|
||||
"button.test": "Тест",
|
||||
"button.test_loading": "...",
|
||||
"button.open_release": "Открыть страницу релиза",
|
||||
"button.start": "Начать",
|
||||
"button.update": "Обновить",
|
||||
"button.page": "Страница",
|
||||
"button.close": "Закрыть",
|
||||
|
||||
"validation.bad_host": "Некорректный IP-адрес.",
|
||||
"validation.bad_port": "Порт должен быть числом 1-65535",
|
||||
"validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).",
|
||||
"validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).",
|
||||
"validation.dc_format": "Неверный формат DC:IP: {entry}",
|
||||
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
|
||||
|
||||
"connectivity.cfproxy_title": "CF-прокси",
|
||||
"connectivity.cfworker_title": "CF Worker",
|
||||
"connectivity.timeout": "таймаут",
|
||||
"connectivity.no_response": "нет ответа",
|
||||
"connectivity.available": "{title}: доступен",
|
||||
"connectivity.unavailable": "{title}: недоступен",
|
||||
"connectivity.all_ok": "{title}: всё работает",
|
||||
"connectivity.partial": "{title}: частично работает",
|
||||
"connectivity.auto_ok": "✓ {title} работает. {ok} из {total} серверов доступны.",
|
||||
"connectivity.all_ok_domain": "✓ Все {total} серверов доступны через {domain}.",
|
||||
"connectivity.none_ok": "✗ Ни один сервер не отвечает через {domain}.\n\nОшибки:\n{errors}",
|
||||
"connectivity.partial_detail": "Домен: {domain}\n\n✓ Работают: {ok_list}\n\n✗ Недоступны:\n{fail_list}",
|
||||
"connectivity.error_line": " {prefix}{dc}: {error}",
|
||||
"connectivity.cf_auto_fail": "✗ Ни один из автоматических CF-доменов не отвечает.",
|
||||
"connectivity.multi_all_ok": "✓ {domain}: все {total} серверов доступны",
|
||||
"connectivity.multi_fail": "✗ {domain}: недоступен",
|
||||
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
|
||||
|
||||
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
|
||||
"updates.status_pending": "Статус появится после фоновой проверки при запуске.",
|
||||
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
|
||||
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
|
||||
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
|
||||
|
||||
"first_run.title": "Прокси запущен и работает в системном трее",
|
||||
"first_run.how_to": "Как подключить Telegram Desktop:",
|
||||
"first_run.auto": " Автоматически:",
|
||||
"first_run.auto_hint": " ПКМ по иконке в трее → «Открыть в Telegram»",
|
||||
"first_run.auto_link": " Или скопировать ссылку, отправить её себе в TG и нажать по ней: {url}",
|
||||
"first_run.manual": " Вручную:",
|
||||
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
|
||||
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
||||
"first_run.manual_secret": " Secret: dd{secret}",
|
||||
"first_run.open_now": "Открыть прокси в Telegram сейчас",
|
||||
|
||||
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
|
||||
"tray.copy_link": "Скопировать ссылку",
|
||||
"tray.restart": "Перезапустить прокси",
|
||||
"tray.settings": "Настройки...",
|
||||
"tray.logs": "Открыть логи",
|
||||
"tray.exit": "Выход",
|
||||
|
||||
"dialog.restart_title": "Перезапустить?",
|
||||
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||
"dialog.already_running": "Приложение уже запущено.",
|
||||
"dialog.log_not_found": "Файл логов ещё не создан.",
|
||||
"dialog.ctk_missing": "customtkinter не установлен.",
|
||||
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
|
||||
"dialog.copy_fail": "Не удалось скопировать ссылку:\n{error}",
|
||||
"dialog.open_tg_fail": "Не удалось открыть Telegram автоматически.\n\n{detail}",
|
||||
"dialog.open_tg_fail_clipboard": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
|
||||
"dialog.open_tg_fail_manual": "Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}",
|
||||
"dialog.pyperclip_missing": "Установите пакет pyperclip для копирования в буфер обмена.",
|
||||
"dialog.log_open_fail": "Не удалось открыть файл логов:\n{error}",
|
||||
"dialog.autostart_fail": "Не удалось изменить автозапуск.\n\nПопробуйте запустить приложение от имени пользователя с правами на реестр.\n\nОшибка: {error}",
|
||||
|
||||
"update.available": "Доступна новая версия: {version}",
|
||||
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
|
||||
"update.downloading": "Скачивание...",
|
||||
"update.replacing": "Замена файла...",
|
||||
"update.restarting": "Перезапуск...",
|
||||
"update.error": "Ошибка: {msg}",
|
||||
"update.download_fail": "Не удалось скачать:\n{error}",
|
||||
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
|
||||
"update.move_fail": "Не удалось переместить файл:\n{error}",
|
||||
|
||||
"error.dc_config": "Ошибка конфигурации DC → IP.",
|
||||
|
||||
"diagnostics.port_busy": "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.",
|
||||
"diagnostics.permission": "Не удалось запустить прокси:\nДоступ к адресу/порту запрещён (брандмауэр, антивирус или права доступа).\n\nИзмените порт на случайный в диапазоне 10000–50000 в настройках, проверьте брандмауэр/антивирус и перезапустите.",
|
||||
"diagnostics.bad_address": "Не удалось запустить прокси:\nНекорректный или недоступный адрес для прослушивания.\n\nПроверьте решение по открывшейся в браузере ссылке.\nПроверьте host и порт в настройках прокси и перезапустите.",
|
||||
|
||||
"ipv6.warning": "На вашем компьютере включена поддержка подключения по IPv6.\n\nTelegram может пытаться подключаться через IPv6, что не поддерживается и может привести к ошибкам.\n\nЕсли прокси не работает или в логах присутствуют ошибки, связанные с попытками подключения по IPv6 - попробуйте отключить в настройках прокси Telegram попытку соединения по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 в системе.\n\nЭто предупреждение будет показано только один раз."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Вспомогательные утилиты (проверка релизов и т.п.)."""
|
||||
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||
|
||||
__all__ = ["RELEASES_PAGE_URL", "get_status", "run_check"]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Общие значения по умолчанию для tray-приложений (Windows / Linux / macOS).
|
||||
Единственное отличие по платформе — ключ autostart только на Windows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from ui.i18n import detect_system_language
|
||||
|
||||
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
||||
"port": 1443,
|
||||
"host": "127.0.0.1",
|
||||
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
|
||||
"verbose": False,
|
||||
"check_updates": True,
|
||||
"log_max_mb": 5,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"cfproxy": True,
|
||||
"cfproxy_user_domain": [],
|
||||
"cfproxy_worker_domain": [],
|
||||
"ws_keepalive_interval": 30,
|
||||
}
|
||||
|
||||
|
||||
def default_tray_config() -> Dict[str, Any]:
|
||||
cfg = dict(_TRAY_DEFAULTS_COMMON)
|
||||
cfg["secret"] = os.urandom(16).hex()
|
||||
cfg["language"] = detect_system_language().value
|
||||
|
||||
if sys.platform == "win32":
|
||||
cfg["autostart"] = False
|
||||
|
||||
return cfg
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import webbrowser
|
||||
|
||||
from typing import Optional, Tuple, Callable
|
||||
|
||||
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
|
||||
_WSA_EACCES = 10013
|
||||
_WSA_EFAULT = 10014
|
||||
_WSA_EADDRINUSE = 10048
|
||||
_WSA_EADDRNOTAVAIL = 10049
|
||||
|
||||
|
||||
def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[Callable]]:
|
||||
"""Map a listen-socket bind failure to a user-facing message.
|
||||
|
||||
Returns None when the exception is not a recognizable bind failure,
|
||||
so callers can fall back to generic handling.
|
||||
"""
|
||||
from ui.i18n import t
|
||||
|
||||
if not isinstance(exc, OSError):
|
||||
return None
|
||||
|
||||
err = exc.errno
|
||||
winerror = getattr(exc, "winerror", None)
|
||||
|
||||
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
|
||||
return t("diagnostics.port_busy"), None
|
||||
if err == errno.EACCES or winerror == _WSA_EACCES:
|
||||
return t("diagnostics.permission"), None
|
||||
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
|
||||
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
|
||||
return t("diagnostics.bad_address"), lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
|
||||
return None, None
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Shared construction of the rotating log file handler.
|
||||
|
||||
Centralizes the rotation invariant so both the tray and the CLI log paths
|
||||
behave identically and the file can never grow without bound (issue #885).
|
||||
|
||||
A ``RotatingFileHandler`` only rotates when ``backupCount >= 1``: CPython's
|
||||
``doRollover`` skips the entire rotation block when ``backupCount == 0``, so
|
||||
``maxBytes`` is silently ignored and the active file grows forever. We force
|
||||
at least one backup here regardless of caller input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging.handlers
|
||||
|
||||
|
||||
_MIN_BYTES = 32 * 1024
|
||||
_MIN_BACKUPS = 1
|
||||
|
||||
|
||||
def build_log_handler(
|
||||
path: str,
|
||||
log_max_mb: float = 5,
|
||||
backups: int = 1,
|
||||
) -> logging.handlers.RotatingFileHandler:
|
||||
"""Create a RotatingFileHandler that actually rotates.
|
||||
|
||||
``backups`` is clamped to at least 1 so rotation is always active, and
|
||||
``maxBytes`` keeps a small floor so a misconfigured tiny size can't cause
|
||||
rotation on every line.
|
||||
"""
|
||||
max_bytes = max(_MIN_BYTES, int(log_max_mb * 1024 * 1024))
|
||||
backup_count = max(_MIN_BACKUPS, int(backups))
|
||||
return logging.handlers.RotatingFileHandler(
|
||||
path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -0,0 +1,526 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket as _socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
import psutil
|
||||
|
||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from proxy.tg_ws_proxy import _run
|
||||
from utils.default_config import default_tray_config
|
||||
from utils.diagnostics import diagnose_listen_error
|
||||
from utils.logging_setup import build_log_handler
|
||||
|
||||
log = logging.getLogger("tg-ws-tray")
|
||||
|
||||
APP_NAME = "TgWsProxy"
|
||||
PORTABLE_DIR_NAME = "TgWsProxy_data"
|
||||
|
||||
|
||||
def _standard_app_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support" / APP_NAME
|
||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
||||
|
||||
|
||||
def _exe_dir() -> Optional[Path]:
|
||||
try:
|
||||
base = getattr(sys, "frozen", False) and sys.executable or sys.argv[0]
|
||||
except Exception:
|
||||
return None
|
||||
if not base:
|
||||
return None
|
||||
p = Path(base).resolve()
|
||||
return p.parent if p.is_file() else p
|
||||
|
||||
|
||||
def _detect_portable() -> Optional[Path]:
|
||||
exe_dir = _exe_dir()
|
||||
if exe_dir is None:
|
||||
return None
|
||||
portable_dir = exe_dir / PORTABLE_DIR_NAME
|
||||
if "--portable" in sys.argv:
|
||||
try:
|
||||
portable_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("Cannot create portable dir %s: %s", portable_dir, repr(exc))
|
||||
return None
|
||||
if portable_dir.is_dir():
|
||||
_migrate_into_portable(portable_dir)
|
||||
return portable_dir
|
||||
return None
|
||||
|
||||
|
||||
def _migrate_into_portable(portable_dir: Path) -> None:
|
||||
try:
|
||||
if any(portable_dir.iterdir()):
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
std = _standard_app_dir()
|
||||
if not std.exists():
|
||||
return
|
||||
try:
|
||||
for src in std.iterdir():
|
||||
if ".log" in src.name:
|
||||
continue
|
||||
dst = portable_dir / src.name
|
||||
try:
|
||||
if not src.is_dir():
|
||||
shutil.copy2(src, dst)
|
||||
except OSError as exc:
|
||||
log.warning("Portable migration: skip %s: %s", src.name, repr(exc))
|
||||
except OSError as exc:
|
||||
log.warning("Portable migration failed: %s", repr(exc))
|
||||
|
||||
|
||||
def _app_dir() -> Path:
|
||||
return _detect_portable() or _standard_app_dir()
|
||||
|
||||
|
||||
APP_DIR = _app_dir()
|
||||
CONFIG_FILE = APP_DIR / "config.json"
|
||||
LOG_FILE = APP_DIR / "proxy.log"
|
||||
FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto"
|
||||
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
|
||||
|
||||
DEFAULT_CONFIG: Dict[str, Any] = default_tray_config()
|
||||
|
||||
IS_FROZEN = bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
APP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# single-instance lock
|
||||
|
||||
_lock_file_path: Optional[Path] = None
|
||||
|
||||
|
||||
def _same_process(meta: dict, proc: psutil.Process) -> bool:
|
||||
try:
|
||||
lock_ct = float(meta.get("create_time", 0.0))
|
||||
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
if IS_FROZEN:
|
||||
return APP_NAME.lower() in proc.name().lower()
|
||||
return False
|
||||
|
||||
|
||||
def acquire_lock() -> bool:
|
||||
global _lock_file_path
|
||||
ensure_dirs()
|
||||
for f in list(APP_DIR.glob("*.lock")):
|
||||
try:
|
||||
pid = int(f.stem)
|
||||
except Exception:
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
meta: dict = {}
|
||||
try:
|
||||
raw = f.read_text(encoding="utf-8").strip()
|
||||
if raw:
|
||||
meta = json.loads(raw)
|
||||
except Exception:
|
||||
pass
|
||||
is_running = False
|
||||
try:
|
||||
is_running = _same_process(meta, psutil.Process(pid))
|
||||
except Exception:
|
||||
pass
|
||||
if is_running:
|
||||
return False
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
lock_file = APP_DIR / f"{os.getpid()}.lock"
|
||||
try:
|
||||
proc = psutil.Process(os.getpid())
|
||||
lock_file.write_text(
|
||||
json.dumps({"create_time": proc.create_time()}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
lock_file.touch()
|
||||
except Exception:
|
||||
pass
|
||||
_lock_file_path = lock_file
|
||||
return True
|
||||
|
||||
|
||||
def release_lock() -> None:
|
||||
global _lock_file_path
|
||||
if _lock_file_path:
|
||||
try:
|
||||
_lock_file_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
_lock_file_path = None
|
||||
|
||||
|
||||
# config
|
||||
|
||||
def _apply_ui_language(cfg: dict) -> None:
|
||||
from ui.i18n import set_language
|
||||
|
||||
set_language(cfg.get("language", DEFAULT_CONFIG["language"]))
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
ensure_dirs()
|
||||
cfg: Optional[dict] = None
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for k, v in DEFAULT_CONFIG.items():
|
||||
data.setdefault(k, v)
|
||||
cfg = data
|
||||
except Exception as exc:
|
||||
log.warning("Failed to load config: %s", repr(exc))
|
||||
if cfg is None:
|
||||
cfg = dict(DEFAULT_CONFIG)
|
||||
_apply_ui_language(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
ensure_dirs()
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# logging
|
||||
|
||||
_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
|
||||
_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s"
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
|
||||
ensure_dirs()
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||
|
||||
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
root.addHandler(fh)
|
||||
|
||||
if not IS_FROZEN:
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(level)
|
||||
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
|
||||
root.addHandler(ch)
|
||||
|
||||
|
||||
# icon
|
||||
|
||||
def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)):
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
margin = 2
|
||||
draw.ellipse([margin, margin, size - margin, size - margin], fill=color)
|
||||
|
||||
for path in _font_paths():
|
||||
try:
|
||||
font = ImageFont.truetype(path, size=int(size * 0.55))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), "T", font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(
|
||||
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
|
||||
"T",
|
||||
fill=(255, 255, 255, 255),
|
||||
font=font,
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def _font_paths():
|
||||
if sys.platform == "win32":
|
||||
return ["arial.ttf"]
|
||||
if sys.platform == "darwin":
|
||||
return ["/System/Library/Fonts/Helvetica.ttc"]
|
||||
return [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
def load_icon():
|
||||
from PIL import Image
|
||||
|
||||
icon_path = Path(__file__).parents[1] / "icon.ico"
|
||||
if icon_path.exists():
|
||||
try:
|
||||
return Image.open(str(icon_path))
|
||||
except Exception:
|
||||
pass
|
||||
return make_icon_image(64)
|
||||
|
||||
|
||||
# proxy lifecycle
|
||||
|
||||
_proxy_thread: Optional[threading.Thread] = None
|
||||
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
|
||||
|
||||
|
||||
def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
||||
global _async_stop
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
stop_ev = asyncio.Event()
|
||||
_async_stop = (loop, stop_ev)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||
except Exception as exc:
|
||||
log.error("Proxy thread crashed: %s", repr(exc))
|
||||
msg, diagnose_called = diagnose_listen_error(exc)
|
||||
if msg:
|
||||
show_error(msg)
|
||||
if diagnose_called:
|
||||
diagnose_called()
|
||||
finally:
|
||||
loop.close()
|
||||
_async_stop = None
|
||||
|
||||
|
||||
def apply_proxy_config(cfg: dict) -> bool:
|
||||
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
||||
try:
|
||||
dc_redirects = parse_dc_ip_list(dc_ip_list)
|
||||
except ValueError as e:
|
||||
log.error("Bad config dc_ip: %s", e)
|
||||
return False
|
||||
|
||||
pc = proxy_config
|
||||
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||
pc.dc_redirects = dc_redirects
|
||||
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||
pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
|
||||
pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]))
|
||||
pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
|
||||
return True
|
||||
|
||||
|
||||
def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||
global _proxy_thread
|
||||
if _proxy_thread and _proxy_thread.is_alive():
|
||||
log.info("Proxy already running")
|
||||
return
|
||||
|
||||
if not apply_proxy_config(cfg):
|
||||
from ui.i18n import t
|
||||
on_error(t("error.dc_config"))
|
||||
return
|
||||
|
||||
pc = proxy_config
|
||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||
_proxy_thread = threading.Thread(
|
||||
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
||||
)
|
||||
_proxy_thread.start()
|
||||
|
||||
|
||||
def stop_proxy() -> None:
|
||||
global _proxy_thread, _async_stop
|
||||
if _async_stop:
|
||||
loop, stop_ev = _async_stop
|
||||
loop.call_soon_threadsafe(stop_ev.set)
|
||||
if _proxy_thread:
|
||||
_proxy_thread.join(timeout=5)
|
||||
if _proxy_thread.is_alive():
|
||||
log.warning("Proxy thread did not stop within timeout; "
|
||||
"port may still be in use")
|
||||
_proxy_thread = None
|
||||
log.info("Proxy stopped")
|
||||
|
||||
|
||||
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||
log.info("Restarting proxy...")
|
||||
stop_proxy()
|
||||
time.sleep(1.0)
|
||||
start_proxy(cfg, on_error)
|
||||
|
||||
|
||||
def tg_proxy_url(cfg: dict) -> str:
|
||||
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||
link_host = get_link_host(host)
|
||||
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||
|
||||
|
||||
def _has_ipv6() -> bool:
|
||||
try:
|
||||
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
|
||||
ip = addr[4][0]
|
||||
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
|
||||
s.bind(("::1", 0))
|
||||
s.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
||||
ensure_dirs()
|
||||
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
|
||||
return
|
||||
IPV6_WARN_MARKER.touch()
|
||||
from ui.i18n import t
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: show_info(t("ipv6.warning"), t("app.name")),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
# update check
|
||||
|
||||
def maybe_notify_update(
|
||||
cfg: dict,
|
||||
is_exiting: Callable[[], bool],
|
||||
ask_open: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
if not cfg.get("check_updates", True):
|
||||
return
|
||||
|
||||
def _work():
|
||||
time.sleep(1.5)
|
||||
if is_exiting():
|
||||
return
|
||||
try:
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||
import webbrowser
|
||||
|
||||
run_check(__version__)
|
||||
st = get_status()
|
||||
if not st.get("has_update"):
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
from ui.i18n import t
|
||||
|
||||
if ask_open(
|
||||
t("update.ask_open", version=ver),
|
||||
t("app.update_title"),
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
# ctk thread (windows / linux)
|
||||
|
||||
_ctk_root: Any = None
|
||||
_ctk_root_ready = threading.Event()
|
||||
|
||||
|
||||
def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
|
||||
global _ctk_root
|
||||
if ctk is None:
|
||||
return False
|
||||
if _ctk_root_ready.is_set():
|
||||
return True
|
||||
|
||||
def _run():
|
||||
global _ctk_root
|
||||
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
||||
|
||||
install_tkinter_variable_del_guard()
|
||||
apply_ctk_appearance(ctk, mode)
|
||||
_ctk_root = ctk.CTk()
|
||||
_ctk_root.withdraw()
|
||||
_ctk_root_ready.set()
|
||||
_ctk_root.mainloop()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="ctk-root").start()
|
||||
_ctk_root_ready.wait(timeout=5.0)
|
||||
return _ctk_root is not None
|
||||
|
||||
|
||||
def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None:
|
||||
if _ctk_root is None:
|
||||
return
|
||||
done = threading.Event()
|
||||
|
||||
def _invoke():
|
||||
try:
|
||||
build_fn(done)
|
||||
except Exception:
|
||||
log.exception("CTk dialog failed")
|
||||
done.set()
|
||||
|
||||
_ctk_root.after(0, _invoke)
|
||||
done.wait()
|
||||
import gc
|
||||
gc.collect()
|
||||
|
||||
|
||||
def quit_ctk() -> None:
|
||||
if _ctk_root is not None:
|
||||
try:
|
||||
_ctk_root.after(0, _ctk_root.quit)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# common bootstrap
|
||||
|
||||
def bootstrap(cfg: dict) -> None:
|
||||
save_config(cfg)
|
||||
if LOG_FILE.exists():
|
||||
try:
|
||||
LOG_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
setup_logging(
|
||||
cfg.get("verbose", False),
|
||||
log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
|
||||
)
|
||||
log.info("TG WS Proxy версия %s starting", __version__)
|
||||
log.info("Config: %s", cfg)
|
||||
log.info("Log file: %s", LOG_FILE)
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Проверка новой версии через GitHub Releases API
|
||||
|
||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request
|
||||
from proxy.utils import build_github_opener
|
||||
|
||||
REPO = "Flowseal/tg-ws-proxy"
|
||||
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
|
||||
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
||||
|
||||
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
||||
_MIN_FETCH_INTERVAL_SEC = 3600.0
|
||||
|
||||
_state: Dict[str, Any] = {
|
||||
"checked": False,
|
||||
"has_update": False,
|
||||
"ahead_of_release": False,
|
||||
"latest": None,
|
||||
"html_url": None,
|
||||
"error": None,
|
||||
"assets": [],
|
||||
}
|
||||
|
||||
|
||||
def _cache_file() -> Optional[Path]:
|
||||
try:
|
||||
from utils.tray_common import APP_DIR
|
||||
root = APP_DIR
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root / ".update_check_cache.json"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_cache(path: Optional[Path]) -> Dict[str, Any]:
|
||||
if not path or not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(path: Optional[Path], data: Dict[str, Any]) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_version_tuple(s: str) -> tuple:
|
||||
s = (s or "").strip().lstrip("vV")
|
||||
if not s:
|
||||
return (0,)
|
||||
parts = []
|
||||
for seg in s.split("."):
|
||||
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
|
||||
if digits:
|
||||
try:
|
||||
parts.append(int(digits))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
else:
|
||||
parts.append(0)
|
||||
return tuple(parts) if parts else (0,)
|
||||
|
||||
|
||||
def _version_gt(a: str, b: str) -> bool:
|
||||
"""True, если версия a новее b (простое сравнение по сегментам)."""
|
||||
ta = _parse_version_tuple(a)
|
||||
tb = _parse_version_tuple(b)
|
||||
for x, y in zip_longest(ta, tb, fillvalue=0):
|
||||
if x > y:
|
||||
return True
|
||||
if x < y:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _apply_release_tag(
|
||||
tag: str, html_url: str, current_version: str,
|
||||
) -> None:
|
||||
global _state
|
||||
if not tag:
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
|
||||
return
|
||||
latest_clean = tag.lstrip("vV")
|
||||
cur = (current_version or "").strip().lstrip("vV")
|
||||
_state["latest"] = latest_clean
|
||||
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
|
||||
_state["has_update"] = _version_gt(latest_clean, cur)
|
||||
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
|
||||
cur, latest_clean
|
||||
)
|
||||
|
||||
|
||||
def fetch_latest_release(
|
||||
timeout: float = 12.0,
|
||||
etag: Optional[str] = None,
|
||||
) -> Tuple[Optional[dict], Optional[str], int]:
|
||||
"""
|
||||
GET releases/latest. Возвращает (data или None при 304, etag или None, HTTP-код).
|
||||
"""
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "tg-ws-proxy-update-check",
|
||||
}
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
req = Request(
|
||||
RELEASES_LATEST_API,
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||
code = getattr(resp, "status", None) or resp.getcode()
|
||||
new_etag = resp.headers.get("ETag")
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw), new_etag, int(code)
|
||||
except HTTPError as e:
|
||||
if e.code == 304:
|
||||
hdrs = e.headers
|
||||
new_etag = hdrs.get("ETag") if hdrs else None
|
||||
return None, new_etag or etag, 304
|
||||
raise
|
||||
|
||||
|
||||
def run_check(current_version: str) -> None:
|
||||
"""Запрашивает последний релиз и обновляет внутреннее состояние."""
|
||||
global _state
|
||||
_state["checked"] = True
|
||||
_state["error"] = None
|
||||
|
||||
cache_path = _cache_file()
|
||||
cache = _load_cache(cache_path)
|
||||
now = time.time()
|
||||
last_attempt = float(cache.get("last_attempt_at") or 0)
|
||||
|
||||
if last_attempt and (now - last_attempt) < _MIN_FETCH_INTERVAL_SEC:
|
||||
tag = (cache.get("tag_name") or "").strip()
|
||||
if tag:
|
||||
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
|
||||
_state["assets"] = cache.get("assets") or []
|
||||
return
|
||||
err = cache.get("last_error")
|
||||
_state["error"] = (
|
||||
err if err else "Проверка обновлений отложена (интервал между запросами)."
|
||||
)
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = RELEASES_PAGE_URL
|
||||
return
|
||||
|
||||
etag = (cache.get("etag") or "").strip() or None
|
||||
try:
|
||||
data, new_etag, code = fetch_latest_release(etag=etag)
|
||||
cache["last_attempt_at"] = now
|
||||
if code == 304:
|
||||
tag = (cache.get("tag_name") or "").strip()
|
||||
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
_apply_release_tag(tag, url, current_version)
|
||||
_state["assets"] = cache.get("assets") or []
|
||||
if new_etag:
|
||||
cache["etag"] = new_etag
|
||||
_save_cache(cache_path, cache)
|
||||
return
|
||||
|
||||
assert data is not None
|
||||
tag = (data.get("tag_name") or "").strip()
|
||||
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
if not tag:
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = html_url
|
||||
else:
|
||||
_apply_release_tag(tag, html_url, current_version)
|
||||
if new_etag:
|
||||
cache["etag"] = new_etag
|
||||
cache["tag_name"] = tag
|
||||
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)
|
||||
_save_cache(cache_path, cache)
|
||||
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
|
||||
cache["last_attempt_at"] = now
|
||||
msg = str(e)
|
||||
if isinstance(e, HTTPError) and e.code == 403:
|
||||
msg = (
|
||||
"GitHub API вернул 403 (лимит или доступ). Повторите позже."
|
||||
)
|
||||
cache["last_error"] = msg
|
||||
_save_cache(cache_path, cache)
|
||||
_state["error"] = msg
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = RELEASES_PAGE_URL
|
||||
|
||||
|
||||
def fetch_release_by_tag(
|
||||
tag: str, timeout: float = 12.0,
|
||||
) -> Tuple[Optional[dict], int]:
|
||||
if not tag:
|
||||
return None, 0
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "tg-ws-proxy-update-check",
|
||||
}
|
||||
req = Request(
|
||||
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||
code = getattr(resp, "status", None) or resp.getcode()
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw), int(code)
|
||||
except HTTPError as e:
|
||||
if e.code in [304, 404]:
|
||||
return None, e.code
|
||||
raise
|
||||
|
||||
|
||||
def _extract_assets(data: Optional[dict]) -> list:
|
||||
if not data:
|
||||
return []
|
||||
return [
|
||||
{"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")
|
||||
]
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
||||
return dict(_state)
|
||||
|
||||
|
||||
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
|
||||
new_assets = _state.get("assets") or []
|
||||
if not new_assets:
|
||||
return None
|
||||
|
||||
target_name = None
|
||||
|
||||
# SHA256 match
|
||||
try:
|
||||
import hashlib
|
||||
data, code = fetch_release_by_tag(f"v{current_version}")
|
||||
if code == 200 and data:
|
||||
cur_assets = _extract_assets(data)
|
||||
if cur_assets:
|
||||
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 cur_assets:
|
||||
d = (a.get("digest") or "").lower()
|
||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
||||
target_name = a["name"]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
||||
import platform
|
||||
import struct
|
||||
|
||||
is_64 = struct.calcsize("P") * 8 == 64
|
||||
machine = platform.machine().lower()
|
||||
is_arm64 = machine in ("arm64", "aarch64")
|
||||
|
||||
try:
|
||||
is_modern = sys.getwindowsversion().major >= 10
|
||||
except Exception:
|
||||
is_modern = True
|
||||
|
||||
if is_arm64:
|
||||
target_name = "TgWsProxy_windows_arm64.exe"
|
||||
elif is_modern:
|
||||
target_name = "TgWsProxy_windows.exe"
|
||||
elif is_64:
|
||||
target_name = "TgWsProxy_windows_7_64bit.exe"
|
||||
else:
|
||||
target_name = "TgWsProxy_windows_7_32bit.exe"
|
||||
|
||||
for a in new_assets:
|
||||
if a.get("name") == target_name:
|
||||
return a["url"], a["name"]
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
def is_windows_dark_theme() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
|
||||
try:
|
||||
import winreg
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
|
||||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||||
return value == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def apply_windows_dark_theme() -> None:
|
||||
try:
|
||||
import ctypes
|
||||
uxtheme = ctypes.windll.uxtheme
|
||||
|
||||
try:
|
||||
set_preferred = uxtheme[135]
|
||||
result = set_preferred(2)
|
||||
if result == 0:
|
||||
flush = uxtheme[136]
|
||||
flush()
|
||||
except Exception:
|
||||
try:
|
||||
allow_dark = uxtheme[135]
|
||||
allow_dark(True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||