Compare commits

..

107 Commits

Author SHA1 Message Date
Flowseal 43bca3a71b may be fixes #1017 2026-06-23 18:45:20 +03:00
Flowseal 6b5fd72612 i18n fixes 2026-06-23 18:24:26 +03:00
Kirill 85b5e7f22a feature: i18n (#1025) 2026-06-23 15:41:49 +03:00
Flowseal fed772049b session close reason 2026-06-23 14:55:25 +03:00
Flowseal 91d39a5ebe Revert "fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925)" (#1036)
This reverts commit 96e5b4b639.
2026-06-23 14:47:12 +03:00
Flowseal 5cbac657dc CfWorker pool age tune 2026-06-23 13:46:54 +03:00
delewer ee6c34e065 ci: add better view on macos installation (#1019) 2026-06-23 13:36:20 +03:00
Flowseal ce6a456bd1 docs update 2026-06-23 13:24:49 +03:00
Flowseal 5bc5001c4d SHA256 compare fix 2026-06-18 15:22:30 +03:00
Flowseal 2afd80825b docs update 2026-06-17 11:33:20 +03:00
Flowseal 12fafbc8f4 Version bump 2026-06-17 11:05:17 +03:00
Flowseal 5839ca2564 Portable mode 2026-06-17 11:02:04 +03:00
Flowseal e40c571009 #646 fixes 2026-06-17 10:25:45 +03:00
Konukhov Yaroslav 96e5b4b639 fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925) 2026-06-17 10:13:55 +03:00
Flowseal 13d2b1db6d #943 fixes 2026-06-17 09:54:53 +03:00
Yan a29a1a8610 Добавлена сборка Windows ARM64 в Build & Release workflow (#943) 2026-06-17 09:50:32 +03:00
partoftheworlD 94010f1481 Added support for cf worker for docker container (#996) 2026-06-17 09:45:25 +03:00
Kenyka Kenykovich 42172235c7 Исправлен fallback список CF proxy доменов (добавлена запятая) (#958) 2026-06-17 09:44:00 +03:00
Flowseal b0010af130 #924 improvements 2026-06-17 09:43:06 +03:00
Konukhov Yaroslav 784a7f659b fix: diagnose permission and bad-address bind failures on startup (#924) 2026-06-17 09:24:44 +03:00
Konukhov Yaroslav 21fe672963 fix: rotate log files instead of growing without bound (#885) (#932) 2026-06-17 09:13:32 +03:00
Flowseal ed46ecce5a version bump 2026-06-03 17:14:12 +03:00
Flowseal 9562b11101 docs 2026-06-03 17:13:47 +03:00
Flowseal dfdb993da5 shuffle cfworker domains 2026-06-03 17:09:16 +03:00
Flowseal d4f8b51326 version bump 2026-05-30 20:34:26 +03:00
Flowseal ca431633d7 Version bump 2026-05-30 20:32:11 +03:00
Flowseal ea4e8e790a Possibility to pass few cfproxy and worker domains 2026-05-30 20:30:47 +03:00
Flowseal 05d6de269b import path fixes 2026-05-30 19:39:58 +03:00
Flowseal 1c4b103df2 Pool for cloudflare worker 2026-05-30 19:34:47 +03:00
Erik 23f0e4d426 Fall back to system libcrypto when cryptography is unavailable (#894) 2026-05-30 19:31:47 +03:00
Konukhov Yaroslav 49e62ca142 perf(bridge): split MTProto packets in O(N) instead of O(N^2) (#913) 2026-05-30 19:25:56 +03:00
delewer 5915a0e1f3 docs: update images (#858) 2026-05-17 01:04:37 +03:00
Flowseal 7bc9e133c8 Update README.md 2026-05-16 20:01:26 +03:00
Flowseal 12d3d5e478 Update README.md 2026-05-16 20:00:34 +03:00
Flowseal b7cca232ea Update CfWorker.md 2026-05-16 11:47:56 +03:00
Flowseal 0eebdff69e Version bump 2026-05-16 11:32:48 +03:00
Flowseal ab3bec967c Update CfWorker.md 2026-05-16 11:32:12 +03:00
Flowseal a16f7dfc0b Update CfWorker.md 2026-05-16 11:31:21 +03:00
Flowseal 6f02fc1c46 remove cf priority flag, cf worker ui setup 2026-05-16 11:17:42 +03:00
Flowseal 884fffcc2f cf worker mention in readme 2026-05-16 11:17:42 +03:00
Flowseal 09ce00b2e0 worker's code cleanup 2026-05-16 11:17:21 +03:00
Flowseal 362c5a4893 cloudflare worker implementation 2026-05-16 11:17:21 +03:00
Kira bff67b3ecf Docs/readme docker (#843) 2026-05-13 09:20:10 +03:00
Flowseal d5abfbf9c2 github connection fallback 2026-05-09 16:47:56 +03:00
Flowseal 8269ebe3bb download ways mention on build's page 2026-05-08 20:42:31 +03:00
Flowseal 3770569789 revert version 2026-05-08 14:49:19 +03:00
Flowseal e72a44d74b github downloader fix 2026-05-08 14:36:54 +03:00
deexsed 33d3147c0b fix: автоответы только для label "bug" (#826) 2026-05-08 12:19:20 +03:00
Flowseal e46cf138ac new domains 2026-05-08 09:35:17 +03:00
Flowseal 145b0c431a version bump 2026-05-08 09:32:07 +03:00
Flowseal b991948a77 remove labels 2026-05-08 09:31:50 +03:00
Flowseal 42df9cfdc4 triage update 2026-05-08 09:31:26 +03:00
Flowseal eabc0c9a15 docs update 2026-05-08 09:22:56 +03:00
Flowseal ea88adc2dc rpm build fixes 2026-05-08 09:19:23 +03:00
Sylvester Alderson f554f730c2 linux rpm package, github actions (#726) 2026-05-08 09:16:54 +03:00
deexsed f85c4fe854 fix(config): добавлена валидация и надежный fallback для CF domain list (#799) 2026-05-08 08:59:03 +03:00
Proger b070647996 support TG_WS_PROXY_SECRET (#769) 2026-05-08 08:54:45 +03:00
Konukhov Yaroslav e3d2229f7f Мелкие фиксы настроек и пара багов (#805) 2026-05-08 08:54:30 +03:00
deexsed 1cbc7dee7d Улучшена "архитектура" документации и настроен triage для issues (#801) 2026-05-08 08:51:40 +03:00
delewer 1728fabfc7 docs(README): update preview image source with better visual (#817) 2026-05-06 17:31:49 +03:00
deexsed bf08bad11e Fix --cfproxy-priority CLI parsing (#797) 2026-04-30 13:29:43 +03:00
Kleshzz a787b4fd6b Update README.md (#796) 2026-04-30 06:12:54 +03:00
Sanfix 2c577c1166 Update README.md (change service name in cli version) (#783) 2026-04-28 17:38:37 +03:00
delewer 05b22fe3ba docs(README): fix little typo bug (#778) 2026-04-26 18:54:02 +03:00
Flowseal cc00c6d040 Version bump 2026-04-26 16:58:48 +03:00
Flowseal b3ed5c09db Windows auto update 2026-04-26 16:58:17 +03:00
Flowseal b8556dc702 fix #775 2026-04-26 16:26:50 +03:00
Flowseal 28be00ea9e docs update 2026-04-19 17:32:54 +03:00
Flowseal 5795de00b1 Version bump 2026-04-18 18:59:46 +03:00
Flowseal c5fa5b7f3e fix: cfproxy user domain not set via CLI #741 2026-04-18 18:59:16 +03:00
Flowseal a70e50b9f3 refactor 2026-04-18 16:58:49 +03:00
Flowseal 059ca8760f moved some dubug logs to warning level 2026-04-18 15:49:42 +03:00
Flowseal 0c8d0f160a better exception logging 2026-04-18 15:45:15 +03:00
Flowseal 791708cc3d ws_blacklsit annotation fix 2026-04-18 15:25:11 +03:00
Flowseal 1abcbf86fe gitignore clear 2026-04-18 15:23:56 +03:00
Flowseal d84b9eadc4 version fix 2026-04-16 18:20:47 +03:00
Flowseal c1b4cb0204 docs update 2026-04-16 18:01:48 +03:00
Flowseal 5d08e16e5d removed repeated annotation 2026-04-16 17:56:48 +03:00
Flowseal a844a88f38 docs update 2026-04-16 17:52:58 +03:00
Flowseal e5f1d02737 docs links update 2026-04-16 17:51:41 +03:00
Flowseal 3a6e82c2a8 docs update 2026-04-16 17:50:32 +03:00
Flowseal e56ada1a34 CF domains balancer 2026-04-16 17:08:03 +03:00
Flowseal b44d79a933 docs update 2026-04-16 17:08:03 +03:00
Aksarin Mikhail 77723d875f Update README.md (#711)
Fix relative links
2026-04-16 00:29:58 +03:00
Flowseal 548ec05fc5 docs update 2026-04-14 21:56:14 +03:00
Flowseal 03c7719c39 mutex check simplify 2026-04-14 16:58:54 +03:00
Flowseal db4cebe0b2 build test 2026-04-14 16:51:26 +03:00
Flowseal ca81d037f7 docs update 2026-04-14 03:11:13 +03:00
Flowseal 07615af49c bootloader build fix 2026-04-14 02:44:15 +03:00
Flowseal f8ee37370d Version bump 2026-04-14 00:27:27 +03:00
Flowseal 4cbb9e555c windows mutex-lock 2026-04-14 00:27:27 +03:00
Flowseal 25ae4b0a24 build version changes 2026-04-14 00:27:27 +03:00
Kleshzz 8af1bc8c89 Add .gitattributes & Update .gitignore (#690) 2026-04-13 19:30:57 +03:00
Flowseal b48ac67b9f donate web link 2026-04-11 21:27:21 +03:00
Flowseal 937acdb461 Version bump 2026-04-11 21:09:46 +03:00
Flowseal 6f3da84e48 Refresh domains schedule 2026-04-11 21:09:08 +03:00
Flowseal 3c3e9eb34b fix domains testing 2026-04-11 21:03:53 +03:00
Flowseal ba89cad8b8 fake-tls cli 2026-04-11 20:52:24 +03:00
Flowseal bf905ec54f docs update 2026-04-11 19:11:47 +03:00
Flowseal ace0a5e968 docs update 2026-04-11 18:54:32 +03:00
Flowseal e47eef4709 docs update 2026-04-11 15:28:37 +03:00
Flowseal abe1d1f01e docs update 2026-04-11 15:28:37 +03:00
Flowseal cc31c02c9d donate button ctk 2026-04-11 15:28:37 +03:00
Flowseal f39bb15ff6 docs update 2026-04-11 15:28:31 +03:00
kreker06 5a62cd82b2 Update Dockerfile (#586) 2026-04-11 14:54:32 +03:00
Flowseal fe4e0e8234 docs update 2026-04-10 19:28:36 +03:00
Flowseal 172dc67093 docs update 2026-04-10 02:57:25 +03:00
56 changed files with 4014 additions and 916 deletions
+9
View File
@@ -0,0 +1,9 @@
* text=auto eol=lf
*.py text diff=python
*.spec text linguist-language=Python
*.toml text
*.txt text
*.ico binary
+11
View File
@@ -0,0 +1,11 @@
# Default owners
* @Flowseal
# Automation and repository settings
.github/** @Flowseal
# Documentation
docs/** @Flowseal
# Core proxy implementation
proxy/** @Flowseal
+1
View File
@@ -0,0 +1 @@
custom: ['https://nowpayments.io/donation/flowseal']
+12 -9
View File
@@ -1,20 +1,23 @@
name: 🐛 Проблема name: 🐛 Проблема
title: '[Проблема] ' title: '[Проблема] '
description: Сообщить о проблеме description: Сообщить о проблеме
labels: ['type: проблема', 'status: нуждается в сортировке'] labels: ['bug']
body: body:
- type: textarea - type: input
id: description id: app_version
attributes: attributes:
label: Опишите вашу проблему label: Версия TG WS Proxy
description: Чётко опишите проблему с которой вы столкнулись description: Укажите версию приложения (например, v1.2.3)
placeholder: Описание проблемы placeholder: vX.Y.Z
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: additions id: description
attributes: attributes:
label: Дополнительные детали label: Опишите вашу проблему
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы. description: Чётко опишите проблему, с которой вы столкнулись
placeholder: Описание проблемы
validations:
required: true
+6
View File
@@ -0,0 +1,6 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Документация
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
about: Ознакомьтесь с документацией перед созданием issue
@@ -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: Любые дополнительные материалы по предложению
+18
View File
@@ -1,2 +1,20 @@
virkgj.com virkgj.com
vmmzovy.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
+195 -46
View File
@@ -17,7 +17,7 @@ permissions:
contents: write contents: write
jobs: jobs:
build-windows: build-windows-x64:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -26,7 +26,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.11"
cache: "pip" cache: "pip"
- name: Setup MSVC 14.40 toolset - name: Setup MSVC 14.40 toolset
@@ -38,10 +38,11 @@ jobs:
run: pip install . run: pip install .
- name: Build PyInstaller bootloader from source - name: Build PyInstaller bootloader from source
run: |
pip install "pyinstaller==6.16.0" --no-binary pyinstaller
env: env:
PYINSTALLER_COMPILE_BOOTLOADER: 1 PYINSTALLER_COMPILE_BOOTLOADER: "1"
run: |
pip download --no-binary pyinstaller --no-deps --no-cache-dir -d pyinstaller_src "pyinstaller==6.10.0"
pip install (Get-ChildItem pyinstaller_src\*.tar.gz).FullName
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm run: pyinstaller packaging/windows.spec --noconfirm
@@ -72,9 +73,85 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy name: TgWsProxy-windows-x64
path: dist/TgWsProxy_windows.exe 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: build-win7:
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
@@ -193,32 +270,12 @@ jobs:
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
python3.12 -m pip install . python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.16.0 python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO - name: Create macOS icon
run: | run: |
set -euo pipefail set -euo pipefail
python3.12 - <<'PY' python3.12 macos.py --render-app-icon icon.icns
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
- name: Build app with PyInstaller - name: Build app with PyInstaller
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
@@ -226,6 +283,11 @@ jobs:
- name: Validate universal2 app bundle - name: Validate universal2 app bundle
run: | run: |
set -euo pipefail 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 found=0
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
if file "$file" | grep -q "Mach-O"; then if file "$file" | grep -q "Mach-O"; then
@@ -249,22 +311,31 @@ jobs:
- name: Create DMG - name: Create DMG
run: | run: |
set -euo pipefail set -euo pipefail
APP_NAME="TG WS Proxy" packaging/dmg/build_dmg.sh \
DMG_TEMP="dist/dmg_temp" "dist/TG WS Proxy.app" \
"TG WS Proxy" \
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 \
"dist/TgWsProxy_macos_universal.dmg" "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 - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -295,7 +366,7 @@ jobs:
run: | run: |
.venv/bin/pip install --upgrade pip .venv/bin/pip install --upgrade pip
.venv/bin/pip install . .venv/bin/pip install .
.venv/bin/pip install "pyinstaller==6.16.0" .venv/bin/pip install "pyinstaller==6.13.0"
- name: Build binary with PyInstaller - name: Build binary with PyInstaller
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
@@ -358,6 +429,76 @@ jobs:
"$PKG_ROOT" \ "$PKG_ROOT" \
"dist/TgWsProxy_linux_amd64.deb" "dist/TgWsProxy_linux_amd64.deb"
- name: Create .rpm package with fpm
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.version }}"
VERSION="${VERSION#v}"
sudo gem install fpm -v 1.17.0
mkdir -p rpm_package/usr/bin
mkdir -p rpm_package/usr/share/applications
mkdir -p rpm_package/usr/share/icons/hicolor/256x256/apps
cp dist/TgWsProxy_linux_amd64 rpm_package/usr/bin/tg-ws-proxy
chmod 755 rpm_package/usr/bin/tg-ws-proxy
.venv/bin/python - <<PY
from PIL import Image
Image.open("icon.ico").save(
"rpm_package/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
"PNG",
)
PY
cat > rpm_package/usr/share/applications/tg-ws-proxy.desktop <<EOF
[Desktop Entry]
Type=Application
Name=TG WS Proxy
GenericName=Telegram Proxy
Comment=Telegram Desktop WebSocket Bridge Proxy
Exec=tg-ws-proxy
Icon=tg-ws-proxy
Terminal=false
Categories=Network;
StartupNotify=true
Keywords=telegram;proxy;websocket;
EOF
cat > post_install.sh <<EOF
#!/bin/bash
if [ -x /usr/bin/update-desktop-database ]; then
/usr/bin/update-desktop-database &> /dev/null || :
fi
if [ -x /usr/bin/gtk-update-icon-cache ]; then
/usr/bin/gtk-update-icon-cache -q /usr/share/icons/hicolor &> /dev/null || :
fi
EOF
chmod +x post_install.sh
fpm -s dir \
-t rpm \
-n tg-ws-proxy \
-v ${VERSION} \
--iteration 1 \
--architecture x86_64 \
--license "MIT" \
--vendor "Flowseal" \
--maintainer "Flowseal" \
--url "https://github.com/Flowseal/tg-ws-proxy" \
--description "MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI." \
--depends "libgtk-3.so.0()(64bit)" \
--depends "libayatana-appindicator3.so.1()(64bit)" \
--depends "python3-tkinter" \
--after-install post_install.sh \
--after-remove post_install.sh \
-C rpm_package \
.
mv tg-ws-proxy-${VERSION}-1.x86_64.rpm dist/TgWsProxy_linux_amd64.rpm
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@@ -365,9 +506,10 @@ jobs:
path: | path: |
dist/TgWsProxy_linux_amd64 dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb dist/TgWsProxy_linux_amd64.deb
dist/TgWsProxy_linux_amd64.rpm
release: release:
needs: [build-windows, build-win7, build-macos, build-linux] needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }} if: ${{ github.event.inputs.make_release == 'true' }}
steps: steps:
@@ -383,14 +525,21 @@ jobs:
tag_name: ${{ github.event.inputs.version }} tag_name: ${{ github.event.inputs.version }}
name: "TG WS Proxy ${{ github.event.inputs.version }}" name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: | body: |
## TG WS Proxy ${{ github.event.inputs.version }} ##
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
> [!TIP]
> Не можете скачать?
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
files: | files: |
dist/TgWsProxy_windows.exe dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_arm64.exe
dist/TgWsProxy_windows_7_64bit.exe dist/TgWsProxy_windows_7_64bit.exe
dist/TgWsProxy_windows_7_32bit.exe dist/TgWsProxy_windows_7_32bit.exe
dist/TgWsProxy_macos_universal.dmg dist/TgWsProxy_macos_universal.dmg
dist/TgWsProxy_linux_amd64 dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb dist/TgWsProxy_linux_amd64.deb
dist/TgWsProxy_linux_amd64.rpm
draft: false draft: false
prerelease: false prerelease: false
env: env:
+42
View File
@@ -0,0 +1,42 @@
name: Auto comment on new issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
comment:
if: contains(github.event.issue.labels.*.name, 'bug')
runs-on: ubuntu-latest
steps:
- name: Comment on new issue
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
### Проверьте две вещи:
- вы на последней версии: [Releases](https://github.com/Flowseal/tg-ws-proxy/releases)
- запускали по инструкции для своей ОС: [Быстрый старт](https://github.com/Flowseal/tg-ws-proxy#навигация)
## Решение частых проблем:
**Q**: Не запускается, падает с ошибкой, не работает как раньше после обновления?
**A**:
1. Удалите всё в папке Temp (или хотя бы всё, что начинается с _MEI)
2. Запускайте от имени админа
3. Попробуйте Win7 версию (если вы пользователь Windows)
4. Попробуйте отключить антивирус (если помогло, то добавьте exe в исключения). Не забудьте включить антивирус обратно.
###
**Q**: Не грузит медиа? (фото/видео/стикеры)
**A**: Удалите в настройках прокси в поле **DC → IP** всё, кроме `4:149.154.167.220`. Если это не помогло, полностью очистите это поле.
#### Если проблема решена, то закройте Issue
### Если проблема осталась, пожалуйста, приложите по возможности логи.
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
+2 -5
View File
@@ -6,6 +6,8 @@ __pycache__/
dist/ dist/
build/ build/
*.spec.bak *.spec.bak
venv/
.venv/
# PyInstaller # PyInstaller
*.manifest *.manifest
@@ -22,9 +24,4 @@ Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
# Project-specific (not for the repo)
scan_ips.py
scan.txt
AyuGramDesktop-dev/
tweb-master/
/icon.icns /icon.icns
+4 -2
View File
@@ -24,7 +24,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PATH=/opt/venv/bin:$PATH \ PATH=/opt/venv/bin:$PATH \
TG_WS_PROXY_HOST=0.0.0.0 \ TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1443 \ TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \ && apt-get install -y --no-install-recommends tini ca-certificates \
@@ -41,5 +43,5 @@ USER app
EXPOSE 1443/tcp EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; 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 [] CMD []
+75
View File
@@ -0,0 +1,75 @@
# Установка из исходников
## Консольный прокси
Для запуска только прокси без интерфейса системного трея достаточно базовой установки:
```bash
pip install -e .
tg-ws-proxy
```
## Tray-приложение по ОС
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
## Консольный режим из исходников
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Аргументы:**
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1443` | Порт прокси |
| `--host` | `127.0.0.1` | Хост прокси |
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--buf-kb` | `256` | Размер буфера в КБ |
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
```bash
# Стандартный запуск
tg-ws-proxy
# Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
tg-ws-proxy -v
# Fake TLS маскировка (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
```
+48
View File
@@ -0,0 +1,48 @@
# CONTRIBUTING
Спасибо за желание помочь проекту `tg-ws-proxy`.
## Перед созданием issue
1. Проверьте документацию в `docs/README.md`.
2. Убедитесь, что похожий issue еще не открыт.
3. Для корректной работы triage используйте стандартные лейблы из `.github/labels.md`.
## Как сообщать о проблемах
- Используйте шаблон `Проблема`.
- По возможности укажите:
- версию приложения,
- ОС,
- шаги воспроизведения,
- ожидаемое и фактическое поведение,
- лог-файл или текст ошибки.
Чем точнее описание, тем быстрее можно помочь.
## Локальный запуск из исходников
Требуется Python `>=3.8`.
```bash
pip install -e .
```
Запуск:
- консольный режим: `tg-ws-proxy`
- Windows tray: `tg-ws-proxy-tray-win`
- macOS tray: `tg-ws-proxy-tray-macos`
- Linux tray: `tg-ws-proxy-tray-linux`
Подробности: `docs/BuildFromSource.md`.
## Pull Request
Перед открытием PR:
1. Убедитесь, что изменение решает конкретную проблему.
2. Проверьте, что не сломаны существующие сценарии.
3. Обновите документацию, если меняется поведение или настройка.
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
+15 -12
View File
@@ -1,18 +1,20 @@
# Cloudflare Proxy # Cloudflare-прокси
Для недоступных датацентров можно использовать альтернативный бесплатный метод подключения - проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и лучше) заменить на свой. Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
Прокси возвращает доступ к тому, что до этого не грузило (реакциям, некоторым стикерам). Если у вас до этого не грузило видео/фото на аккаунте без премиума, то уберите всё кроме `4:149.154.167.220` из `DC->IP` блока в настройках. Если CF-прокси у вас работает - медиа снова начнёт грузиться. Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
## Зачем мне настраивать свой домен? ## Зачем мне настраивать свой домен?
Cloudflare имеет лимиты на одновременное количество подключений WS. Домен по умолчанию может перестать работать в любой момент.
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
## Настройка своего домена ## Настройка своего домена
1. Добавьте свой домен в Cloudflare (либо купив у них напрямую, либо поменяв NS сервера: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят +- 150 рублей на год, подойдёт любой.
2. В `SSL/TLS` -> `Overview` выставьте режим **Flexible** 1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
3. В `DNS` -> `Records` добавьте следующие `A` записи через `+ Add Record`: 2. В `SSL/TLS` `Overview` выставьте режим **Flexible**.
3. В `DNS``Records` добавьте следующие `A`-записи через `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50` - Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51` - Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100` - Name=`kws3` IPv4=`149.154.175.100`
@@ -20,10 +22,11 @@ Cloudflare имеет лимиты на одновременное количе
- Name=`kws5` IPv4=`149.154.171.5` - Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100` - Name=`kws203` IPv4=`91.105.192.100`
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или другой софт для обхода блокировок, так как подсеть Cloudflare забанена (по крайней мере, если вы из России)** 4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
5. В настройках TgWsProxy поменяйте домен на свой 5. В настройках `TgWsProxy` замените домен на свой.
## Mentions ## Благодарности
Idea - https://github.com/Nekogram/WSProxy
Thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for the information - Идея: https://github.com/Nekogram/WSProxy
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
+124
View File
@@ -0,0 +1,124 @@
# Cloudflare Worker
Альтернативный (полностью бесплатный, не нужно покупать домен в отличии от [CfProxy](./CfProxy.md)) способ проксирования.
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium с данным способом все еще не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`
##
1. **Добавьте в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО следующие домены:**
```
cloudflare.com
cloudflare.dev
workers.dev
```
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
3. Слева в панели выберите `Compute``Workers & Pages`
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
4. Нажмите сверху справа кнопку **`Create application`** → `Start with Hello World!``Deploy`
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
5. Сверху справа нажмите кнопку **`Edit code`**, замените код слева на тот, [что находится внизу этой страницы](./CfWorker.md#код-workerа)
* Если у вас не загружается код, то вы не выполнили первый пункт
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
6. Нажмите сверху справа кнопку **`Deploy`**
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
* Пример домена: `random-symbols-1234.username.workers.dev`
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
### Код Worker'а
```javascript
import { connect } from "cloudflare:sockets";
function toBytes(data) {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data && typeof data.arrayBuffer === "function") {
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
}
return new Uint8Array();
}
export default {
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
return new Response("Not found", { status: 404 });
}
const dst = url.searchParams.get("dst");
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
const socket = connect({ hostname: dst, port: 443 });
const tcpReader = socket.readable.getReader();
const tcpWriter = socket.writable.getWriter();
server.addEventListener("message", async (event) => {
try {
await tcpWriter.write(await toBytes(event.data));
} catch {
try {
server.close(1011, "tcp write failed");
} catch {}
}
});
server.addEventListener("close", async () => {
try {
await tcpWriter.close();
} catch {}
try {
socket.close();
} catch {}
});
(async () => {
try {
while (true) {
const { value, done } = await tcpReader.read();
if (done) {
break;
}
if (value) {
server.send(value);
}
}
} catch {
} finally {
try {
server.close();
} catch {}
try {
tcpReader.releaseLock();
} catch {}
try {
socket.close();
} catch {}
}
})();
return new Response(null, { status: 101, webSocket: client });
},
};
```
+52
View File
@@ -0,0 +1,52 @@
# Fake TLS + upstream в nginx
Домен в параметре `--fake-tls-domain` должен указывать на тот же IP, на котором запущен прокси.
## Пример `nginx.conf` для stream-модуля
```nginx
upstream mtproto {
server 127.0.0.1:8446;
}
map $ssl_preread_server_name $sni_name {
hostnames;
example.com mtproto;
# if you have xray with selfsni running:
# sub.example.com www;
# default xray;
}
# upstream xray {
# server 127.0.0.1:8443;
# }
#
# upstream www {
# server 127.0.0.1:7443;
# }
server {
proxy_protocol on;
set_real_ip_from unix:;
listen 443;
proxy_pass $sni_name;
ssl_preread on;
}
```
## Запуск прокси за Nginx
```bash
python3 proxy/tg_ws_proxy.py \
--port 8446 \
--host 127.0.0.1 \
--fake-tls-domain example.com \
--proxy-protocol \
--secret <32-hex-chars>
```
Ссылка для подключения будет в формате `ee`-секрета:
```text
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
```
+12
View File
@@ -0,0 +1,12 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
Буду благодарен за любую форму поддержки! Спасибо ❤️
+70
View File
@@ -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:** из настроек или логов
+51
View File
@@ -0,0 +1,51 @@
# TG WS Proxy для Linux
## Готовые сборки
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет `TgWsProxy_linux_amd64.deb`.
Для Arch и основанных на Arch дистрибутивов подготовлены пакеты в AUR:
- [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin)
- [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git)
- [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Установка без AUR-helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
# Для пакета -cli запуск через systemd (8888 — номер порта; secret можно сгенерировать командой openssl rand -hex 16)
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
```
Для остальных дистрибутивов можно использовать `TgWsProxy_linux_amd64` (бинарный файл для x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Настройка Telegram Desktop
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
+30
View File
@@ -0,0 +1,30 @@
# TG WS Proxy для macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте `TgWsProxy_macos_universal.dmg` (универсальная сборка для Apple Silicon и Intel).
1. Откройте образ
2. Перенесите `TG WS Proxy.app` в папку `Applications`
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
Минимально поддерживаемые версии:
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
## Настройка Telegram Desktop
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавьте прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
+94 -182
View File
@@ -1,28 +1,96 @@
<div align="center">
<br />
<p>
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
</p>
</div>
##
> [!TIP] > [!TIP]
> >
> ### 🎉 Поддержать меня > ### [🎉 Поддержать меня](./Funding.md)
> >
> USDT (TRC20): `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> BTC: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> ETH: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
> ### Реакция антивирусов > ### Реакция антивирусов
> >
> Windows Defender часто ошибочно помечает приложение как **Wacatac**. > Антивирусы часто ошибочно помечают приложение как вирус из-за упаковщика.
> Если вы не можете скачать из-за блокировки, то: > Если вы не можете скачать из-за блокировки антивирусом, то:
> >
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала) > 1) **Попробуйте скачать версию для Windows 7 (по функциональности она не отличается)**
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно > 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
> >
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal** > Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
# TG WS Proxy # TG WS Proxy
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. **Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" /> <picture>
<source srcset="./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:** из настроек или логов
## Как это работает ## Как это работает
@@ -33,194 +101,38 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443` 1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
2. Перехватывает подключения к IP-адресам Telegram 2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета 3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram 4. Устанавливает WebSocket-соединение (TLS) к соответствующему DC через домены Telegram
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение 5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
> [!IMPORTANT] > [!IMPORTANT]
> ### Не грузит фото/видео? > ### Не грузит фото/видео?
> ### Удалите в настройках прокси в DC->IP всё, кроме `4:149.154.167.220` > **Удалите в настройках прокси в DCIP всё, кроме `4:149.154.167.220`**
> **Если это не помогло, полностью очистите это поле**
> Подобная проблема встречается на аккаунтах без Premium > Подобная проблема встречается на аккаунтах без Premium
> Если вам не помогло, то настраивайте свой домен по гайду отсюда: https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md > Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md)
## 🚀 Быстрый старт
### 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://proxy` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
1. Открыть образ
2. Перенести **TG WS Proxy.app** в папку **Applications**
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
### Linux
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**.
Для Arch и Arch-Based дистрибутивов подготовлены пакеты в AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
```shell
# Установка без AUR-helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Установка из исходников
### Консольный proxy
Для запуска только proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
tg-ws-proxy
```
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
### Консольный режим из исходников
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Аргументы:**
| Аргумент | По умолчанию | Описание |
|---|---|---|
| `--port` | `1443` | Порт прокси |
| `--host` | `127.0.0.1` | Хост прокси |
| `--secret` | `random` | 32 hex chars secret для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare]((https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md)) |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudfalre. [Подробнее тут](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md) |
| `--cfproxy-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
| `--buf-kb` | `256` | Размер буфера в КБ |
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
| `--log-file` | выкл. | Путь до файла, в который сохранять логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
```bash
# Стандартный запуск
tg-ws-proxy
# Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
tg-ws-proxy -v
```
## Настройка Telegram Desktop
### Автоматически
ПКМ по иконке в трее → **«Открыть в Telegram»**
### Вручную
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Конфигурация
Tray-приложение хранит данные в:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"host": "127.0.0.1",
"port": 1443,
"secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"check_updates": true
}
```
Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему).
## Автоматическая сборка ## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](../packaging/windows.spec), [`packaging/macos.spec`](../packaging/macos.spec), [`packaging/linux.spec`](../packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](../.github/workflows/build.yml)) для автоматической сборки.
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy_windows.exe` - Windows 10+ x64 для `TgWsProxy_windows.exe`
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe` - Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe` - Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+ - Intel macOS 10.15+
- Apple Silicon macOS 11.0+ - Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея) - Linux x86_64 (требуется AppIndicator для системного трея)
## Контрибьюторы
Спасибо всем, кто помогает развивать проект ❤️
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
</a>
## Лицензия ## Лицензия
[MIT License](LICENSE) [MIT License](../LICENSE)
+57
View File
@@ -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
```
+31
View File
@@ -0,0 +1,31 @@
# Файлы конфигурации Tray-приложения
Tray-приложение хранит данные в:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
```json
{
"host": "127.0.0.1",
"port": 1443,
"secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"check_updates": true,
"cfproxy": true,
"cfproxy_user_domain": "",
"cfproxy_worker_domain": "",
"appearance": "auto"
}
```
Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки).
На Windows в конфиге может быть `autostart` (автозапуск при входе в систему).
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+66 -30
View File
@@ -30,6 +30,7 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
) )
from ui.i18n import set_language, t
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
@@ -53,16 +54,16 @@ def _msgbox(kind: str, text: str, title: str, **kw):
return result return result
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: def _show_error(text: str, title: Optional[str] = None) -> None:
_msgbox("showerror", text, title) _msgbox("showerror", text, title or t("app.error_title"))
def _show_info(text: str, title: str = "TG WS Proxy") -> None: def _show_info(text: str, title: Optional[str] = None) -> None:
_msgbox("showinfo", text, title) _msgbox("showinfo", text, title or t("app.name"))
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
return bool(_msgbox("askyesno", text, title)) return bool(_msgbox("askyesno", text, title or t("app.name")))
def _apply_window_icon(root) -> None: def _apply_window_icon(root) -> None:
@@ -80,12 +81,10 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
log.info("Copying %s", url) log.info("Copying %s", url)
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(t("dialog.copy_ok", url=url))
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
)
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(t("dialog.copy_fail", error=exc))
def _on_copy_link(icon=None, item=None) -> None: def _on_copy_link(icon=None, item=None) -> None:
@@ -95,7 +94,7 @@ def _on_copy_link(icon=None, item=None) -> None:
pyperclip.copy(url) pyperclip.copy(url)
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(t("dialog.copy_fail", error=exc))
def _on_restart(icon=None, item=None) -> None: def _on_restart(icon=None, item=None) -> None:
@@ -118,7 +117,7 @@ def _on_open_logs(icon=None, item=None) -> None:
stdin=subprocess.DEVNULL, start_new_session=True, stdin=subprocess.DEVNULL, start_new_session=True,
) )
else: else:
_show_info("Файл логов ещё не создан.") _show_info(t("dialog.log_not_found"))
def _on_exit(icon=None, item=None) -> None: def _on_exit(icon=None, item=None) -> None:
@@ -139,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None:
def _edit_config_dialog() -> None: def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.") _show_error(t("dialog.ctk_missing"))
return return
cfg = dict(_config) cfg = dict(_config)
@@ -148,40 +147,77 @@ def _edit_config_dialog() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=_apply_window_icon, after_create=_apply_window_icon,
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
def _refresh_tray_menu() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
on_language_change=_refresh_tray_menu,
)
_original_appearance = ctk.get_appearance_mode()
def _restore_ui_locale() -> None:
set_language(_original_language)
_refresh_tray_menu()
def _finish() -> None: def _finish() -> None:
root.destroy() root.destroy()
done.set() done.set()
def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance)
_restore_ui_locale()
_finish()
def on_save() -> None: def on_save() -> None:
from tkinter import messagebox from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str): if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) messagebox.showerror(t("app.error_title"), merged, parent=root)
return return
_ui_only_keys = {"appearance", "check_updates", "language"}
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_restore_ui_locale()
_finish()
return
save_config(merged) save_config(merged)
_config.update(merged) _config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged) log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu() _tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno( do_restart = messagebox.askyesno(
"Перезапустить?", t("dialog.restart_title"),
"Настройки сохранены.\n\nПерезапустить прокси сейчас?", t("dialog.restart_body"),
parent=root, parent=root,
) )
_finish() _finish()
if do_restart: if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish) root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish) install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
ctk_run_dialog(_build) ctk_run_dialog(_build)
@@ -205,7 +241,7 @@ def _show_first_run() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme, ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=_apply_window_icon, after_create=_apply_window_icon,
) )
@@ -229,14 +265,14 @@ def _build_menu():
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) link_host = get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem(t("tray.restart"), _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem(t("tray.settings"), _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit), pystray.MenuItem(t("tray.exit"), _on_exit),
) )
@@ -264,7 +300,7 @@ def run_tray() -> None:
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) _tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@@ -273,8 +309,8 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if not acquire_lock("linux.py"): if not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
+94 -44
View File
@@ -9,22 +9,59 @@ import webbrowser
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
try:
import rumps
except ImportError:
rumps = None
try: try:
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
except ImportError: except ImportError:
Image = ImageDraw = ImageFont = None Image = ImageDraw = ImageFont = None
def render_app_icon(size: int):
scale = size / 1024
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
outer = tuple(round(value * scale) for value in (92, 92, 932, 932))
draw.ellipse(outer, fill=(0, 151, 221, 255))
try:
font = ImageFont.truetype(
"/System/Library/Fonts/Helvetica.ttc",
round(430 * scale),
)
except Exception:
font = ImageFont.load_default()
box = draw.textbbox((0, 0), "T", font=font)
width = box[2] - box[0]
height = box[3] - box[1]
draw.text(
(
(size - width) / 2 - box[0],
(size - height) / 2 - box[1] - round(10 * scale),
),
"T",
font=font,
fill=(255, 255, 255, 255),
)
return image
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon":
if Image is None:
raise SystemExit("Pillow is required to render the macOS app icon")
output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns"
render_app_icon(1024).save(output_path, format="ICNS")
raise SystemExit(0)
try:
import rumps
except ImportError:
rumps = None
try: try:
import pyperclip import pyperclip
except ImportError: except ImportError:
pyperclip = None pyperclip = None
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.tg_ws_proxy import _run from proxy.tg_ws_proxy import _run
from utils.tray_common import ( from utils.tray_common import (
@@ -32,6 +69,7 @@ from utils.tray_common import (
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
) )
from utils.diagnostics import diagnose_listen_error
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
@@ -41,6 +79,8 @@ _app: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
# osascript dialogs # osascript dialogs
@@ -109,32 +149,42 @@ def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> O
return r.stdout.rstrip("\r\n") return r.stdout.rstrip("\r\n")
def _ask_cfworker_domain(default: str) -> Optional[str]:
value = default
while True:
script = (
f'set d to display dialog "{_esc("Cloudflare Worker домены через запятую (например, name.account.workers.dev):")}" '
f'default answer "{_esc(value)}" '
f'with title "TG WS Proxy" '
f'buttons {{"Закрыть", "?", "OK"}} '
f'default button "OK" cancel button "Закрыть"\n'
f'return (button returned of d) & "\\n" & (text returned of d)'
)
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
if r.returncode != 0:
return None
out_lines = r.stdout.splitlines()
button = out_lines[0].strip() if out_lines else ""
value = out_lines[1].strip() if len(out_lines) > 1 else value
if button == "?":
webbrowser.open(_CFWORKER_HELP_URL)
continue
if button == "OK":
return value.strip()
# menubar icon # menubar icon
def _make_menubar_icon(size: int = 44): def _make_menubar_icon(size: int = 44):
if Image is None: if Image is None:
return None return None
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) return render_app_icon(size)
draw = ImageDraw.Draw(img)
margin = size // 11
draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255))
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55))
except Exception:
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 _ensure_menubar_icon() -> None: def _ensure_menubar_icon() -> None:
if MENUBAR_ICON_PATH.exists():
return
ensure_dirs() ensure_dirs()
img = _make_menubar_icon(44) img = _make_menubar_icon(44)
if img: if img:
@@ -156,13 +206,9 @@ def _run_proxy_thread() -> None:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc): msg, _ = diagnose_listen_error(exc)
_show_error( if msg:
"Не удалось запустить прокси:\n" _show_error(msg)
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -309,7 +355,7 @@ def _maybe_notify_update_async() -> None:
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.debug("Update check failed: %s", exc) log.warning("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()
@@ -396,21 +442,25 @@ def _edit_config_dialog() -> None:
if cfproxy is None: if cfproxy is None:
return return
cfproxy_priority = True
if cfproxy:
cfproxy_priority_result = _ask_yes_no_close("Приоритет CfProxy (пробовать раньше прямого TCP)?")
if cfproxy_priority_result is None:
return
cfproxy_priority = cfproxy_priority_result
cfproxy_domain = _osascript_input( cfproxy_domain = _osascript_input(
"Свой CF-домен (оставьте пустым для автоматического выбора):\n" "Свои CF-домены через запятую (оставьте пустым для автоматического выбора):\n"
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.", "DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")), ", ".join(coerce_domain_list(
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", []))
)),
) )
if cfproxy_domain is None: if cfproxy_domain is None:
return return
cfproxy_domain = cfproxy_domain.strip() cfproxy_domains = coerce_domain_list(cfproxy_domain)
cfworker_domain = _ask_cfworker_domain(
", ".join(coerce_domain_list(
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", []))
))
)
if cfworker_domain is None:
return
cfworker_domains = coerce_domain_list(cfworker_domain)
new_cfg = { new_cfg = {
"host": host, "host": host,
@@ -423,8 +473,8 @@ def _edit_config_dialog() -> None:
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
"check_updates": cfg.get("check_updates", True), "check_updates": cfg.get("check_updates", True),
"cfproxy": cfproxy, "cfproxy": cfproxy,
"cfproxy_priority": cfproxy_priority, "cfproxy_user_domain": cfproxy_domains,
"cfproxy_user_domain": cfproxy_domain, "cfproxy_worker_domain": cfworker_domains,
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
@@ -610,7 +660,7 @@ def run_menubar() -> None:
def main() -> None: def main() -> None:
if not acquire_lock("macos.py"): if not acquire_lock():
_show_info("Приложение уже запущено.") _show_info("Приложение уже запущено.")
return return
try: try:
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+93
View File
@@ -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"
+3 -1
View File
@@ -12,6 +12,8 @@ block_cipher = None
import customtkinter import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) 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 # Collect gi (PyGObject) submodules and data so pystray._appindicator works
gi_hiddenimports = collect_submodules('gi') gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi') gi_datas = collect_data_files('gi')
@@ -26,7 +28,7 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas, datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + gi_datas + typelib_datas,
hiddenimports=[ hiddenimports=[
'pystray._appindicator', 'pystray._appindicator',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
+3 -1
View File
@@ -5,11 +5,13 @@ import os
block_cipher = None block_cipher = None
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis( a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[(_i18n_path, 'ui/i18n')],
hiddenimports=[ hiddenimports=[
'rumps', 'rumps',
'objc', 'objc',
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx # http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 0, 0, 0), filevers=(1, 7, 3, 0),
prodvers=(1, 0, 0, 0), prodvers=(1, 7, 3, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[ [
StringStruct(u'CompanyName', u'Flowseal'), StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'), StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.0.0.0'), StringStruct(u'FileVersion', u'1.7.3.0'),
StringStruct(u'InternalName', u'TgWsProxy'), StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'), StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'), StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'), StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.0.0.0'), StringStruct(u'ProductVersion', u'1.7.3.0'),
] ]
) )
] ]
+3 -1
View File
@@ -9,11 +9,13 @@ block_cipher = None
import customtkinter import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis( a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[(ctk_path, 'customtkinter/')], datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')],
hiddenimports=[ hiddenimports=[
'pystray._win32', 'pystray._win32',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
+4 -4
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host from .utils import get_link_host, build_github_opener
__version__ = "1.6.0" __version__ = "1.7.3"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+130
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
import random
from collections import Counter
from typing import Dict, List, Iterator
class _Balancer:
def __init__(self):
self.domains: List[str] = []
self._dc_to_domain: Dict[int, str] = {}
def update_domains_list(self, domains_list: List[str]) -> None:
if Counter(self.domains) == Counter(domains_list):
return
self.domains = domains_list[:]
self._dc_to_domain = {
dc_id: random.choice(self.domains)
for dc_id in (1, 2, 3, 4, 5, 203)
}
def update_domain_for_dc(self, dc_id: int, domain: str) -> bool:
if self._dc_to_domain.get(dc_id) == domain:
return False
self._dc_to_domain[dc_id] = domain
return True
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
current_domain = self._dc_to_domain.get(dc_id)
if current_domain is not None:
yield current_domain
shuffled_domains = self.domains[:]
random.shuffle(shuffled_domains)
for domain in shuffled_domains:
if domain != current_domain:
yield domain
balancer = _Balancer()
+130 -67
View File
@@ -1,28 +1,24 @@
import asyncio import asyncio
import logging import logging
import struct import struct
import random
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from typing import List, Optional
from typing import Dict, List, Optional from urllib.parse import urlencode
from .utils import * from .utils import *
from .stats import stats from .stats import stats
from .balancer import balancer
from .config import proxy_config from .config import proxy_config
from .raw_websocket import RawWebSocket from .raw_websocket import RawWebSocket
from .pool import cf_worker_pool
from ._aes import Cipher, algorithms, modes
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
_st_I_le = struct.Struct('<I') _st_I_le = struct.Struct('<I')
ZERO_64 = b'\x00' * 64 ZERO_64 = b'\x00' * 64
DC_DEFAULT_IPS: Dict[int, str] = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100'
}
class CryptoCtx: class CryptoCtx:
@@ -62,19 +58,27 @@ class MsgSplitter:
self._plain_buf.extend(self._dec.update(chunk)) self._plain_buf.extend(self._dec.update(chunk))
parts = [] parts = []
while self._cipher_buf: offset = 0
packet_len = self._next_packet_len() 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: if packet_len is None:
break break
if packet_len <= 0: if packet_len <= 0:
parts.append(bytes(self._cipher_buf)) parts.append(bytes(self._cipher_buf[offset:]))
self._cipher_buf.clear() offset = buf_len
self._plain_buf.clear()
self._disabled = True self._disabled = True
break break
parts.append(bytes(self._cipher_buf[:packet_len])) parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
del self._cipher_buf[:packet_len] offset += packet_len
del self._plain_buf[:packet_len]
if offset:
del self._cipher_buf[:offset]
del self._plain_buf[:offset]
return parts return parts
def flush(self) -> List[bytes]: def flush(self) -> List[bytes]:
@@ -85,22 +89,23 @@ class MsgSplitter:
self._plain_buf.clear() self._plain_buf.clear()
return [tail] return [tail]
def _next_packet_len(self) -> Optional[int]: def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
if not self._plain_buf: if avail <= 0:
return None return None
if self._proto == PROTO_ABRIDGED_INT: if self._proto == PROTO_ABRIDGED_INT:
return self._next_abridged_len() return self._next_abridged_len(offset, avail)
if self._proto in (PROTO_INTERMEDIATE_INT, if self._proto in (PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT): PROTO_PADDED_INTERMEDIATE_INT):
return self._next_intermediate_len() return self._next_intermediate_len(offset, avail)
return 0 return 0
def _next_abridged_len(self) -> Optional[int]: def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
first = self._plain_buf[0] first = self._plain_buf[offset]
if first in (0x7F, 0xFF): if first in (0x7F, 0xFF):
if len(self._plain_buf) < 4: if avail < 4:
return None return None
payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4 payload_len = int.from_bytes(
self._plain_buf[offset + 1:offset + 4], 'little') * 4
header_len = 4 header_len = 4
else: else:
payload_len = (first & 0x7F) * 4 payload_len = (first & 0x7F) * 4
@@ -108,41 +113,51 @@ class MsgSplitter:
if payload_len <= 0: if payload_len <= 0:
return 0 return 0
packet_len = header_len + payload_len packet_len = header_len + payload_len
if len(self._plain_buf) < packet_len: if avail < packet_len:
return None return None
return packet_len return packet_len
def _next_intermediate_len(self) -> Optional[int]: def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
if len(self._plain_buf) < 4: if avail < 4:
return None return None
payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
if payload_len <= 0: if payload_len <= 0:
return 0 return 0
packet_len = 4 + payload_len packet_len = 4 + payload_len
if len(self._plain_buf) < packet_len: if avail < packet_len:
return None return None
return packet_len return packet_len
async def do_fallback(reader, writer, relay_init, label, async def do_fallback(reader, writer, relay_init, label,
dc, is_media, media_tag, dc: int, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None): ctx: CryptoCtx, splitter=None):
fallback_dst = DC_DEFAULT_IPS.get(dc) fallback_dst = DC_DEFAULT_IPS.get(dc)
use_cf = proxy_config.fallback_cfproxy use_cf = proxy_config.fallback_cfproxy
cf_first = proxy_config.fallback_cfproxy_priority worker_domains = proxy_config.cfproxy_worker_domains
methods: List[str] = ['tcp'] methods: List[str] = []
if worker_domains and fallback_dst:
methods.append('cf_worker')
if use_cf: if use_cf:
methods.insert(0 if cf_first else 1, 'cf') methods.append('cf')
if fallback_dst:
methods.append('tcp')
for method in methods: for method in methods:
if method == 'cf': 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( ok = await _cfproxy_fallback(
reader, writer, relay_init, label, reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
if ok: if ok:
return True return True
elif method == 'tcp' and fallback_dst: elif method == 'tcp' and fallback_dst:
@@ -150,27 +165,68 @@ async def do_fallback(reader, writer, relay_init, label,
label, dc, media_tag, fallback_dst) label, dc, media_tag, fallback_dst)
ok = await _tcp_fallback( ok = await _tcp_fallback(
reader, writer, fallback_dst, 443, reader, writer, fallback_dst, 443,
relay_init, label, dc=dc, is_media=is_media, ctx=ctx) relay_init, label, ctx)
if ok: if ok:
return True return True
return False return False
async def _cfproxy_fallback(reader, writer, relay_init, label, async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
dc=None, is_media=False, ctx: CryptoCtx,
ctx: CryptoCtx = None, splitter=None): dc: int, is_media: bool,
fallback_dst: str,
splitter=None):
media_tag = ' media' if is_media else '' media_tag = ' media' if is_media else ''
worker_domains = proxy_config.cfproxy_worker_domains
if not worker_domains:
return False
active = proxy_config.active_cfproxy_domain random.shuffle(worker_domains)
others = [d for d in proxy_config.cfproxy_domains if d != active]
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 ws = None
chosen_domain = None chosen_domain = None
log.info("[%s] DC%d%s -> trying CF proxy", log.info("[%s] DC%d%s -> trying CF proxy",
label, dc, media_tag) label, dc, media_tag)
for base_domain in ([active] + others): for base_domain in balancer.get_domains_for_dc(dc):
domain = f'kws{dc}.{base_domain}' domain = f'kws{dc}.{base_domain}'
try: try:
ws = await RawWebSocket.connect(domain, domain, timeout=10.0) ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
@@ -178,45 +234,42 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
break break
except Exception as exc: except Exception as exc:
log.warning("[%s] DC%d%s CF proxy failed: %s", log.warning("[%s] DC%d%s CF proxy failed: %s",
label, dc, media_tag, exc) label, dc, media_tag, repr(exc))
if ws is None: if ws is None:
return False return False
if chosen_domain and chosen_domain != proxy_config.active_cfproxy_domain: if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
log.info("[%s] Switching active CF domain", label) log.info("[%s] Switched active CF domain", label)
proxy_config.active_cfproxy_domain = chosen_domain
stats.connections_cfproxy += 1 stats.connections_cfproxy += 1
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
return True return True
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
dc=None, is_media=False, ctx: CryptoCtx = None):
try: try:
rr, rw = await asyncio.wait_for( rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10) asyncio.open_connection(dst, port), timeout=10)
except Exception as exc: except Exception as exc:
log.warning("[%s] TCP fallback to %s:%d failed: %s", log.warning("[%s] TCP fallback to %s:%d failed: %s",
label, dst, port, exc) label, dst, port, repr(exc))
return False return False
stats.connections_tcp_fallback += 1 stats.connections_tcp_fallback += 1
rw.write(relay_init) rw.write(relay_init)
await rw.drain() await rw.drain()
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
dc=dc, is_media=is_media, ctx=ctx)
return True return True
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
ctx: CryptoCtx,
dc=None, is_media=False, dc=None, is_media=False,
ctx: CryptoCtx = None, splitter: Optional[MsgSplitter] = None):
splitter: MsgSplitter = None):
""" """
Bidirectional TCP(client) <-> WS(telegram) with re-encryption. Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
client ciphertext decrypt(clt_key) encrypt(tg_key) WS client ciphertext decrypt(clt_key) encrypt(tg_key) WS
@@ -229,9 +282,10 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
up_packets = 0 up_packets = 0
down_packets = 0 down_packets = 0
start_time = asyncio.get_running_loop().time() start_time = asyncio.get_running_loop().time()
close_reason = 'normal'
async def tcp_to_ws(): async def tcp_to_ws():
nonlocal up_bytes, up_packets nonlocal up_bytes, up_packets, close_reason
try: try:
while True: while True:
chunk = await reader.read(65536) chunk = await reader.read(65536)
@@ -257,17 +311,22 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
await ws.send(parts[0]) await ws.send(parts[0])
else: else:
await ws.send(chunk) await ws.send(chunk)
except (asyncio.CancelledError, ConnectionError, OSError): except asyncio.CancelledError:
return return
except (ConnectionError, OSError) as e:
close_reason = f"client: {type(e).__name__}"
except Exception as e: except Exception as e:
close_reason = f"client: {type(e).__name__}: {e}"
log.debug("[%s] tcp->ws ended: %s", label, e) log.debug("[%s] tcp->ws ended: %s", label, e)
async def ws_to_tcp(): async def ws_to_tcp():
nonlocal down_bytes, down_packets nonlocal down_bytes, down_packets, close_reason
try: try:
while True: while True:
data = await ws.recv() data = await ws.recv()
if data is None: if data is None:
if close_reason == 'normal':
close_reason = 'upstream: ws_close'
break break
n = len(data) n = len(data)
stats.bytes_down += n stats.bytes_down += n
@@ -277,9 +336,14 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
data = ctx.clt_enc.update(plain) data = ctx.clt_enc.update(plain)
writer.write(data) writer.write(data)
await writer.drain() await writer.drain()
except (asyncio.CancelledError, ConnectionError, OSError): except asyncio.CancelledError:
return 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: except Exception as e:
close_reason = f"upstream: {type(e).__name__}: {e}"
log.debug("[%s] ws->tcp ended: %s", label, e) log.debug("[%s] ws->tcp ended: %s", label, e)
tasks = [asyncio.create_task(tcp_to_ws()), tasks = [asyncio.create_task(tcp_to_ws()),
@@ -295,9 +359,9 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
except BaseException: except BaseException:
pass pass
elapsed = asyncio.get_running_loop().time() - start_time elapsed = asyncio.get_running_loop().time() - start_time
log.info("[%s] %s WS session closed: " log.info("[%s] %s WS session closed (%s): "
"^%s (%d pkts) v%s (%d pkts) in %.1fs", "^%s (%d pkts) v%s (%d pkts) in %.1fs",
label, dc_tag, label, dc_tag, close_reason,
human_bytes(up_bytes), up_packets, human_bytes(up_bytes), up_packets,
human_bytes(down_bytes), down_packets, human_bytes(down_bytes), down_packets,
elapsed) elapsed)
@@ -313,8 +377,7 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer, async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
label, dc=None, is_media=False, label, ctx: CryptoCtx):
ctx: CryptoCtx = None):
"""Bidirectional TCP <-> TCP with re-encryption.""" """Bidirectional TCP <-> TCP with re-encryption."""
async def forward(src, dst_w, is_up): async def forward(src, dst_w, is_up):
+131 -22
View File
@@ -7,7 +7,10 @@ import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List from typing import Dict, List
from urllib.request import Request, urlopen from urllib.request import Request
from .balancer import balancer
from .utils import build_github_opener
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
@@ -16,7 +19,28 @@ CFPROXY_DOMAINS_URL = (
"/.github/cfproxy-domains.txt" "/.github/cfproxy-domains.txt"
) )
_CFPROXY_ENC: List[str] = ['virkgj.com'] _CFPROXY_ENC: List[str] = [
'virkgj.com',
'vmmzovy.com',
'mkuosckvso.com',
'zaewayzmplad.com',
'twdmbzcm.com',
'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)) _S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
@@ -32,6 +56,7 @@ def _dd(s: str) -> str:
CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC] CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC]
_CFPROXY_MIN_VALID_DOMAINS = 3
@dataclass @dataclass
@@ -43,20 +68,44 @@ class ProxyConfig:
buffer_size: int = 256 * 1024 buffer_size: int = 256 * 1024
pool_size: int = 4 pool_size: int = 4
fallback_cfproxy: bool = True fallback_cfproxy: bool = True
fallback_cfproxy_priority: bool = True cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_user_domain: str = '' cfproxy_worker_domains: List[str] = field(default_factory=list)
cfproxy_domains: List[str] = field(default_factory=lambda: list(CFPROXY_DEFAULT_DOMAINS)) fake_tls_domain: str = ''
active_cfproxy_domain: str = field(default_factory=lambda: random.choice(CFPROXY_DEFAULT_DOMAINS)) proxy_protocol: bool = False
proxy_config = ProxyConfig() proxy_config = ProxyConfig()
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]: def _fetch_cfproxy_domain_list() -> List[str]:
try: try:
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)), req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
headers={'User-Agent': 'tg-ws-proxy'}) headers={'User-Agent': 'tg-ws-proxy'})
with urlopen(req, timeout=10) as resp: with build_github_opener().open(req, timeout=10) as resp:
text = resp.read().decode('utf-8', errors='replace') text = resp.read().decode('utf-8', errors='replace')
encoded = [ encoded = [
line.strip() for line in text.splitlines() line.strip() for line in text.splitlines()
@@ -64,46 +113,106 @@ def _fetch_cfproxy_domain_list() -> List[str]:
] ]
return [_dd(d) for d in encoded] return [_dd(d) for d in encoded]
except Exception as exc: except Exception as exc:
log.warning("Failed to fetch CF proxy domain list: %s", exc) log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
return [] return []
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: def refresh_cfproxy_domains() -> None:
if proxy_config.cfproxy_user_domain: if proxy_config.cfproxy_user_domains:
return return
fetched = _fetch_cfproxy_domain_list() 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: if fetched:
seen = set() log.warning(
pool = [d for d in fetched if not (d in seen or seen.add(d))] "Ignoring fetched CF proxy domains due to low-quality payload "
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool)) "(total=%d, valid=%d, required>=%d); keeping current domain pool",
len(fetched), len(pool), _CFPROXY_MIN_VALID_DOMAINS,
)
else: else:
pool = list(proxy_config.cfproxy_domains) or list(CFPROXY_DEFAULT_DOMAINS) log.warning(
"CF proxy domain refresh failed or empty response; "
"keeping current domain pool",
)
proxy_config.cfproxy_domains = pool
proxy_config.active_cfproxy_domain = random.choice(pool) _refresh_stop: threading.Event = threading.Event()
def start_cfproxy_domain_refresh() -> None: def start_cfproxy_domain_refresh() -> None:
threading.Thread( global _refresh_stop
target=refresh_cfproxy_domains, _refresh_stop.set()
daemon=True, _refresh_stop = threading.Event()
name='cfproxy-domains-refresh', stop = _refresh_stop
).start()
balancer.update_domains_list(CFPROXY_DEFAULT_DOMAINS)
def _loop():
refresh_cfproxy_domains()
while not stop.wait(timeout=3600):
refresh_cfproxy_domains()
threading.Thread(target=_loop, daemon=True, name='cfproxy-domains-refresh').start()
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
dc_redirects: Dict[int, str] = {} dc_redirects: Dict[int, str] = {}
for entry in dc_ip_list: for entry in dc_ip_list:
if ':' not in entry: if ':' not in entry:
raise ValueError( err = ValueError(
f"Invalid --dc-ip format {entry!r}, expected DC:IP") 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) dc_s, ip_s = entry.split(':', 1)
try: try:
dc_n = int(dc_s) dc_n = int(dc_s)
_socket.inet_aton(ip_s) _socket.inet_aton(ip_s)
except (ValueError, OSError): except (ValueError, OSError):
raise ValueError(f"Invalid --dc-ip {entry!r}") err = ValueError(f"Invalid --dc-ip {entry!r}")
err.entry = entry
err.kind = "invalid"
raise err
dc_redirects[dc_n] = ip_s dc_redirects[dc_n] = ip_s
return dc_redirects return dc_redirects
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import asyncio
import hmac
import hashlib
import os
import random
import struct
import time
import logging
from typing import Optional, Tuple
from .stats import stats
log = logging.getLogger('tg-mtproto-proxy')
TLS_RECORD_HANDSHAKE = 0x16
TLS_RECORD_CCS = 0x14
TLS_RECORD_APPDATA = 0x17
TLS_VERSION_10 = b'\x03\x01'
TLS_VERSION_12 = b'\x03\x03'
TLS_VERSION_13 = b'\x03\x04'
CLIENT_RANDOM_OFFSET = 11
CLIENT_RANDOM_LEN = 32
SESSION_ID_OFFSET = 44
SESSION_ID_LEN = 32
TIMESTAMP_TOLERANCE = 120
TLS_APPDATA_MAX = 16384
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
_SERVER_HELLO_TEMPLATE = bytearray(
b'\x16\x03\x03\x00\x7a'
b'\x02\x00\x00\x76'
b'\x03\x03'
+ b'\x00' * 32
+ b'\x20'
+ b'\x00' * 32
+ b'\x13\x01\x00'
+ b'\x00\x2e'
+ b'\x00\x33\x00\x24\x00\x1d\x00\x20'
+ b'\x00' * 32
+ b'\x00\x2b\x00\x02\x03\x04'
)
_SH_RANDOM_OFF = 11
_SH_SESSID_OFF = 44
_SH_PUBKEY_OFF = 89
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
n = len(data)
# 5 (record hdr) + 6 (hs type+len+version) + 32 (random) = 43
if n < 43:
return None
if data[0] != TLS_RECORD_HANDSHAKE:
return None
if data[5] != 0x01:
return None
client_random = bytes(data[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN])
zeroed = bytearray(data)
zeroed[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = b'\x00' * CLIENT_RANDOM_LEN
expected = hmac.new(secret, bytes(zeroed), hashlib.sha256).digest()
if not hmac.compare_digest(expected[:28], client_random[:28]):
return None
ts_xor = bytes(client_random[28 + i] ^ expected[28 + i] for i in range(4))
timestamp = struct.unpack('<I', ts_xor)[0]
now = int(time.time())
if abs(now - timestamp) > TIMESTAMP_TOLERANCE:
return None
session_id = b'\x00' * SESSION_ID_LEN
if n >= SESSION_ID_OFFSET + SESSION_ID_LEN and data[43] == 0x20:
session_id = bytes(data[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN])
return client_random, session_id, timestamp
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -> bytes:
sh = bytearray(_SERVER_HELLO_TEMPLATE)
sh[_SH_SESSID_OFF:_SH_SESSID_OFF + 32] = session_id
sh[_SH_PUBKEY_OFF:_SH_PUBKEY_OFF + 32] = os.urandom(32)
ccs = _CCS_FRAME
encrypted_size = random.randint(1900, 2100)
encrypted_data = os.urandom(encrypted_size)
app_record = b'\x17\x03\x03' + struct.pack('>H', encrypted_size) + encrypted_data
response = bytes(sh) + ccs + app_record
hmac_input = client_random + response
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
final = bytearray(response)
final[_SH_RANDOM_OFF:_SH_RANDOM_OFF + 32] = server_random
return bytes(final)
def wrap_tls_record(data: bytes) -> bytes:
parts = []
offset = 0
while offset < len(data):
chunk = data[offset:offset + TLS_APPDATA_MAX]
parts.append(
b'\x17\x03\x03'
+ struct.pack('>H', len(chunk))
+ chunk
)
offset += len(chunk)
return b''.join(parts)
class FakeTlsStream:
__slots__ = ('_reader', '_writer', '_read_buf', '_read_left')
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self._reader = reader
self._writer = writer
self._read_buf = bytearray()
self._read_left = 0
async def readexactly(self, n: int) -> bytes:
while len(self._read_buf) < n:
payload = await self._read_tls_payload()
if not payload:
raise asyncio.IncompleteReadError(bytes(self._read_buf), n)
self._read_buf.extend(payload)
result = bytes(self._read_buf[:n])
del self._read_buf[:n]
return result
async def read(self, n: int) -> bytes:
if self._read_buf:
chunk = bytes(self._read_buf[:n])
del self._read_buf[:n]
return chunk
payload = await self._read_tls_payload()
if not payload:
return b''
if len(payload) > n:
self._read_buf.extend(payload[n:])
return payload[:n]
return payload
async def _read_tls_payload(self) -> bytes:
if self._read_left > 0:
data = await self._reader.read(self._read_left)
if not data:
return b''
self._read_left -= len(data)
return data
while True:
hdr = await self._reader.readexactly(5)
rtype = hdr[0]
rec_len = struct.unpack('>H', hdr[3:5])[0]
if rtype == TLS_RECORD_CCS:
if rec_len > 0:
await self._reader.readexactly(rec_len)
continue
if rtype != TLS_RECORD_APPDATA:
return b''
data = await self._reader.read(min(rec_len, 65536))
if not data:
return b''
remaining = rec_len - len(data)
if remaining > 0:
self._read_left = remaining
return data
def write(self, data: bytes) -> None:
self._writer.write(wrap_tls_record(data))
async def drain(self) -> None:
await self._writer.drain()
def close(self) -> None:
self._writer.close()
async def wait_closed(self) -> None:
await self._writer.wait_closed()
def get_extra_info(self, name, default=None):
return self._writer.get_extra_info(name, default)
@property
def transport(self):
return self._writer.transport
def is_closing(self):
return self._writer.is_closing()
async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
domain: str, label: str) -> None:
try:
up_reader, up_writer = await asyncio.wait_for(
asyncio.open_connection(domain, 443), timeout=10)
except Exception as exc:
log.warning("[%s] masking: cannot connect to %s:443: %s",
label, domain, repr(exc))
return
log.debug("[%s] masking -> %s:443", label, domain)
stats.connections_masked += 1
try:
if initial_data:
up_writer.write(initial_data)
await up_writer.drain()
async def _relay(src, dst):
try:
while True:
chunk = await src.read(16384)
if not chunk:
break
dst.write(chunk)
await dst.drain()
except (ConnectionResetError, BrokenPipeError, OSError,
asyncio.CancelledError):
pass
finally:
try:
dst.close()
await dst.wait_closed()
except Exception:
pass
await asyncio.gather(
_relay(reader, up_writer),
_relay(up_reader, writer),
)
except Exception:
pass
finally:
try:
up_writer.close()
except Exception:
pass
+214
View File
@@ -0,0 +1,214 @@
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()
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))
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) -> Optional[RawWebSocket]:
for domain in domains:
try:
return await RawWebSocket.connect(
target_ip, domain, timeout=8)
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()
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()
+29 -6
View File
@@ -1,5 +1,6 @@
import os import os
import ssl import ssl
import logging
import base64 import base64
import struct import struct
import asyncio import asyncio
@@ -8,6 +9,8 @@ import socket as _socket
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from .config import proxy_config from .config import proxy_config
log = logging.getLogger('tg-mtproto-proxy')
_st_BB = struct.Struct('>BB') _st_BB = struct.Struct('>BB')
_st_BBH = struct.Struct('>BBH') _st_BBH = struct.Struct('>BBH')
@@ -25,7 +28,7 @@ _ssl_ctx.verify_mode = ssl.CERT_NONE
class WsHandshakeError(Exception): class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str, def __init__(self, status_code: int, status_line: str,
headers: dict = None, location: str = None): headers: Optional[dict] = None, location: Optional[str] = None):
self.status_code = status_code self.status_code = status_code
self.status_line = status_line self.status_line = status_line
self.headers = headers or {} self.headers = headers or {}
@@ -78,7 +81,8 @@ class RawWebSocket:
self._closed = False self._closed = False
@staticmethod @staticmethod
async def connect(host: str, domain: str, timeout: float = 10.0) -> 'RawWebSocket': async def connect(host: str, domain: str, timeout: float = 10.0,
path: str = '/apiws') -> 'RawWebSocket':
reader, writer = await asyncio.wait_for( reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, 443, ssl=_ssl_ctx, asyncio.open_connection(host, 443, ssl=_ssl_ctx,
server_hostname=domain), server_hostname=domain),
@@ -89,16 +93,13 @@ class RawWebSocket:
ws_key = base64.b64encode(os.urandom(16)).decode() ws_key = base64.b64encode(os.urandom(16)).decode()
req = ( req = (
f'GET /apiws HTTP/1.1\r\n' f'GET {path} HTTP/1.1\r\n'
f'Host: {domain}\r\n' f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n' f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n' f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n' f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n' f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n' f'Sec-WebSocket-Protocol: binary\r\n'
f'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
f'AppleWebKit/537.36 (KHTML, like Gecko) '
f'Chrome/131.0.0.0 Safari/537.36\r\n'
f'\r\n' f'\r\n'
) )
writer.write(req.encode()) writer.write(req.encode())
@@ -162,6 +163,9 @@ class RawWebSocket:
if opcode == self.OP_CLOSE: if opcode == self.OP_CLOSE:
self._closed = True self._closed = True
code, reason = self._parse_close(payload)
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
code, reason)
try: try:
self.writer.write(self._build_frame( self.writer.write(self._build_frame(
self.OP_CLOSE, self.OP_CLOSE,
@@ -204,6 +208,25 @@ class RawWebSocket:
except Exception: except Exception:
pass 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 @staticmethod
def _build_frame(opcode: int, data: bytes, def _build_frame(opcode: int, data: bytes,
mask: bool = False) -> bytes: mask: bool = False) -> bytes:
+8
View File
@@ -8,24 +8,32 @@ class _Stats:
self.connections_tcp_fallback = 0 self.connections_tcp_fallback = 0
self.connections_cfproxy = 0 self.connections_cfproxy = 0
self.connections_bad = 0 self.connections_bad = 0
self.connections_masked = 0
self.ws_errors = 0 self.ws_errors = 0
self.bytes_up = 0 self.bytes_up = 0
self.bytes_down = 0 self.bytes_down = 0
self.pool_hits = 0 self.pool_hits = 0
self.pool_misses = 0 self.pool_misses = 0
self.cf_pool_hits = 0
self.cf_pool_misses = 0
def summary(self) -> str: def summary(self) -> str:
pool_total = self.pool_hits + self.pool_misses pool_total = self.pool_hits + self.pool_misses
pool_s = (f"{self.pool_hits}/{pool_total}" pool_s = (f"{self.pool_hits}/{pool_total}"
if pool_total else "n/a") 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} " return (f"total={self.connections_total} "
f"active={self.connections_active} " f"active={self.connections_active} "
f"ws={self.connections_ws} " f"ws={self.connections_ws} "
f"tcp_fb={self.connections_tcp_fallback} " f"tcp_fb={self.connections_tcp_fallback} "
f"cf={self.connections_cfproxy} " f"cf={self.connections_cfproxy} "
f"bad={self.connections_bad} " f"bad={self.connections_bad} "
f"masked={self.connections_masked} "
f"err={self.ws_errors} " f"err={self.ws_errors} "
f"pool={pool_s} " f"pool={pool_s} "
f"cf_pool={cf_pool_s} "
f"up={human_bytes(self.bytes_up)} " f"up={human_bytes(self.bytes_up)} "
f"down={human_bytes(self.bytes_down)}") f"down={human_bytes(self.bytes_down)}")
+211 -178
View File
@@ -4,7 +4,6 @@ import os
import sys import sys
import time import time
import struct import struct
import random
import asyncio import asyncio
import hashlib import hashlib
import argparse import argparse
@@ -12,10 +11,8 @@ import logging
import logging.handlers import logging.handlers
import socket as _socket import socket as _socket
from collections import deque from typing import Dict, Optional, Set, Tuple
from typing import Dict, List, Optional, Set, Tuple
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
if __name__ == '__main__' and (__package__ is None or __package__ == ''): if __name__ == '__main__' and (__package__ is None or __package__ == ''):
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -25,17 +22,21 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''):
from .utils import * from .utils import *
from .stats import stats from .stats import stats
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, CFPROXY_DEFAULT_DOMAINS from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, coerce_domain_list
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
from .balancer import balancer
from .pool import ws_pool, cf_worker_pool
from ._aes import Cipher, algorithms, modes
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
DC_FAIL_COOLDOWN = 30.0 DC_FAIL_COOLDOWN = 30.0
WS_FAIL_TIMEOUT = 2.0 WS_FAIL_TIMEOUT = 2.0
ws_blacklist: Set[Tuple[int, bool]] = set() ws_blacklist: Set[str] = set()
dc_fail_until: Dict[Tuple[int, bool], float] = {} dc_fail_until: Dict[str, float] = {}
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]: def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
@@ -99,156 +100,106 @@ def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes:
return bytes(result) return bytes(result)
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']
class _WsPool:
WS_POOL_MAX_AGE = 120.0
def __init__(self): async def _read_client_init(reader, writer, secret, label, masking):
self._idle: Dict[Tuple[int, bool], deque] = {} if proxy_config.proxy_protocol:
self._refilling: Set[Tuple[int, bool]] = set() try:
pp_line = await asyncio.wait_for(
async def get(self, dc: int, is_media: bool, reader.readline(), timeout=10)
target_ip: str, domains: List[str] except asyncio.IncompleteReadError:
) -> Optional[RawWebSocket]: log.debug("[%s] disconnected during PROXY header", label)
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 return None
pp_text = pp_line.decode('ascii', errors='replace').strip()
def _schedule_refill(self, key, target_ip, domains): if pp_text.startswith('PROXY '):
if key in self._refilling: parts = pp_text.split()
return if len(parts) >= 6:
self._refilling.add(key) label = f"{parts[2]}:{parts[4]}"
asyncio.create_task(self._refill(key, target_ip, domains)) log.debug("[%s] PROXY protocol: %s", label, pp_text)
else:
async def _refill(self, key, target_ip, domains): log.debug("[%s] expected PROXY header, got: %r", label,
dc, is_media = key pp_text[:60])
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))
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) -> Optional[RawWebSocket]:
for domain in domains:
try:
return await RawWebSocket.connect(
target_ip, domain, timeout=8)
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, dc_redirects: Dict[int, Optional[str]]):
for dc, target_ip in 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(dc_redirects))
def reset(self):
self._idle.clear()
self._refilling.clear()
_ws_pool = _WsPool()
async def _handle_client(reader, writer, secret: bytes):
stats.connections_total += 1
stats.connections_active += 1
peer = writer.get_extra_info('peername')
label = f"{peer[0]}:{peer[1]}" if peer else "?"
set_sock_opts(writer.transport, proxy_config.buffer_size)
try: try:
try: first_byte = await asyncio.wait_for(
handshake = await asyncio.wait_for( reader.readexactly(1), timeout=10)
reader.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError: except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label) log.debug("[%s] client disconnected before handshake", label)
return return None
result = _try_handshake(handshake, secret) if first_byte[0] == TLS_RECORD_HANDSHAKE and masking:
if result is None:
stats.connections_bad += 1
log.debug("[%s] bad handshake (wrong secret or proto)", label)
try: try:
while await reader.read(4096): hdr_rest = await asyncio.wait_for(
pass reader.readexactly(4), timeout=10)
except Exception: except asyncio.IncompleteReadError:
pass log.debug("[%s] incomplete TLS record header", label)
return return None
dc, is_media, proto_tag, client_dec_prekey_iv = result tls_header = first_byte + hdr_rest
record_len = struct.unpack('>H', tls_header[3:5])[0]
try:
record_body = await asyncio.wait_for(
reader.readexactly(record_len), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete TLS record body", label)
return None
client_hello = tls_header + record_body
tls_result = verify_client_hello(client_hello, secret)
if tls_result is None:
log.debug("[%s] Fake TLS verify failed (size=%d rec=%d) "
"-> masking",
label, len(client_hello), record_len)
await proxy_to_masking_domain(
reader, writer, client_hello, masking, label)
return None
client_random, session_id, ts = tls_result
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
server_hello = build_server_hello(secret, client_random, session_id)
writer.write(server_hello)
await writer.drain()
tls_stream = FakeTlsStream(reader, writer)
try:
handshake = await asyncio.wait_for(
tls_stream.readexactly(HANDSHAKE_LEN), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] incomplete obfs2 init inside TLS", label)
return None
return handshake, tls_stream, tls_stream, label
elif masking:
log.debug("[%s] non-TLS byte 0x%02X -> HTTP redirect", label,
first_byte[0])
redirect = (
f"HTTP/1.1 301 Moved Permanently\r\n"
f"Location: https://{masking}/\r\n"
f"Content-Length: 0\r\n"
f"Connection: close\r\n\r\n"
).encode()
writer.write(redirect)
await writer.drain()
return None
if proto_tag == PROTO_TAG_ABRIDGED:
proto_int = PROTO_ABRIDGED_INT
elif proto_tag == PROTO_TAG_INTERMEDIATE:
proto_int = PROTO_INTERMEDIATE_INT
else: else:
proto_int = PROTO_PADDED_INTERMEDIATE_INT try:
rest = await asyncio.wait_for(
reader.readexactly(HANDSHAKE_LEN - 1), timeout=10)
except asyncio.IncompleteReadError:
log.debug("[%s] client disconnected before handshake", label)
return None
return first_byte + rest, reader, writer, label
dc_idx = -dc if is_media else dc
log.debug("[%s] handshake ok: DC%d%s proto=0x%08X",
label, dc, ' media' if is_media else '', proto_int)
relay_init = _generate_relay_init(proto_tag, dc_idx)
def _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init):
# key = SHA256(prekey + secret), iv from handshake # key = SHA256(prekey + secret), iv from handshake
# "dec" = decrypt data from client; "enc" = encrypt data to client # "dec" = decrypt data from client; "enc" = encrypt data to client
clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN] clt_dec_prekey = client_dec_prekey_iv[:PREKEY_LEN]
@@ -289,7 +240,52 @@ async def _handle_client(reader, writer, secret: bytes):
tg_encryptor.update(ZERO_64) tg_encryptor.update(ZERO_64)
ctx = CryptoCtx(clt_decryptor, clt_encryptor, tg_encryptor, tg_decryptor) return CryptoCtx(clt_decryptor, clt_encryptor, tg_encryptor, tg_decryptor)
async def _handle_client(reader, writer, secret: bytes):
stats.connections_total += 1
stats.connections_active += 1
peer = writer.get_extra_info('peername')
label = f"{peer[0]}:{peer[1]}" if peer else "?"
set_sock_opts(writer.transport, proxy_config.buffer_size)
try:
init = await _read_client_init(
reader, writer, secret, label, proxy_config.fake_tls_domain)
if init is None:
return
handshake, clt_reader, clt_writer, label = init
result = _try_handshake(handshake, secret)
if result is None:
stats.connections_bad += 1
log.warning("[%s] bad handshake (wrong secret or proto)", label)
try:
while await clt_reader.read(4096):
pass
except Exception:
pass
return
dc, is_media, proto_tag, client_dec_prekey_iv = result
if proto_tag == PROTO_TAG_ABRIDGED:
proto_int = PROTO_ABRIDGED_INT
elif proto_tag == PROTO_TAG_INTERMEDIATE:
proto_int = PROTO_INTERMEDIATE_INT
else:
proto_int = PROTO_PADDED_INTERMEDIATE_INT
dc_idx = -dc if is_media else dc
log.debug("[%s] handshake ok: DC%d%s proto=0x%08X",
label, dc, ' media' if is_media else '', proto_int)
relay_init = _generate_relay_init(proto_tag, dc_idx)
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
dc_key = f'{dc}{"m" if is_media else ""}' dc_key = f'{dc}{"m" if is_media else ""}'
media_tag = " media" if is_media else "" media_tag = " media" if is_media else ""
@@ -308,7 +304,7 @@ async def _handle_client(reader, writer, secret: bytes):
except Exception: except Exception:
pass pass
ok = await do_fallback( ok = await do_fallback(
reader, writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_media, media_tag,
ctx, splitter=splitter) ctx, splitter=splitter)
if not ok: if not ok:
@@ -320,13 +316,13 @@ async def _handle_client(reader, writer, secret: bytes):
fail_until = dc_fail_until.get(dc_key, 0) fail_until = dc_fail_until.get(dc_key, 0)
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0 ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
domains = _ws_domains(dc, is_media) domains = ws_domains(dc, is_media)
target = proxy_config.dc_redirects[dc] target = proxy_config.dc_redirects[dc]
ws = None ws = None
ws_failed_redirect = False ws_failed_redirect = False
all_redirects = True all_redirects = True
ws = await _ws_pool.get(dc, is_media, target, domains) ws = await ws_pool.get(dc, is_media, target, domains)
if ws: if ws:
log.info("[%s] DC%d%s -> pool hit via %s", log.info("[%s] DC%d%s -> pool hit via %s",
label, dc, media_tag, target) label, dc, media_tag, target)
@@ -357,7 +353,7 @@ async def _handle_client(reader, writer, secret: bytes):
stats.ws_errors += 1 stats.ws_errors += 1
all_redirects = False all_redirects = False
log.warning("[%s] DC%d%s WS connect failed: %s", log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, exc) label, dc, media_tag, repr(exc))
# WS failed -> fallback # WS failed -> fallback
if ws is None: if ws is None:
@@ -378,7 +374,7 @@ async def _handle_client(reader, writer, secret: bytes):
except Exception: except Exception:
pass pass
ok = await do_fallback( ok = await do_fallback(
reader, writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_media, media_tag,
ctx, splitter=splitter_fb) ctx, splitter=splitter_fb)
if ok: if ok:
@@ -399,9 +395,9 @@ async def _handle_client(reader, writer, secret: bytes):
await ws.send(relay_init) await ws.send(relay_init)
await bridge_ws_reencrypt(reader, writer, ws, label, await bridge_ws_reencrypt(clt_reader, clt_writer, ws, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
ctx=ctx, splitter=splitter) splitter=splitter)
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warning("[%s] timeout during handshake", label) log.warning("[%s] timeout during handshake", label)
@@ -415,13 +411,14 @@ async def _handle_client(reader, writer, secret: bytes):
if getattr(exc, 'winerror', None) == 1236: if getattr(exc, 'winerror', None) == 1236:
log.debug("[%s] connection aborted by local system", label) log.debug("[%s] connection aborted by local system", label)
else: else:
log.error("[%s] unexpected OS error: %s", label, exc) log.error("[%s] unexpected OS error: %s", label, repr(exc))
except Exception as exc: except Exception as exc:
log.error("[%s] unexpected: %s", label, exc, exc_info=True) log.error("[%s] unexpected: %s", label, exc, exc_info=True)
finally: finally:
stats.connections_active -= 1 stats.connections_active -= 1
try: try:
writer.close() writer.close()
await writer.wait_closed()
except BaseException: except BaseException:
pass pass
@@ -435,19 +432,17 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
global _server_instance, _server_stop_event global _server_instance, _server_stop_event
_server_stop_event = stop_event _server_stop_event = stop_event
_ws_pool.reset() ws_pool.reset()
cf_worker_pool.reset()
ws_blacklist.clear() ws_blacklist.clear()
dc_fail_until.clear() dc_fail_until.clear()
_client_tasks.clear() _client_tasks.clear()
if proxy_config.fallback_cfproxy: if proxy_config.fallback_cfproxy:
user = proxy_config.cfproxy_user_domain user = proxy_config.cfproxy_user_domains
if user: if user:
proxy_config.cfproxy_domains = [user] balancer.update_domains_list(user)
proxy_config.active_cfproxy_domain = user
else: else:
proxy_config.cfproxy_domains = list(CFPROXY_DEFAULT_DOMAINS)
proxy_config.active_cfproxy_domain = random.choice(CFPROXY_DEFAULT_DOMAINS)
start_cfproxy_domain_refresh() start_cfproxy_domain_refresh()
secret_bytes = bytes.fromhex(proxy_config.secret) secret_bytes = bytes.fromhex(proxy_config.secret)
@@ -467,23 +462,39 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
pass pass
link_host = get_link_host(proxy_config.host) link_host = get_link_host(proxy_config.host)
tg_link = f"tg://proxy?server={link_host}&port={proxy_config.port}&secret=dd{proxy_config.secret}" ftls = proxy_config.fake_tls_domain
dd_link = (f"tg://proxy?server={link_host}"
f"&port={proxy_config.port}"
f"&secret=dd{proxy_config.secret}")
ee_link = ""
if ftls:
domain_hex = ftls.encode('ascii').hex()
ee_link = (f"tg://proxy?server={link_host}"
f"&port={proxy_config.port}"
f"&secret=ee{proxy_config.secret}{domain_hex}")
log.info("=" * 60) log.info("=" * 60)
log.info(" Telegram MTProto WS Bridge Proxy") log.info(" Telegram MTProto WS Bridge Proxy")
log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port) log.info(" Listening on %s:%d", proxy_config.host, proxy_config.port)
log.info(" Secret: %s", proxy_config.secret) log.info(" Secret: %s", proxy_config.secret)
if ftls:
log.info(" Fake TLS: %s", ftls)
log.info(" Target DC IPs:") log.info(" Target DC IPs:")
for dc in sorted(proxy_config.dc_redirects.keys()): for dc in sorted(proxy_config.dc_redirects.keys()):
ip = proxy_config.dc_redirects.get(dc) ip = proxy_config.dc_redirects.get(dc)
log.info(" DC%d: %s", dc, ip) log.info(" DC%d: %s", dc, ip)
if proxy_config.fallback_cfproxy: if proxy_config.fallback_cfproxy:
prio = 'CF first' if proxy_config.fallback_cfproxy_priority else 'TCP first' user_domain = "user" if proxy_config.cfproxy_user_domains else "auto"
user_domain = "user" if proxy_config.cfproxy_user_domain else "auto" log.info(" CF proxy: enabled (%s)", user_domain)
log.info(" CF proxy: enabled (%s | %s)", prio, user_domain) if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)",
", ".join(proxy_config.cfproxy_worker_domains))
log.info("=" * 60) log.info("=" * 60)
log.info(" Connect link:") log.info(" Connect:")
log.info(" %s", tg_link) if ftls:
log.info(" %s", ee_link)
else:
log.info(" %s", dd_link)
log.info("=" * 60) log.info("=" * 60)
async def log_stats(): async def log_stats():
@@ -497,7 +508,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
log_stats_task = asyncio.create_task(log_stats()) log_stats_task = asyncio.create_task(log_stats())
await _ws_pool.warmup(proxy_config.dc_redirects) await ws_pool.warmup()
await cf_worker_pool.warmup()
try: try:
async with server: async with server:
@@ -509,14 +521,19 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
if stop_task in done: if stop_task in done:
server.close() for task in list(_client_tasks):
await server.wait_closed() task.cancel()
if _client_tasks:
await asyncio.gather(
*_client_tasks, return_exceptions=True)
if not serve_task.done(): if not serve_task.done():
serve_task.cancel() serve_task.cancel()
try: try:
await serve_task await serve_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
server.close()
await server.wait_closed()
else: else:
stop_task.cancel() stop_task.cancel()
try: try:
@@ -556,19 +573,31 @@ def main():
help='Log to file with rotation (default: stderr only)') help='Log to file with rotation (default: stderr only)')
ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB',
help='Max log file size in MB before rotation (default 5)') help='Max log file size in MB before rotation (default 5)')
ap.add_argument('--log-backups', type=int, default=0, metavar='N', ap.add_argument('--log-backups', type=int, default=1, metavar='N',
help='Number of rotated log files to keep (default 0)') help='Number of rotated log files to keep (min 1; '
'rotation needs at least one backup to bound size)')
ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', ap.add_argument('--buf-kb', type=int, default=256, metavar='KB',
help='Socket send/recv buffer size in KB (default 256)') help='Socket send/recv buffer size in KB (default 256)')
ap.add_argument('--pool-size', type=int, default=4, metavar='N', ap.add_argument('--pool-size', type=int, default=4, metavar='N',
help='WS connection pool size per DC (default 4, min 0)') help='WS connection pool size per DC (default 4, min 0)')
ap.add_argument('--cfproxy-domain', type=str, default='', ap.add_argument('--cfproxy-domain', action='append', default=None,
metavar='DOMAIN', metavar='DOMAIN',
help='User defined Cloudflare-proxied domain for WS fallback') help='User defined Cloudflare-proxied domain for WS fallback '
'(repeatable for multiple domains)')
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
metavar='DOMAIN',
help='Cloudflare Worker domain for WS fallback '
'(tried before other fallback methods, '
'repeatable for multiple domains)')
ap.add_argument('--no-cfproxy', action='store_true', ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback') help='Disable Cloudflare proxy fallback')
ap.add_argument('--cfproxy-priority', type=bool, default=True, ap.add_argument('--fake-tls-domain', type=str, default='',
help='Try cfproxy before tcp fallback (default: true)') metavar='DOMAIN',
help='Enable Fake TLS (ee-secret) masking with the given '
'SNI domain, e.g. example.com')
ap.add_argument('--proxy-protocol', action='store_true',
help='Accept PROXY protocol v1 header '
'(for use behind nginx/haproxy with proxy_protocol on)')
args = ap.parse_args() args = ap.parse_args()
if not args.dc_ip: if not args.dc_ip:
@@ -601,8 +630,10 @@ def main():
proxy_config.buffer_size = max(4, args.buf_kb) * 1024 proxy_config.buffer_size = max(4, args.buf_kb) * 1024
proxy_config.pool_size = max(0, args.pool_size) proxy_config.pool_size = max(0, args.pool_size)
proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.fallback_cfproxy_priority = args.cfproxy_priority proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
proxy_config.cfproxy_user_domain = args.cfproxy_domain proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol
log_level = logging.DEBUG if args.verbose else logging.INFO log_level = logging.DEBUG if args.verbose else logging.INFO
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
@@ -615,15 +646,17 @@ def main():
root.addHandler(console) root.addHandler(console)
if args.log_file: if args.log_file:
fh = logging.handlers.RotatingFileHandler( from utils.logging_setup import build_log_handler
fh = build_log_handler(
args.log_file, args.log_file,
maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)), log_max_mb=args.log_max_mb,
backupCount=max(0, args.log_backups), backups=args.log_backups,
encoding='utf-8',
) )
fh.setFormatter(log_fmt) fh.setFormatter(log_fmt)
root.addHandler(fh) root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING)
try: try:
asyncio.run(_run()) asyncio.run(_run())
except KeyboardInterrupt: except KeyboardInterrupt:
+58 -2
View File
@@ -1,6 +1,9 @@
import socket as _socket import socket as _socket
import urllib.request
import http.client
from typing import Optional from typing import Optional, Dict, List
from urllib.request import Request
ZERO_64 = b'\x00' * 64 ZERO_64 = b'\x00' * 64
@@ -26,12 +29,34 @@ RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'} b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
RESERVED_CONTINUE = b'\x00\x00\x00\x00' 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: def human_bytes(n: int) -> str:
for unit in ('B', 'KB', 'MB', 'GB'): for unit in ('B', 'KB', 'MB', 'GB'):
if abs(n) < 1024: if abs(n) < 1024:
return f"{n:.1f}{unit}" return f"{n:.1f}{unit}"
n /= 1024 n /= 1024 # type: ignore
return f"{n:.1f}TB" return f"{n:.1f}TB"
@@ -46,3 +71,34 @@ def get_link_host(host: str) -> Optional[str]:
return link_host return link_host
else: else:
return host 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())
+415 -190
View File
@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import webbrowser import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from proxy import __version__, get_link_host, parse_dc_ip_list from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
from proxy.config import CFPROXY_DEFAULT_DOMAINS from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status from utils.update_check import RELEASES_PAGE_URL, get_status
@@ -16,63 +17,30 @@ from ui.ctk_theme import (
main_content_frame, main_content_frame,
) )
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets 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,
)
_TIP_HOST = ( log = logging.getLogger('tg-mtproto-proxy')
"Адрес, на котором прокси принимает подключения.\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_PRIORITY = (
"Пробовать CF-прокси раньше прямого TCP-подключения"
)
_TIP_CFPROXY_DOMAIN = (
"Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n"
"Если не указан — выбирается автоматически из поддерживаемых доменов"
)
_TIP_CFPROXY_USER_DOMAIN_CB = (
"Указать свой домен вместо автоматического выбора"
)
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md" _CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203] _CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100',
}
def _run_cfproxy_connectivity_test(domain: str) -> dict: def _run_connectivity_test(cases: list) -> dict:
import base64 import base64
import ssl import ssl
import socket as _socket import socket as _socket
@@ -81,15 +49,14 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
results = {} results = {}
for dc in _CFPROXY_TEST_DCS: for dc, connect_host, sni_host, req_host, path in cases:
host = f"kws{dc}.{domain}"
try: try:
with _socket.create_connection((host, 443), timeout=5) as raw: with _socket.create_connection((connect_host, 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as ssock: with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode() ws_key = base64.b64encode(os.urandom(16)).decode()
req = ( req = (
f"GET /apiws HTTP/1.1\r\n" f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n" f"Host: {req_host}\r\n"
f"Upgrade: websocket\r\n" f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n" f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n" f"Sec-WebSocket-Key: {ws_key}\r\n"
@@ -109,48 +76,95 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
if "101" in first: if "101" in first:
results[dc] = True results[dc] = True
else: else:
results[dc] = first or "нет ответа" results[dc] = first or t("connectivity.no_response")
ssock.close() ssock.close()
raw.close() raw.close()
except _socket.timeout: except _socket.timeout:
results[dc] = "таймаут" results[dc] = t("connectivity.timeout")
except OSError as exc: except OSError as exc:
msg = str(exc) msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg results[dc] = msg[:60] if len(msg) > 60 else msg
return results 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: def _run_cfproxy_auto_test(domains: list) -> tuple:
last: dict = {} merged: dict = {}
for domain in domains: best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain) res = _run_cfproxy_connectivity_test(domain)
last = res if all(v is True for v in res.values()):
if any(v is True for v in res.values()):
return domain, res return domain, res
return None, last for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def _cfproxy_show_test_results(domain: str, results: dict) -> None: def _show_connectivity_results(title_base: str, results: dict,
domain: str = '', label_prefix: str = 'DC',
auto_mode: bool = False,
unavailable_message: str = '') -> None:
import tkinter as _tk import tkinter as _tk
from tkinter import messagebox as _mb from tkinter import messagebox as _mb
ok = [dc for dc, v in results.items() if v is True] ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True] total = len(_CFPROXY_TEST_DCS)
if len(ok) == len(_CFPROXY_TEST_DCS): if auto_mode:
title = "CF-прокси: всё работает" if domain:
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}." title = t("connectivity.available", title=title_base)
elif not ok: msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
title = "CF-прокси: недоступен"
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
msg += "\n".join(f" kws{dc}: {v}" for dc, v in fail)
else: else:
title = "CF-прокси: частично работает" title = t("connectivity.unavailable", title=title_base)
msg = ( msg = unavailable_message
f"Домен: {domain}\n\n" else:
f"\u2713 Работают: {', '.join(f'kws{dc}' for dc in ok)}\n\n" fail = [(dc, v) for dc, v in results.items() if v is not True]
f"\u2717 Недоступны:\n" if len(ok) == total:
+ "\n".join(f" kws{dc}: {v}" for dc, v in fail) 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 = _tk.Tk()
root.withdraw() root.withdraw()
try: try:
@@ -161,18 +175,41 @@ def _cfproxy_show_test_results(domain: str, results: dict) -> None:
root.destroy() root.destroy()
def _cfproxy_show_auto_test_results(ok_domain, results: dict) -> None: def _show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC') -> None:
import tkinter as _tk import tkinter as _tk
from tkinter import messagebox as _mb from tkinter import messagebox as _mb
if ok_domain is not None: total = len(_CFPROXY_TEST_DCS)
title = "CF-прокси: доступен" 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] ok = [dc for dc, v in results.items() if v is True]
msg = f"\u2713 CF-прокси работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны." 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: else:
title = "CF-прокси: недоступен" all_ok = False
msg = "\u2717 Ни один из автоматических CF-доменов не отвечает.\n" any_ok = True
msg += "Возможно, блокировка или проблемы с сетью." 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 = _tk.Tk()
root.withdraw() root.withdraw()
try: try:
@@ -184,12 +221,32 @@ def _cfproxy_show_auto_test_results(ok_domain, results: dict) -> None:
_INNER_W = 396 _INNER_W = 396
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"] _APPEARANCE_KEYS = ("auto", "light", "dark")
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "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): def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict( opts = dict(
font=(theme.ui_font_family, 13), corner_radius=radius, font=(theme.ui_font_family, 13), corner_radius=radius,
@@ -290,9 +347,10 @@ class TrayConfigFormWidgets:
autostart_var: Optional[Any] autostart_var: Optional[Any]
check_updates_var: Optional[Any] check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None cfproxy_var: Optional[Any] = None
cfproxy_priority_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
def install_tray_config_form( def install_tray_config_form(
@@ -304,11 +362,15 @@ def install_tray_config_form(
*, *,
show_autostart: bool = False, show_autostart: bool = False,
autostart_value: bool = False, autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets: ) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg)
header = ctk.CTkFrame(frame, fg_color="transparent") header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2)) header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel( ctk.CTkLabel(
header, text="Настройки прокси", header, text=t("settings.title"),
font=(theme.ui_font_family, 17, "bold"), font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w", text_color=theme.text_primary, anchor="w",
).pack(side="left") ).pack(side="left")
@@ -317,23 +379,81 @@ def install_tray_config_form(
font=(theme.ui_font_family, 12), font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e", text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0)) ).pack(side="right", padx=(4, 0))
appearance_var = ctk.StringVar( appearance_var = ctk.StringVar(
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто") value=_appearance_from_cfg(cfg.get("appearance", "auto"))
) )
def _on_appearance_change(choice: str) -> None: def _on_appearance_change(choice: str) -> None:
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto") cfg_val = _appearance_to_cfg(choice)
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val]) ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
cfg["appearance"] = cfg_val
ctk.CTkComboBox( ctk.CTkButton(
header, header, text="Donate ♥", width=90, height=28,
values=_APPEARANCE_OPTIONS, font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
variable=appearance_var, fg_color="#22c55e", hover_color="#16a34a",
width=102, text_color="#ffffff", border_width=0,
height=28, 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), font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, text_color=theme.text_primary,
fg_color=theme.field_bg, 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)
def _on_language_change(choice: str) -> None:
lang = language_from_label(choice)
set_language(lang)
_sync_language_combobox(language_combo, language_var, lang)
if on_language_change is not None:
on_language_change()
language_combo.configure(command=_on_language_change)
_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, border_color=theme.field_border,
button_color=theme.field_border, button_color=theme.field_border,
button_hover_color=theme.text_secondary, button_hover_color=theme.text_secondary,
@@ -343,24 +463,25 @@ def install_tray_config_form(
corner_radius=8, corner_radius=8,
state="readonly", state="readonly",
command=_on_appearance_change, command=_on_appearance_change,
).pack(side="right") )
theme_combo.pack(fill="x")
conn = _config_section(ctk, frame, theme, "Подключение MTProto") conn = _config_section(ctk, frame, theme, t("section.mtproto"))
host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x") host_row.pack(fill="x")
host_col, host_var = _labeled_entry( host_col, host_var = _labeled_entry(
ctk, host_row, theme, "IP-адрес", ctk, host_row, theme, t("label.host"),
cfg.get("host", default_config["host"]), cfg.get("host", default_config["host"]),
tip=_TIP_HOST, width=160, pack_fill=True, tip=t("tip.host"), width=160, pack_fill=True,
) )
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = _labeled_entry( port_col, port_var = _labeled_entry(
ctk, host_row, theme, "Порт", ctk, host_row, theme, t("label.port"),
cfg.get("port", default_config["port"]), cfg.get("port", default_config["port"]),
tip=_TIP_PORT, width=100, tip=t("tip.port"), width=100,
) )
port_col.pack(side="left") port_col.pack(side="left")
@@ -368,9 +489,9 @@ def install_tray_config_form(
secret_row.pack(fill="x") secret_row.pack(fill="x")
secret_col, secret_var = _labeled_entry( secret_col, secret_var = _labeled_entry(
ctk, secret_row, theme, "Secret", ctk, secret_row, theme, t("label.secret"),
cfg.get("secret", default_config["secret"]), cfg.get("secret", default_config["secret"]),
tip=_TIP_SECRET, width=160, pack_fill=True, tip=t("tip.secret"), width=160, pack_fill=True,
) )
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
@@ -385,8 +506,8 @@ def install_tray_config_form(
command=lambda: secret_var.set(os.urandom(16).hex()), command=lambda: secret_var.set(os.urandom(16).hex()),
).pack() ).pack()
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11) dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
dc_lbl.pack(anchor="w", pady=(0, 4)) dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox( dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88, dc_inner, width=_INNER_W, height=88,
@@ -396,9 +517,9 @@ def install_tray_config_form(
) )
dc_textbox.pack(fill="x") dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC) attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy") cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent") cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4)) cf_row.pack(fill="x", pady=(0, 4))
@@ -406,42 +527,61 @@ def install_tray_config_form(
cfproxy_var = ctk.BooleanVar( cfproxy_var = ctk.BooleanVar(
value=cfg.get("cfproxy", default_config.get("cfproxy", True)) value=cfg.get("cfproxy", default_config.get("cfproxy", True))
) )
cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var) cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16)) cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY) attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
cfproxy_priority_var = ctk.BooleanVar(
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
)
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет", cfproxy_priority_var)
cf_prio_cb.pack(side="left")
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
_cf_test_btn = [None] _cf_test_btn = [None]
def _on_cf_test(): def _on_cf_test():
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else "" user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
btn = _cf_test_btn[0] btn = _cf_test_btn[0]
if btn: if btn:
btn.configure(text="...", state="disabled") btn.configure(text=t("button.test_loading"), state="disabled")
import threading as _threading import threading as _threading
if user_domain: if user_domains:
def _worker(): def _worker():
res = _run_cfproxy_connectivity_test(user_domain) try:
per = _run_cfproxy_multi_test(user_domains)
if btn: if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal")) btn.after(
btn.after(0, lambda: _cfproxy_show_test_results(user_domain, res)) 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() _threading.Thread(target=_worker, daemon=True).start()
else: else:
def _worker_auto(): def _worker_auto():
ok_domain, res = _run_cfproxy_auto_test(CFPROXY_DEFAULT_DOMAINS) try:
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
if btn: if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal")) btn.after(
btn.after(0, lambda: _cfproxy_show_auto_test_results(ok_domain, res)) 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() _threading.Thread(target=_worker_auto, daemon=True).start()
_cf_test_widget = ctk.CTkButton( _cf_test_widget = ctk.CTkButton(
cf_row, text="Тест", width=56, height=28, cf_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8, font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border, text_color="#ffffff", border_width=1, border_color=theme.field_border,
@@ -453,11 +593,13 @@ def install_tray_config_form(
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent") cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x") cf_custom_row.pack(fill="x")
saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) saved_user_domains = coerce_domain_list(
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain)) cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var) )
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)) cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB) attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
ctk.CTkButton( ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32, cf_custom_row, text="?", width=28, height=32,
@@ -467,13 +609,13 @@ def install_tray_config_form(
command=lambda: webbrowser.open(_CFPROXY_HELP_URL), command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
).pack(side="right") ).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain) cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
cf_domain_entry = _entry( cf_domain_entry = _entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var, ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8, height=32, radius=8,
) )
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN) attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
def _sync_domain_entry(*_): def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled" state = "normal" if cf_custom_cb_var.get() else "disabled"
@@ -484,20 +626,96 @@ def install_tray_config_form(
cf_custom_cb_var.trace_add("write", _sync_domain_entry) cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry() _sync_domain_entry()
log_inner = _config_section(ctk, frame, theme, "Логи и производительность") 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_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var) verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6)) verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE) attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent") adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x") adv_frame.pack(fill="x")
adv_rows = [ adv_rows = [
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB), (t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL), (t("label.pool_size"), "pool_size", t("tip.pool")),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB), (t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
] ]
for label_text, key, tip in adv_rows: for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent") col = ctk.CTkFrame(adv_frame, fg_color="transparent")
@@ -514,38 +732,32 @@ def install_tray_config_form(
adv_entries = list(adv_frame.winfo_children()) adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb") adv_keys = ("buf_kb", "pool_size", "log_max_mb")
upd_inner = _config_section(ctk, frame, theme, "Обновления") upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
st = get_status() st = get_status()
check_updates_var = ctk.BooleanVar( check_updates_var = ctk.BooleanVar(
value=bool(cfg.get("check_updates", default_config.get("check_updates", True))) value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
) )
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var) upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6)) upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES) attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
if st.get("error"): if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть." upd_status = t("updates.status_error")
elif not st.get("checked"): elif not st.get("checked"):
upd_status = "Статус появится после фоновой проверки при запуске." upd_status = t("updates.status_pending")
elif st.get("has_update") and st.get("latest"): elif st.get("has_update") and st.get("latest"):
upd_status = ( upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
f"На GitHub доступна версия {st['latest']} "
f"(у вас {__version__})."
)
elif st.get("ahead_of_release") and st.get("latest"): elif st.get("ahead_of_release") and st.get("latest"):
upd_status = ( upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
f"У вас {__version__} — новее последнего релиза на GitHub "
f"({st['latest']})."
)
else: else:
upd_status = "Установлена последняя известная версия с GitHub." upd_status = t("updates.status_latest")
_label(ctk, upd_inner, theme, upd_status, size=11, _label(ctk, upd_inner, theme, upd_status, size=11,
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ctk.CTkButton( ctk.CTkButton(
upd_inner, text="Открыть страницу релиза", height=32, upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8, font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border, fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, text_color=theme.text_primary, border_width=1,
@@ -555,17 +767,17 @@ def install_tray_config_form(
autostart_var = None autostart_var = None
if show_autostart: if show_autostart:
sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4) sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
autostart_var = ctk.BooleanVar(value=autostart_value) autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var) as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
as_cb.pack(anchor="w", pady=(0, 4)) as_cb.pack(anchor="w", pady=(0, 4))
as_hint = _label( as_hint = _label(
ctk, sys_inner, theme, ctk, sys_inner, theme,
"Если переместить программу в другую папку, запись автозапуска может сброситься.", t("label.autostart_hint"),
size=11, justify="left", wraplength=_INNER_W, size=11, justify="left", wraplength=_INNER_W,
) )
as_hint.pack(anchor="w") as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
return TrayConfigFormWidgets( return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var, host_var=host_var, port_var=port_var, secret_var=secret_var,
@@ -573,9 +785,10 @@ def install_tray_config_form(
adv_entries=adv_entries, adv_keys=adv_keys, adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var, autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var, cfproxy_var=cfproxy_var,
cfproxy_priority_var=cfproxy_priority_var,
cfproxy_user_domain_var=cfproxy_user_domain_var, cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var, appearance_var=appearance_var,
language_var=language_var,
) )
@@ -596,6 +809,16 @@ def merge_adv_from_form(
base[key] = default_config[key] 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( def validate_config_form(
widgets: TrayConfigFormWidgets, widgets: TrayConfigFormWidgets,
default_config: dict, default_config: dict,
@@ -608,14 +831,14 @@ def validate_config_form(
try: try:
_sock.inet_aton(host_val) _sock.inet_aton(host_val)
except OSError: except OSError:
return "Некорректный IP-адрес." return t("validation.bad_host")
try: try:
port_val = int(widgets.port_var.get().strip()) port_val = int(widgets.port_var.get().strip())
if not (1 <= port_val <= 65535): if not (1 <= port_val <= 65535):
raise ValueError raise ValueError
except ValueError: except ValueError:
return "Порт должен быть числом 1-65535" return t("validation.bad_port")
lines = [ lines = [
line.strip() line.strip()
@@ -625,15 +848,15 @@ def validate_config_form(
try: try:
parse_dc_ip_list(lines) parse_dc_ip_list(lines)
except ValueError as e: except ValueError as e:
return str(e) return _dc_validation_message(e)
secret_val = widgets.secret_var.get().strip() secret_val = widgets.secret_var.get().strip()
if len(secret_val) != 32: if len(secret_val) != 32:
return "Secret должен содержать ровно 32 hex-символа (16 байт)." return t("validation.bad_secret_len")
try: try:
bytes.fromhex(secret_val) bytes.fromhex(secret_val)
except ValueError: except ValueError:
return "Secret должен состоять только из hex-символов (0-9, a-f)." return t("validation.bad_secret_hex")
new_cfg: Dict[str, Any] = { new_cfg: Dict[str, Any] = {
"host": host_val, "host": host_val,
@@ -654,12 +877,14 @@ def validate_config_form(
new_cfg["check_updates"] = bool(widgets.check_updates_var.get()) new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
if widgets.cfproxy_var is not None: if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get()) new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_priority_var is not None:
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
if widgets.cfproxy_user_domain_var is not None: if widgets.cfproxy_user_domain_var is not None:
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip() 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: if widgets.appearance_var is not None:
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto") 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 return new_cfg
@@ -680,22 +905,22 @@ def install_tray_config_buttons(
btn_frame = ctk.CTkFrame(frame, fg_color="transparent") btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x", pady=(0, 0)) btn_frame.pack(fill="x", pady=(0, 0))
save_btn = ctk.CTkButton( save_btn = ctk.CTkButton(
btn_frame, text="Сохранить", height=38, btn_frame, text=t("button.save"), height=38,
font=(theme.ui_font_family, 14, "bold"), corner_radius=10, font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", text_color="#ffffff",
command=on_save) command=on_save)
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8)) save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
attach_ctk_tooltip(save_btn, _TIP_SAVE) attach_ctk_tooltip(save_btn, t("tip.save"))
cancel_btn = ctk.CTkButton( cancel_btn = ctk.CTkButton(
btn_frame, text="Отмена", height=38, btn_frame, text=t("button.cancel"), height=38,
font=(theme.ui_font_family, 14), corner_radius=10, font=(theme.ui_font_family, 14), corner_radius=10,
fg_color=theme.field_bg, hover_color=theme.field_border, fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, text_color=theme.text_primary, border_width=1,
border_color=theme.field_border, border_color=theme.field_border,
command=on_cancel) command=on_cancel)
cancel_btn.pack(side="right", fill="x", expand=True) cancel_btn.pack(side="right", fill="x", expand=True)
attach_ctk_tooltip(cancel_btn, _TIP_CANCEL) attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
def populate_first_run_window( def populate_first_run_window(
@@ -720,19 +945,19 @@ def populate_first_run_window(
width=4, height=32, corner_radius=2) width=4, height=32, corner_radius=2)
accent_bar.pack(side="left", padx=(0, 12)) accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее", ctk.CTkLabel(title_frame, text=t("first_run.title"),
font=(theme.ui_font_family, 17, "bold"), font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary).pack(side="left") text_color=theme.text_primary).pack(side="left")
sections = [ sections = [
("Как подключить Telegram Desktop:", True), (t("first_run.how_to"), True),
(" Автоматически:", True), (t("first_run.auto"), True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False), (t("first_run.auto_hint"), False),
(f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False), (t("first_run.auto_link", url=tg_url), False),
("\n Вручную:", True), ("\n" + t("first_run.manual"), True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False), (t("first_run.manual_path"), False),
(f" MTProto → {link_host} : {port}", False), (t("first_run.manual_mtproto", host=link_host, port=port), False),
(f" Secret: dd{secret}", False), (t("first_run.manual_secret", secret=secret), False),
] ]
textbox = ctk.CTkTextbox( textbox = ctk.CTkTextbox(
@@ -764,13 +989,13 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12)) corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True) auto_var = ctk.BooleanVar(value=True)
_checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас", _checkbox(ctk, frame, theme, t("first_run.open_now"),
auto_var).pack(anchor="w", pady=(0, 16)) auto_var).pack(anchor="w", pady=(0, 16))
def on_ok(): def on_ok():
on_done(auto_var.get()) on_done(auto_var.get())
ctk.CTkButton(frame, text="Начать", width=180, height=42, ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
font=(theme.ui_font_family, 15, "bold"), corner_radius=10, font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", text_color="#ffffff",
+165
View File
@@ -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())
+149
View File
@@ -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 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 media does not work with CF proxy enabled, try removing the line 2:149.154.167.220",
"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 1000050000 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."
}
+149
View File
@@ -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": "Язык",
"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Это предупреждение будет показано только один раз."
}
+6 -2
View File
@@ -8,6 +8,8 @@ import sys
import os import os
from typing import Any, Dict from typing import Any, Dict
from ui.i18n import detect_system_language
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = { _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1443, "port": 1443,
"host": "127.0.0.1", "host": "127.0.0.1",
@@ -18,14 +20,16 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"buf_kb": 256, "buf_kb": 256,
"pool_size": 4, "pool_size": 4,
"cfproxy": True, "cfproxy": True,
"cfproxy_priority": True, "cfproxy_user_domain": [],
"cfproxy_user_domain": "", "cfproxy_worker_domain": [],
"ws_keepalive_interval": 30,
} }
def default_tray_config() -> Dict[str, Any]: def default_tray_config() -> Dict[str, Any]:
cfg = dict(_TRAY_DEFAULTS_COMMON) cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex() cfg["secret"] = os.urandom(16).hex()
cfg["language"] = detect_system_language().value
if sys.platform == "win32": if sys.platform == "win32":
cfg["autostart"] = False cfg["autostart"] = False
+36
View File
@@ -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
+39
View File
@@ -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",
)
+103 -45
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import logging.handlers
import os import os
import shutil
import socket as _socket import socket as _socket
import sys import sys
import threading import threading
@@ -14,16 +14,19 @@ from typing import Any, Callable, Dict, Optional, Tuple
import psutil import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.tg_ws_proxy import _run from proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config 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") log = logging.getLogger("tg-ws-tray")
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
PORTABLE_DIR_NAME = "TgWsProxy_data"
def _app_dir() -> Path: def _standard_app_dir() -> Path:
if sys.platform == "win32": if sys.platform == "win32":
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
if sys.platform == "darwin": if sys.platform == "darwin":
@@ -31,6 +34,61 @@ def _app_dir() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / 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() APP_DIR = _app_dir()
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = APP_DIR / "proxy.log"
@@ -51,7 +109,7 @@ def ensure_dirs() -> None:
_lock_file_path: Optional[Path] = None _lock_file_path: Optional[Path] = None
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool: def _same_process(meta: dict, proc: psutil.Process) -> bool:
try: try:
lock_ct = float(meta.get("create_time", 0.0)) lock_ct = float(meta.get("create_time", 0.0))
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0: if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
@@ -63,7 +121,7 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
return False return False
def acquire_lock(script_hint: str = "") -> bool: def acquire_lock() -> bool:
global _lock_file_path global _lock_file_path
ensure_dirs() ensure_dirs()
for f in list(APP_DIR.glob("*.lock")): for f in list(APP_DIR.glob("*.lock")):
@@ -84,7 +142,7 @@ def acquire_lock(script_hint: str = "") -> bool:
pass pass
is_running = False is_running = False
try: try:
is_running = _same_process(meta, psutil.Process(pid), script_hint) is_running = _same_process(meta, psutil.Process(pid))
except Exception: except Exception:
pass pass
if is_running: if is_running:
@@ -122,18 +180,28 @@ def release_lock() -> None:
# config # 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: def load_config() -> dict:
ensure_dirs() ensure_dirs()
cfg: Optional[dict] = None
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
try: try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f: with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
for k, v in DEFAULT_CONFIG.items(): for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v) data.setdefault(k, v)
return data cfg = data
except Exception as exc: except Exception as exc:
log.warning("Failed to load config: %s", exc) log.warning("Failed to load config: %s", repr(exc))
return dict(DEFAULT_CONFIG) if cfg is None:
cfg = dict(DEFAULT_CONFIG)
_apply_ui_language(cfg)
return cfg
def save_config(cfg: dict) -> None: def save_config(cfg: dict) -> None:
@@ -153,13 +221,9 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
level = logging.DEBUG if verbose else logging.INFO level = logging.DEBUG if verbose else logging.INFO
root = logging.getLogger() root = logging.getLogger()
root.setLevel(level) root.setLevel(level)
logging.getLogger('asyncio').setLevel(logging.WARNING)
fh = logging.handlers.RotatingFileHandler( fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
str(LOG_FILE),
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
backupCount=0,
encoding="utf-8",
)
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh) root.addHandler(fh)
@@ -230,7 +294,7 @@ _proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
global _async_stop global _async_stop
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@@ -241,14 +305,12 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
try: try:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", repr(exc))
if "Address already in use" in str(exc) or "10048" in str(exc): msg, diagnose_called = diagnose_listen_error(exc)
on_port_busy( if msg:
"Не удалось запустить прокси:\n" show_error(msg)
"Порт уже используется другим приложением.\n\n" if diagnose_called:
"Закройте приложение, использующее этот порт, " diagnose_called()
"или измените порт в настройках прокси и перезапустите."
)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -270,8 +332,9 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024 pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])) pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"]) pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"]) pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
pc.cfproxy_user_domain = 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 return True
@@ -282,7 +345,8 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
return return
if not apply_proxy_config(cfg): if not apply_proxy_config(cfg):
on_error("Ошибка конфигурации DC → IP.") from ui.i18n import t
on_error(t("error.dc_config"))
return return
pc = proxy_config pc = proxy_config
@@ -300,6 +364,9 @@ def stop_proxy() -> None:
loop.call_soon_threadsafe(stop_ev.set) loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread: if _proxy_thread:
_proxy_thread.join(timeout=5) _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 _proxy_thread = None
log.info("Proxy stopped") log.info("Proxy stopped")
@@ -307,7 +374,7 @@ def stop_proxy() -> None:
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
log.info("Restarting proxy...") log.info("Restarting proxy...")
stop_proxy() stop_proxy()
time.sleep(0.3) time.sleep(1.0)
start_proxy(cfg, on_error) start_proxy(cfg, on_error)
@@ -319,19 +386,6 @@ def tg_proxy_url(cfg: dict) -> str:
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
_IPV6_WARNING = (
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз."
)
def _has_ipv6() -> bool: def _has_ipv6() -> bool:
try: try:
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6): for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
@@ -354,8 +408,10 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
if IPV6_WARN_MARKER.exists() or not _has_ipv6(): if IPV6_WARN_MARKER.exists() or not _has_ipv6():
return return
IPV6_WARN_MARKER.touch() IPV6_WARN_MARKER.touch()
from ui.i18n import t
threading.Thread( threading.Thread(
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"), target=lambda: show_info(t("ipv6.warning"), t("app.name")),
daemon=True, daemon=True,
).start() ).start()
@@ -384,13 +440,15 @@ def maybe_notify_update(
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?" ver = st.get("latest") or "?"
from ui.i18n import t
if ask_open( if ask_open(
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", t("update.ask_open", version=ver),
"TG WS Proxy — обновление", t("app.update_title"),
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.debug("Update check failed: %s", exc) log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()
+113 -12
View File
@@ -1,5 +1,5 @@
""" """
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей). Проверка новой версии через GitHub Releases API
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304. данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
@@ -7,17 +7,18 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import time import time
from itertools import zip_longest from itertools import zip_longest
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request
from proxy.utils import build_github_opener
REPO = "Flowseal/tg-ws-proxy" REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
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" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag). # Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
@@ -30,18 +31,14 @@ _state: Dict[str, Any] = {
"latest": None, "latest": None,
"html_url": None, "html_url": None,
"error": None, "error": None,
"assets": [],
} }
def _cache_file() -> Optional[Path]: def _cache_file() -> Optional[Path]:
try: try:
if sys.platform == "win32": from utils.tray_common import APP_DIR
root = Path(os.environ.get("APPDATA", str(Path.home()))) / "TgWsProxy" root = APP_DIR
elif sys.platform == "darwin":
root = Path.home() / "Library/Application Support/TgWsProxy"
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
root = (Path(xdg).expanduser() if xdg else Path.home() / ".config") / "TgWsProxy"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
return root / ".update_check_cache.json" return root / ".update_check_cache.json"
except OSError: except OSError:
@@ -72,7 +69,7 @@ def _parse_version_tuple(s: str) -> tuple:
return (0,) return (0,)
parts = [] parts = []
for seg in s.split("."): for seg in s.split("."):
digits = "".join(c for c in seg if c.isdigit()) digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
if digits: if digits:
try: try:
parts.append(int(digits)) parts.append(int(digits))
@@ -134,7 +131,7 @@ def fetch_latest_release(
method="GET", method="GET",
) )
try: try:
with urlopen(req, timeout=timeout) as resp: with build_github_opener().open(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode() code = getattr(resp, "status", None) or resp.getcode()
new_etag = resp.headers.get("ETag") new_etag = resp.headers.get("ETag")
raw = resp.read().decode("utf-8", errors="replace") raw = resp.read().decode("utf-8", errors="replace")
@@ -162,6 +159,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip() tag = (cache.get("tag_name") or "").strip()
if tag: if tag:
_apply_release_tag(tag, cache.get("html_url") or "", current_version) _apply_release_tag(tag, cache.get("html_url") or "", current_version)
_state["assets"] = cache.get("assets") or []
return return
err = cache.get("last_error") err = cache.get("last_error")
_state["error"] = ( _state["error"] = (
@@ -181,6 +179,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip() tag = (cache.get("tag_name") or "").strip()
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
_apply_release_tag(tag, url, current_version) _apply_release_tag(tag, url, current_version)
_state["assets"] = cache.get("assets") or []
if new_etag: if new_etag:
cache["etag"] = new_etag cache["etag"] = new_etag
_save_cache(cache_path, cache) _save_cache(cache_path, cache)
@@ -200,6 +199,13 @@ def run_check(current_version: str) -> None:
cache["etag"] = new_etag cache["etag"] = new_etag
cache["tag_name"] = tag cache["tag_name"] = tag
cache["html_url"] = html_url cache["html_url"] = html_url
assets = [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
]
_state["assets"] = assets
cache["assets"] = assets
cache.pop("last_error", None) cache.pop("last_error", None)
_save_cache(cache_path, cache) _save_cache(cache_path, cache)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e: except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
@@ -218,6 +224,101 @@ def run_check(current_version: str) -> None:
_state["html_url"] = RELEASES_PAGE_URL _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]: def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках).""" """Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state) 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
+351 -39
View File
@@ -2,13 +2,17 @@ from __future__ import annotations
import ctypes import ctypes
import os import os
import subprocess
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import winreg import winreg
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from proxy.utils import build_github_opener
try: try:
import pyperclip import pyperclip
@@ -40,7 +44,7 @@ from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, save_config, start_proxy, stop_proxy, tg_proxy_url,
) )
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
@@ -52,10 +56,44 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
) )
from ui.i18n import set_language, t
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
_win_mutex_handle = None
_ERROR_ALREADY_EXISTS = 183
def _acquire_win_mutex() -> bool | None:
global _win_mutex_handle
try:
kernel32 = ctypes.windll.kernel32
kernel32.CreateMutexW.restype = ctypes.c_void_p
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
handle = kernel32.CreateMutexW(None, True, "Local\\TgWsProxy_SingleInstance")
if kernel32.GetLastError() == _ERROR_ALREADY_EXISTS:
kernel32.CloseHandle(ctypes.c_void_p(handle))
return False
if not handle:
return None
_win_mutex_handle = handle
return True
except Exception:
return None
def _release_win_mutex() -> None:
global _win_mutex_handle
if _win_mutex_handle:
try:
kernel32 = ctypes.windll.kernel32
kernel32.ReleaseMutex(ctypes.c_void_p(_win_mutex_handle))
kernel32.CloseHandle(ctypes.c_void_p(_win_mutex_handle))
except Exception:
pass
_win_mutex_handle = None
ICON_PATH = str(Path(__file__).parent / "icon.ico") ICON_PATH = str(Path(__file__).parent / "icon.ico")
@@ -68,19 +106,247 @@ _u32.MessageBoxW.restype = ctypes.c_int
_MB_OK_ERR = 0x10 _MB_OK_ERR = 0x10
_MB_OK_INFO = 0x40 _MB_OK_INFO = 0x40
_MB_YESNO_Q = 0x24 _MB_YESNO_Q = 0x24
_MB_YESNOCANCEL_Q = 0x23
_IDYES = 6 _IDYES = 6
_IDNO = 7
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: def _show_error(text: str, title: Optional[str] = None) -> None:
_u32.MessageBoxW(None, text, title, _MB_OK_ERR) _u32.MessageBoxW(None, text, title or t("app.error_title"), _MB_OK_ERR)
def _show_info(text: str, title: str = "TG WS Proxy") -> None: def _show_info(text: str, title: Optional[str] = None) -> None:
_u32.MessageBoxW(None, text, title, _MB_OK_INFO) _u32.MessageBoxW(None, text, title or t("app.name"), _MB_OK_INFO)
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES return _u32.MessageBoxW(None, text, title or t("app.name"), _MB_YESNO_Q) == _IDYES
def update_ctk_form(
text: str, title: Optional[str] = None, download_url: Optional[str] = None,
release_url: Optional[str] = None,
) -> str:
title = title or t("app.name")
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES:
return "update"
if result == _IDNO:
return "open"
return "close"
result = {"value": "close"}
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
root = create_ctk_toplevel(
ctk,
title=title,
width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100,
theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
ctk.CTkLabel(
frame,
text=text,
justify="left",
anchor="w",
wraplength=270,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
).pack(fill="x", pady=(0, 10))
row = ctk.CTkFrame(frame, fg_color="transparent")
row.pack(fill="x")
status_label = ctk.CTkLabel(
frame, text="", justify="left", anchor="w", wraplength=270,
font=(theme.ui_font_family, 11), text_color=theme.text_secondary,
)
status_label.pack(fill="x", pady=(6, 0))
btns: list = []
def _set_status(msg: str) -> None:
root.after(0, lambda: status_label.configure(text=msg))
def _close_with(value: str) -> None:
result["value"] = value
root.destroy()
done.set()
def _on_update() -> None:
if not download_url:
if release_url:
webbrowser.open(release_url)
_close_with("open")
return
for b in btns:
b.configure(state="disabled")
root.protocol("WM_DELETE_WINDOW", lambda: None)
def _run():
_perform_update(download_url, set_status=_set_status)
root.after(0, lambda: [b.configure(state="normal") for b in btns])
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
threading.Thread(target=_run, daemon=True).start()
if IS_FROZEN:
btn_upd = ctk.CTkButton(
row, text=t("button.update"), width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update,
)
btn_upd.pack(side="left", padx=(0, 6))
btns.append(btn_upd)
btn_pg = ctk.CTkButton(
row, text=t("button.page"), width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
)
btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg)
btn_cl = ctk.CTkButton(
row, text=t("button.close"), width=88, height=34,
font=(theme.ui_font_family, 13),
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
command=lambda: _close_with("close"),
)
btn_cl.pack(side="left")
btns.append(btn_cl)
root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))
ctk_run_dialog(_build)
return result["value"]
def _perform_update(download_url: str, set_status=None) -> None:
def _step(msg: str) -> None:
log.info("Update: %s", msg)
if set_status:
set_status(msg)
time.sleep(0.8)
def _err(msg: str) -> None:
log.error("Update error: %s", msg)
if set_status:
set_status(f"{t('update.error', msg=msg)}")
else:
_show_error(msg)
_step(t("update.downloading"))
cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
tmp_path = None
try:
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
os.close(fd)
tmp_path = Path(tmp_name)
log.info("Downloading update from %s", download_url)
opener = build_github_opener()
with opener.open(download_url) as _resp:
with open(str(tmp_path), "wb") as _fout:
while True:
_chunk = _resp.read(65536)
if not _chunk:
break
_fout.write(_chunk)
except Exception as exc:
_err(t("update.download_fail", error=exc))
if tmp_path:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step(t("update.replacing"))
try:
if old_exe.exists():
old_exe.unlink()
cur_exe.rename(old_exe)
except Exception as exc:
_err(t("update.rename_fail", error=exc))
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
try:
tmp_path.rename(cur_exe)
except Exception as exc:
_err(t("update.move_fail", error=exc))
try:
old_exe.rename(cur_exe)
except OSError:
pass
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step(t("update.restarting"))
_release_win_mutex()
stop_proxy()
# Don't reuse existing _MEI* dir
env = os.environ.copy()
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
del env[_k]
if hasattr(sys, "_MEIPASS"):
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
env["PATH"] = os.pathsep.join(
p for p in env.get("PATH", "").split(os.pathsep)
if os.path.normcase(p.rstrip("\\/")) != _mei
)
try:
subprocess.Popen(
[str(cur_exe)],
env=env,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
except Exception as exc:
log.error("Failed to launch updated exe: %s", exc)
time.sleep(0.5)
os._exit(0)
def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True):
return
def _work():
time.sleep(1.5)
if is_exiting():
return
try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update") or is_exiting():
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start()
# autostart (registry) # autostart (registry)
@@ -118,9 +384,7 @@ def set_autostart_enabled(enabled: bool) -> None:
except OSError as exc: except OSError as exc:
log.error("Failed to update autostart: %s", exc) log.error("Failed to update autostart: %s", exc)
_show_error( _show_error(
"Не удалось изменить автозапуск.\n\n" t("dialog.autostart_fail", error=exc)
"Попробуйте запустить приложение от имени пользователя "
f"с правами на реестр.\n\nОшибка: {exc}"
) )
@@ -136,34 +400,30 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
log.info("Browser open failed, copying to clipboard") log.info("Browser open failed, copying to clipboard")
if pyperclip is None: if pyperclip is None:
_show_error( _show_error(
"Не удалось открыть Telegram автоматически.\n\n" t("dialog.open_tg_fail_manual", url=url)
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
) )
return return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
"Не удалось открыть Telegram автоматически.\n\n" t("dialog.open_tg_fail_clipboard", url=url)
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
) )
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(t("dialog.copy_fail", error=exc))
def _on_copy_link(icon=None, item=None) -> None: def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config) url = tg_proxy_url(_config)
log.info("Copying link: %s", url) log.info("Copying link: %s", url)
if pyperclip is None: if pyperclip is None:
_show_error( _show_error(t("dialog.pyperclip_missing"))
"Установите пакет pyperclip для копирования в буфер обмена."
)
return return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(t("dialog.copy_fail", error=exc))
def _on_restart(icon=None, item=None) -> None: def _on_restart(icon=None, item=None) -> None:
@@ -179,9 +439,13 @@ def _on_edit_config(icon=None, item=None) -> None:
def _on_open_logs(icon=None, item=None) -> None: def _on_open_logs(icon=None, item=None) -> None:
log.info("Opening log file: %s", LOG_FILE) log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists(): if LOG_FILE.exists():
try:
os.startfile(str(LOG_FILE)) os.startfile(str(LOG_FILE))
except Exception as exc:
log.error("Failed to open log file: %s", exc)
_show_error(t("dialog.log_open_fail", error=exc))
else: else:
_show_info("Файл логов ещё не создан.") _show_info(t("dialog.log_not_found"))
def _on_exit(icon=None, item=None) -> None: def _on_exit(icon=None, item=None) -> None:
@@ -201,7 +465,7 @@ def _on_exit(icon=None, item=None) -> None:
def _edit_config_dialog() -> None: def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.") _show_error(t("dialog.ctk_missing"))
return return
cfg = dict(_config) cfg = dict(_config)
@@ -216,46 +480,80 @@ def _edit_config_dialog() -> None:
h += 100 h += 100
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
def _refresh_tray_menu() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
widgets = install_tray_config_form( widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG, ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=_supports_autostart(), show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False), autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu,
) )
_original_appearance = ctk.get_appearance_mode()
def _restore_ui_locale() -> None:
set_language(_original_language)
_refresh_tray_menu()
def _finish() -> None: def _finish() -> None:
root.destroy() root.destroy()
done.set() done.set()
def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance)
_restore_ui_locale()
_finish()
def on_save() -> None: def on_save() -> None:
from tkinter import messagebox from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
if isinstance(merged, str): if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) messagebox.showerror(t("app.error_title"), merged, parent=root)
return return
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_restore_ui_locale()
_finish()
return
save_config(merged) save_config(merged)
_config.update(merged) _config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged) log.info("Config saved: %s", merged)
if _supports_autostart(): if _supports_autostart():
set_autostart_enabled(bool(merged.get("autostart", False))) set_autostart_enabled(bool(merged.get("autostart", False)))
_tray_icon.menu = _build_menu() _tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno( do_restart = messagebox.askyesno(
"Перезапустить?", t("dialog.restart_title"),
"Настройки сохранены.\n\nПерезапустить прокси сейчас?", t("dialog.restart_body"),
parent=root, parent=root,
) )
_finish() _finish()
if do_restart: if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish) root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish) install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
ctk_run_dialog(_build) ctk_run_dialog(_build)
@@ -278,7 +576,7 @@ def _show_first_run() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme, ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
@@ -303,14 +601,14 @@ def _build_menu():
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) link_host = get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem(t("tray.restart"), _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem(t("tray.settings"), _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit), pystray.MenuItem(t("tray.exit"), _on_exit),
) )
@@ -321,7 +619,7 @@ def run_tray() -> None:
_config = load_config() _config = load_config()
if is_windows_dark_theme: if is_windows_dark_theme():
apply_windows_dark_theme() apply_windows_dark_theme()
bootstrap(_config) bootstrap(_config)
@@ -337,11 +635,11 @@ def run_tray() -> None:
return return
start_proxy(_config, _show_error) start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) _maybe_do_update(_config, lambda: _exiting)
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) _tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@@ -350,13 +648,27 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if not acquire_lock("windows.py"): if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return return
if IS_FROZEN:
def _cleanup_old_exes():
exe_dir = Path(sys.executable).parent
time.sleep(3)
for _f in exe_dir.glob("*_oldtgws.exe"):
try:
_f.unlink()
log.info("Deleted leftover: %s", _f)
except OSError:
pass
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
try: try:
run_tray() run_tray()
finally: finally:
release_lock() release_lock()
_release_win_mutex()
if __name__ == "__main__": if __name__ == "__main__":